Skip to content

feat(DENG-11100): Create new base table zendesk_ticket_sla_v1 + KPI View#9338

Merged
phil-lee70 merged 10 commits into
mainfrom
deng-11100
May 15, 2026
Merged

feat(DENG-11100): Create new base table zendesk_ticket_sla_v1 + KPI View#9338
phil-lee70 merged 10 commits into
mainfrom
deng-11100

Conversation

@phil-lee70
Copy link
Copy Markdown
Contributor

@phil-lee70 phil-lee70 commented May 8, 2026

Description

This PR creates the zendesk_ticket_sla_v1 base table for the Customer Experience data model and a view for the KPI.

KPIs created by this table:

  • Time to First Response (TTFR) - Biz Hrs, Calendar Hrs, etc. - in the query it is called first_reply_time_*
  • Customer Satisfaction (CSAT)
  • First Contact Resolution (FCR)
  • Total Resolution Time (TRT) - in the query it is called full_resolution_time_*

KPI List:
user-data-sources-and-metrics

Customer Experience Project Planning Doc

Related Tickets & Documents

Validation

Compared this PR's query.sql against the original mzl-sumo-metrics/dashboard queries/zendesk_sla_metrics.sql at the per-ticket grain for first_reply_time_calendar_minutes and first_reply_time_business_minutes.

Date run: 2026-05-08

Alignment applied

  • Window: both restricted to last 12 months
  • Exclusions matched on both sides: Appbot Non-English, Sumo Test/VPN QA groups, %test%-tagged tickets
  • Dashboard query truncated before its weekly GROUP BY so per-ticket values are exposed

Row counts

metric new query dashboard
total rows 93,860 93,854
distinct ticket_ids 93,860 93,854
matched (inner join) 93,854 93,854
new-only 6
dash-only 0

Statistical profile

metric new dashboard
calendar_minutes null 53,947 53,944
calendar_minutes min/max/avg 0.0 / 61,843.6 / 1,277.4 0.0 / 61,843.6 / 1,277.5
business_minutes null 53,947 53,944
business_minutes min/max/avg 0.0 / 16,664.0 / 279.5 0.0 / 16,664.0 / 279.6

Per-ticket value diff (matched 93,854 tickets)

column match mismatch
first_reply_time_calendar_minutes 93,854 0
first_reply_time_business_minutes 93,854 0

Verdict: 100% per-ticket parity on both columns across the 93,854 tickets present in both queries. The avg/null-count differences are explained entirely by the 6 new-only tickets.

Why the 6 new-only tickets: they have group_id IS NULL. The dashboard query's filter g.name NOT IN ('Sumo Test', 'VPN QA') is NULL-unsafe — when g.name IS NULL the predicate is UNKNOWN and the row is silently dropped. The new query uses COALESCE(g.name, '') NOT IN (...), which correctly retains tickets without a group. This is an intentional bug fix in the new query, not a regression.

Validation query (click to expand)
-- ===== NEW QUERY per-ticket =====
CREATE TEMP TABLE new_per_ticket AS
WITH tickets_in_window AS (
  SELECT
    t.id AS ticket_id,
    t.created_at AS ticket_created_at,
    DATE(t.created_at) AS ticket_created_date,
    t.status,
    t.group_id,
    t.custom_product,
    COALESCE(m.product_mapping, t.custom_product) AS product
  FROM
    `moz-fx-data-shared-prod.zendesk_syndicate.ticket` AS t
  LEFT JOIN
    `moz-fx-data-shared-prod.static.cx_product_mappings_v1` AS m
    ON m.product = t.custom_product
    AND m.source = 'Zendesk'
  WHERE
    DATE(t.created_at)
    BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 1 YEAR)
    AND CURRENT_DATE()
    AND t.status != 'deleted'
),
ticket_comments_with_role AS (
  SELECT
    tc.ticket_id,
    tc.created AS comment_created_at,
    u.role AS author_role
  FROM
    `moz-fx-sumo-prod.zendesk.ticket_comment` AS tc
  JOIN
    `moz-fx-sumo-prod.zendesk.user` AS u
    ON tc.user_id = u.id
  JOIN
    tickets_in_window AS tw
    ON tw.ticket_id = tc.ticket_id
  WHERE
    tc.public = TRUE
    AND u.role IN ('agent', 'admin')
),
first_agent_reply AS (
  SELECT
    ticket_id,
    MIN(comment_created_at) AS first_reply_at
  FROM
    ticket_comments_with_role
  GROUP BY
    ticket_id
),
active_schedule AS (
  SELECT
    id AS schedule_id,
    ANY_VALUE(
      CASE
        time_zone
        WHEN 'Central Time (US & Canada)' THEN 'America/Chicago'
        WHEN 'Eastern Time (US & Canada)' THEN 'America/New_York'
        WHEN 'Mountain Time (US & Canada)' THEN 'America/Denver'
        WHEN 'Pacific Time (US & Canada)' THEN 'America/Los_Angeles'
        ELSE time_zone
      END
    ) AS schedule_tz
  FROM
    `moz-fx-sumo-prod.zendesk.schedule`
  WHERE
    name = 'Mozilla Support Hours'
  GROUP BY
    id
),
ticket_events AS (
  SELECT
    tw.ticket_id,
    tw.ticket_created_at,
    far.first_reply_at AS event_at,
    'first_reply' AS event_type
  FROM
    tickets_in_window AS tw
  JOIN
    first_agent_reply AS far
    ON tw.ticket_id = far.ticket_id
),
ticket_event_window AS (
  SELECT
    te.ticket_id,
    te.event_type,
    te.ticket_created_at,
    te.event_at,
    a.schedule_id,
    a.schedule_tz,
    ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1) AS calendar_minutes,
    DATETIME_DIFF(
      DATETIME(te.ticket_created_at, a.schedule_tz),
      DATETIME_TRUNC(DATETIME(te.ticket_created_at, a.schedule_tz), WEEK),
      SECOND
    ) / 60.0 AS start_time_in_minutes_from_week,
    GREATEST(
      0,
      ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 0)
    ) AS raw_delta_in_minutes
  FROM
    ticket_events AS te
  CROSS JOIN
    active_schedule AS a
),
weekly_periods AS (
  SELECT
    ticket_id,
    event_type,
    ticket_created_at,
    event_at,
    schedule_id,
    schedule_tz,
    calendar_minutes,
    start_time_in_minutes_from_week,
    raw_delta_in_minutes,
    week_number,
    GREATEST(
      0,
      start_time_in_minutes_from_week - week_number * (7 * 24 * 60)
    ) AS ticket_week_start_time,
    LEAST(
      start_time_in_minutes_from_week + raw_delta_in_minutes - week_number * (7 * 24 * 60),
      (7 * 24 * 60)
    ) AS ticket_week_end_time
  FROM
    ticket_event_window,
    UNNEST(
      GENERATE_ARRAY(
        0,
        CAST(
          FLOOR((start_time_in_minutes_from_week + raw_delta_in_minutes) / (7 * 24 * 60)) AS INT64
        ),
        1
      )
    ) AS week_number
),
intercepted_periods AS (
  SELECT
    wp.ticket_id,
    wp.event_type,
    wp.schedule_id,
    wp.schedule_tz,
    wp.week_number,
    wp.ticket_created_at,
    wp.event_at,
    wp.calendar_minutes,
    s.start_time AS schedule_interval_start,
    LEAST(wp.ticket_week_end_time, s.end_time) - GREATEST(
      wp.ticket_week_start_time,
      s.start_time
    ) AS scheduled_minutes
  FROM
    weekly_periods AS wp
  JOIN
    `moz-fx-sumo-prod.zendesk.schedule` AS s
    ON s.id = wp.schedule_id
    AND wp.ticket_week_start_time <= s.end_time
    AND wp.ticket_week_end_time >= s.start_time
),
intercepted_with_dates AS (
  SELECT
    ip.*,
    DATE_ADD(
      DATE_TRUNC(DATE(DATETIME(ticket_created_at, schedule_tz)), WEEK),
      INTERVAL CAST(7 * week_number + FLOOR(schedule_interval_start / 1440) AS INT64) DAY
    ) AS schedule_interval_date
  FROM
    intercepted_periods AS ip
),
holiday_minutes_per_event AS (
  SELECT
    iwd.ticket_id,
    iwd.event_type,
    SUM(iwd.scheduled_minutes) AS holiday_minutes
  FROM
    intercepted_with_dates AS iwd
  JOIN
    `moz-fx-sumo-prod.zendesk.schedule_holiday` AS sh
    ON sh.schedule_id = iwd.schedule_id
    AND iwd.schedule_interval_date
    BETWEEN DATE(sh.start_date)
    AND DATE(sh.end_date)
  GROUP BY
    iwd.ticket_id,
    iwd.event_type
),
business_event_times AS (
  SELECT
    te.ticket_id,
    te.event_type,
    te.event_at,
    ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1) AS calendar_minutes,
    ROUND(COALESCE(sched.business_minutes, 0), 1) AS business_minutes
  FROM
    ticket_events AS te
  LEFT JOIN
    (
      SELECT
        ip.ticket_id,
        ip.event_type,
        SUM(ip.scheduled_minutes) - COALESCE(MAX(hm.holiday_minutes), 0) AS business_minutes
      FROM
        intercepted_periods AS ip
      LEFT JOIN
        holiday_minutes_per_event AS hm
        ON ip.ticket_id = hm.ticket_id
        AND ip.event_type = hm.event_type
      GROUP BY
        ip.ticket_id,
        ip.event_type
    ) AS sched
    ON te.ticket_id = sched.ticket_id
    AND te.event_type = sched.event_type
),
appbot_class AS (
  SELECT
    tt.ticket_id,
    CASE
      WHEN LOGICAL_OR(tt.tag = 'appbot')
        AND LOGICAL_OR(tt.tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
        THEN 'Appbot - English'
      WHEN LOGICAL_OR(tt.tag = 'appbot')
        AND NOT LOGICAL_OR(tt.tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
        THEN 'Appbot - Non-English'
      ELSE 'All Other Tickets'
    END AS ticket_group
  FROM
    `moz-fx-sumo-prod.zendesk.ticket_tag` AS tt
  JOIN
    tickets_in_window AS tw
    ON tw.ticket_id = tt.ticket_id
  GROUP BY
    tt.ticket_id
),
test_tickets AS (
  SELECT DISTINCT
    tt.ticket_id
  FROM
    `moz-fx-sumo-prod.zendesk.ticket_tag` AS tt
  JOIN
    tickets_in_window AS tw
    ON tw.ticket_id = tt.ticket_id
  WHERE
    tt.tag LIKE '%test%'
)
SELECT
  tw.ticket_id,
  fr.calendar_minutes AS first_reply_time_calendar_minutes,
  fr.business_minutes AS first_reply_time_business_minutes
FROM
  tickets_in_window AS tw
LEFT JOIN
  business_event_times AS fr
  ON tw.ticket_id = fr.ticket_id
  AND fr.event_type = 'first_reply'
LEFT JOIN
  appbot_class AS ac
  ON tw.ticket_id = ac.ticket_id
LEFT JOIN
  test_tickets AS tt
  ON tw.ticket_id = tt.ticket_id
LEFT JOIN
  `moz-fx-sumo-prod.zendesk.group` AS g
  ON tw.group_id = g.id
WHERE
  COALESCE(ac.ticket_group, 'All Other Tickets') != 'Appbot - Non-English'
  AND COALESCE(g.name, '') NOT IN ('Sumo Test', 'VPN QA')
  AND tt.ticket_id IS NULL
;

-- ===== DASHBOARD QUERY per-ticket =====
CREATE TEMP TABLE dash_per_ticket AS
WITH
ticket_comments_with_role AS (
  SELECT
    tc.ticket_id,
    tc.created      AS comment_created_at,
    u.role          AS author_role
  FROM `moz-fx-sumo-prod.zendesk.ticket_comment` AS tc
  JOIN `moz-fx-sumo-prod.zendesk.user`           AS u
    ON tc.user_id = u.id
  WHERE tc.public = TRUE
),
first_agent_reply AS (
  SELECT
    ticket_id,
    MIN(comment_created_at) AS first_reply_at
  FROM ticket_comments_with_role
  WHERE author_role IN ('agent', 'admin')
  GROUP BY ticket_id
),
active_schedule AS (
  SELECT
    id AS schedule_id,
    ANY_VALUE(
      CASE time_zone
        WHEN 'Central Time (US & Canada)'  THEN 'America/Chicago'
        WHEN 'Eastern Time (US & Canada)'  THEN 'America/New_York'
        WHEN 'Mountain Time (US & Canada)' THEN 'America/Denver'
        WHEN 'Pacific Time (US & Canada)'  THEN 'America/Los_Angeles'
        ELSE time_zone
      END
    ) AS schedule_tz
  FROM `moz-fx-sumo-prod.zendesk.schedule`
  WHERE name = 'Mozilla Support Hours'
  GROUP BY id
),
ticket_events AS (
  SELECT
    t.id           AS ticket_id,
    t.created_at   AS ticket_created_at,
    far.first_reply_at AS event_at,
    'first_reply'  AS event_type
  FROM `moz-fx-sumo-prod.zendesk.ticket` AS t
  JOIN first_agent_reply AS far ON t.id = far.ticket_id
),
ticket_event_window AS (
  SELECT
    te.ticket_id,
    te.event_type,
    te.ticket_created_at,
    te.event_at,
    a.schedule_id,
    a.schedule_tz,
    ROUND(
      TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1
    ) AS calendar_minutes,
    DATETIME_DIFF(
      DATETIME(te.ticket_created_at, a.schedule_tz),
      DATETIME_TRUNC(DATETIME(te.ticket_created_at, a.schedule_tz), WEEK),
      SECOND
    ) / 60.0 AS start_time_in_minutes_from_week,
    GREATEST(
      0,
      ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 0)
    ) AS raw_delta_in_minutes
  FROM ticket_events AS te
  CROSS JOIN active_schedule AS a
),
weekly_periods AS (
  SELECT
    ticket_id,
    event_type,
    ticket_created_at,
    event_at,
    schedule_id,
    schedule_tz,
    calendar_minutes,
    start_time_in_minutes_from_week,
    raw_delta_in_minutes,
    week_number,
    GREATEST(
      0,
      start_time_in_minutes_from_week - week_number * (7 * 24 * 60)
    ) AS ticket_week_start_time,
    LEAST(
      start_time_in_minutes_from_week + raw_delta_in_minutes
        - week_number * (7 * 24 * 60),
      (7 * 24 * 60)
    ) AS ticket_week_end_time
  FROM ticket_event_window,
    UNNEST(
      GENERATE_ARRAY(
        0,
        CAST(
          FLOOR(
            (start_time_in_minutes_from_week + raw_delta_in_minutes) / (7 * 24 * 60)
          ) AS INT64
        ),
        1
      )
    ) AS week_number
),
intercepted_periods AS (
  SELECT
    wp.ticket_id,
    wp.event_type,
    wp.schedule_id,
    wp.schedule_tz,
    wp.week_number,
    wp.ticket_created_at,
    wp.event_at,
    wp.calendar_minutes,
    s.start_time AS schedule_interval_start,
    LEAST(wp.ticket_week_end_time, s.end_time)
      - GREATEST(wp.ticket_week_start_time, s.start_time) AS scheduled_minutes
  FROM weekly_periods AS wp
  JOIN `moz-fx-sumo-prod.zendesk.schedule` AS s
    ON  s.id = wp.schedule_id
    AND wp.ticket_week_start_time <= s.end_time
    AND wp.ticket_week_end_time   >= s.start_time
),
intercepted_with_dates AS (
  SELECT
    ip.*,
    DATE_ADD(
      DATE_TRUNC(DATE(DATETIME(ticket_created_at, schedule_tz)), WEEK),
      INTERVAL CAST(7 * week_number + FLOOR(schedule_interval_start / 1440) AS INT64) DAY
    ) AS schedule_interval_date
  FROM intercepted_periods ip
),
holiday_minutes_per_event AS (
  SELECT
    iwd.ticket_id,
    iwd.event_type,
    SUM(iwd.scheduled_minutes) AS holiday_minutes
  FROM intercepted_with_dates iwd
  JOIN `moz-fx-sumo-prod.zendesk.schedule_holiday` sh
    ON  sh.schedule_id = iwd.schedule_id
    AND iwd.schedule_interval_date BETWEEN DATE(sh.start_date) AND DATE(sh.end_date)
  GROUP BY iwd.ticket_id, iwd.event_type
),
business_event_times AS (
  SELECT
    te.ticket_id,
    te.event_type,
    te.event_at,
    ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1) AS calendar_minutes,
    ROUND(COALESCE(sched.business_minutes, 0), 1) AS business_minutes
  FROM ticket_events AS te
  LEFT JOIN (
    SELECT
      ip.ticket_id,
      ip.event_type,
      SUM(ip.scheduled_minutes) - COALESCE(MAX(hm.holiday_minutes), 0) AS business_minutes
    FROM intercepted_periods ip
    LEFT JOIN holiday_minutes_per_event hm
      ON  ip.ticket_id  = hm.ticket_id
      AND ip.event_type = hm.event_type
    GROUP BY 1, 2
  ) AS sched
    ON  te.ticket_id  = sched.ticket_id
    AND te.event_type = sched.event_type
),
appbot_class AS (
  SELECT
    ticket_id,
    CASE
      WHEN LOGICAL_OR(tag = 'appbot')
        AND LOGICAL_OR(tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
        THEN 'Appbot - English'
      WHEN LOGICAL_OR(tag = 'appbot')
        AND NOT LOGICAL_OR(tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
        THEN 'Appbot - Non-English'
      ELSE 'All Other Tickets'
    END AS ticket_group
  FROM `moz-fx-sumo-prod.zendesk.ticket_tag`
  GROUP BY ticket_id
)
SELECT
  t.id AS ticket_id,
  fr.calendar_minutes AS first_reply_time_calendar_minutes,
  fr.business_minutes AS first_reply_time_business_minutes
FROM `moz-fx-sumo-prod.zendesk.ticket` AS t
LEFT JOIN business_event_times AS fr
  ON  t.id = fr.ticket_id
  AND fr.event_type = 'first_reply'
LEFT JOIN appbot_class
  ON t.id = appbot_class.ticket_id
LEFT JOIN `moz-fx-sumo-prod.zendesk.group` g
  ON t.group_id = g.id
WHERE 1=1
  AND COALESCE(ticket_group, 'All Other Tickets') != 'Appbot - Non-English'
  AND g.name NOT IN ('Sumo Test', 'VPN QA')
  AND t.status != 'deleted'
  AND t.id NOT IN (SELECT DISTINCT ticket_id FROM `moz-fx-sumo-prod.zendesk.ticket_tag` WHERE tag LIKE '%test%')
  AND DATE(t.created_at) BETWEEN CURRENT_DATE - INTERVAL 1 YEAR AND CURRENT_DATE
;

-- ===== Comparison output =====

-- 1. Row counts
SELECT 'row_counts' AS check_name,
  (SELECT COUNT(*) FROM new_per_ticket) AS new_query_rows,
  (SELECT COUNT(DISTINCT ticket_id) FROM new_per_ticket) AS new_query_distinct_ticket_ids,
  (SELECT COUNT(*) FROM dash_per_ticket) AS dashboard_rows,
  (SELECT COUNT(DISTINCT ticket_id) FROM dash_per_ticket) AS dashboard_distinct_ticket_ids,
  (SELECT COUNT(*) FROM new_per_ticket n INNER JOIN dash_per_ticket d USING (ticket_id)) AS matched_rows,
  (SELECT COUNT(*) FROM new_per_ticket n LEFT JOIN dash_per_ticket d USING (ticket_id) WHERE d.ticket_id IS NULL) AS new_only,
  (SELECT COUNT(*) FROM dash_per_ticket d LEFT JOIN new_per_ticket n USING (ticket_id) WHERE n.ticket_id IS NULL) AS dash_only
;

-- 2. Statistical profile per side
SELECT
  'profile_new' AS source,
  COUNT(*) AS rows_total,
  COUNTIF(first_reply_time_calendar_minutes IS NULL) AS calendar_minutes_null,
  COUNTIF(first_reply_time_business_minutes IS NULL) AS business_minutes_null,
  ROUND(MIN(first_reply_time_calendar_minutes), 1) AS cal_min,
  ROUND(MAX(first_reply_time_calendar_minutes), 1) AS cal_max,
  ROUND(AVG(first_reply_time_calendar_minutes), 1) AS cal_avg,
  ROUND(MIN(first_reply_time_business_minutes), 1) AS biz_min,
  ROUND(MAX(first_reply_time_business_minutes), 1) AS biz_max,
  ROUND(AVG(first_reply_time_business_minutes), 1) AS biz_avg
FROM new_per_ticket
UNION ALL
SELECT
  'profile_dash',
  COUNT(*),
  COUNTIF(first_reply_time_calendar_minutes IS NULL),
  COUNTIF(first_reply_time_business_minutes IS NULL),
  ROUND(MIN(first_reply_time_calendar_minutes), 1),
  ROUND(MAX(first_reply_time_calendar_minutes), 1),
  ROUND(AVG(first_reply_time_calendar_minutes), 1),
  ROUND(MIN(first_reply_time_business_minutes), 1),
  ROUND(MAX(first_reply_time_business_minutes), 1),
  ROUND(AVG(first_reply_time_business_minutes), 1)
FROM dash_per_ticket
;

-- 3. Per-ticket diff: how many matched tickets agree/disagree on each column
SELECT
  'value_diff' AS check_name,
  COUNT(*) AS matched_tickets,
  COUNTIF(
    n.first_reply_time_calendar_minutes IS NOT DISTINCT FROM d.first_reply_time_calendar_minutes
  ) AS calendar_minutes_match,
  COUNTIF(
    n.first_reply_time_calendar_minutes IS DISTINCT FROM d.first_reply_time_calendar_minutes
  ) AS calendar_minutes_mismatch,
  COUNTIF(
    n.first_reply_time_business_minutes IS NOT DISTINCT FROM d.first_reply_time_business_minutes
  ) AS business_minutes_match,
  COUNTIF(
    n.first_reply_time_business_minutes IS DISTINCT FROM d.first_reply_time_business_minutes
  ) AS business_minutes_mismatch
FROM new_per_ticket n
INNER JOIN dash_per_ticket d USING (ticket_id)
;

-- 4. Sample of mismatched rows (top 20 by absolute calendar-minute diff)
SELECT
  'sample_mismatch' AS check_name,
  n.ticket_id,
  n.first_reply_time_calendar_minutes AS new_cal,
  d.first_reply_time_calendar_minutes AS dash_cal,
  n.first_reply_time_business_minutes AS new_biz,
  d.first_reply_time_business_minutes AS dash_biz,
  ROUND(
    ABS(
      COALESCE(n.first_reply_time_calendar_minutes, 0)
      - COALESCE(d.first_reply_time_calendar_minutes, 0)
    ),
    1
  ) AS abs_cal_diff,
  ROUND(
    ABS(
      COALESCE(n.first_reply_time_business_minutes, 0)
      - COALESCE(d.first_reply_time_business_minutes, 0)
    ),
    1
  ) AS abs_biz_diff
FROM new_per_ticket n
INNER JOIN dash_per_ticket d USING (ticket_id)
WHERE
  n.first_reply_time_calendar_minutes IS DISTINCT FROM d.first_reply_time_calendar_minutes
  OR n.first_reply_time_business_minutes IS DISTINCT FROM d.first_reply_time_business_minutes
ORDER BY abs_cal_diff DESC, abs_biz_diff DESC
LIMIT 20
;

Reviewer, please follow this checklist

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Claude finished @phil-lee70's task in 4m 51s —— View job


Claude Code Review

  • Read changed files
  • Read recommended_practices.md
  • Analyze SQL query, schema, metadata, and view
  • Post inline comments for actionable findings
  • Submit formal PR review with summary

Posted a formal PR review with 11 inline comments and a summary covering correctness risks, repo conventions, performance hygiene, and schema nits.

Top items to address before merge:

  • CSAT join can fan out tickets (no per-ticket dedupe in the csat CTE) — silently multiplies KPI numerators.
  • tag LIKE '%test%' is too broad — matches latest, tested, manifest, etc.
  • Time-zone ELSE fallback may pass non-IANA labels into DATETIME(...).

Conventions:

  • View name sumo_zendesk_sla_kpis_v1 carries a _v1 suffix that docs/reference/recommended_practices.md advises against for views.
  • PR body refers to it as sumo_zendesk_kpis_v1 but the directory is sumo_zendesk_sla_kpis_v1 — pick one.
  • Mixed source projects (zendesk_syndicate vs. moz-fx-sumo-prod.zendesk) — worth a one-line justification or unifying.

Non-blocking: add clustering, consolidate the duplicate schedule reads, reconsider integer-minute rounding, document reopen-detection edge cases, prefer BOOLEAN over INTEGER for is_one_touch.
• Branch: deng-11100

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Nice, well-documented introduction of a SUMO Zendesk SLA base table and KPI view. The business-minutes pipeline (week-expansion, schedule intersection, holiday subtraction) is laid out cleanly and the rolling-13-month, non-incremental rebuild strategy is well-justified for late-arriving CSAT/resolution updates. Metadata and schema descriptions are unusually thorough — appreciated.

A few cross-cutting observations; details inline.

Correctness / data-quality (please address before merge)

  • CSAT join can fan out tickets. The csat CTE is not deduplicated by ticket_id. If any ticket has more than one survey response (or more than one rating_scale answer per response), the final LEFT JOIN multiplies ticket rows and silently inflates every downstream KPI. Suggest a GROUP BY ticket_id (or QUALIFY ROW_NUMBER()) inside the CTE plus a quick spot-check query.
  • tag LIKE '%test%' is too broad — matches latest, tested, manifest, etc., dropping legitimate tickets from KPI denominators. Prefer an explicit allow-list or a word-boundary regex.
  • Time-zone fallback risk: the CASE mapping for time_zone falls through to the raw Zendesk label, which may not be a valid IANA zone for DATETIME(...). A defensive default plus a data check would make breakage loud rather than silent.

Conventions / repo guidance

  • View name carries _v1 suffix, which docs/reference/recommended_practices.md advises against (views should be a stable interface). The PR description also references it as sumo_zendesk_kpis_v1, but the directory is sumo_zendesk_sla_kpis_v1 — please pin one canonical name and update the cross-reference in zendesk_ticket_sla_v1/metadata.yaml.
  • Mixed source projects (moz-fx-data-shared-prod.zendesk_syndicate.ticket vs. moz-fx-sumo-prod.zendesk.*) — worth a one-line comment explaining why or, ideally, reading tickets from the same project as the joined tables to avoid silent schema/coverage drift.

Performance / hygiene (non-blocking)

  • Add clustering on product and automation_category at table-creation time — cheap now, expensive later.
  • The schedule table is read twice; folding start_time/end_time into the active_schedule CTE removes the foot-gun the inline comment correctly warns about.
  • raw_delta_in_minutes is rounded to whole minutes while neighboring fields are rounded to one decimal — consider keeping precision for the schedule-intersection bounds.
  • Consider whether the simple solved → open reopen detection is robust enough given that automation classification depends on it.

Schema nits

  • is_one_touch reads as a flag but is INTEGER; aligning on BOOLEAN (matching is_excluded_from_sla and survey_responded) would be more consistent.
  • Document the csat_percentage NULL semantics when no group has a rated ticket.

Overall the design is solid; the main thing I'd want resolved before this lands is the CSAT fan-out check and the test-tag matcher.

Comment on lines +378 to +388
csat AS (
SELECT
t_s.ticket_id,
a_s.rating_category
FROM
`moz-fx-sumo-prod.zendesk.csat_survey_answer` AS a_s
JOIN
`moz-fx-sumo-prod.zendesk.ticket_csat_survey` AS t_s
ON a_s.survey_response_id = t_s.survey_response_id
AND a_s.type = 'rating_scale'
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential row fan-out on CSAT join (correctness risk).

The csat CTE selects (ticket_id, rating_category) without grouping or de-duplicating. If ticket_csat_survey has more than one row per ticket (re-surveyed tickets, multiple survey responses, or test data), or if csat_survey_answer has more than one rating_scale answer per survey_response_id, the LEFT JOIN csat ON tw.ticket_id = csat.ticket_id at the bottom of the query will multiply ticket rows. Because every ticket-level metric (agent_reply_count, is_one_touch, business minutes, …) is then double-counted in the KPI view, this would silently inflate FCR and CSAT denominators.

Suggest aggregating to one row per ticket — e.g. pick the latest survey response, or MAX/ANY_VALUE the rating — and add a GROUP BY ticket_id here. A qualify ROW_NUMBER() OVER (PARTITION BY ticket_id ORDER BY survey_response_id DESC) = 1 works too. Worth verifying with a quick SELECT ticket_id, COUNT(*) FROM csat GROUP BY ticket_id HAVING COUNT(*) > 1 LIMIT 10 before merge.

Comment on lines +123 to +136
ANY_VALUE(
CASE
time_zone
WHEN 'Central Time (US & Canada)'
THEN 'America/Chicago'
WHEN 'Eastern Time (US & Canada)'
THEN 'America/New_York'
WHEN 'Mountain Time (US & Canada)'
THEN 'America/Denver'
WHEN 'Pacific Time (US & Canada)'
THEN 'America/Los_Angeles'
ELSE time_zone
END
) AS schedule_tz
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time-zone ELSE fallback can silently break business-minutes math.

DATETIME(ticket_created_at, schedule_tz) (used downstream) requires an IANA-style identifier such as America/Los_Angeles. The ELSE time_zone branch passes Zendesk's raw time_zone string through unmodified — if Zendesk ever returns a friendly label not in the explicit list (e.g. "Alaska", "Arizona", "London"), the query will either fail at runtime or compute against UTC, producing wrong business-minute values.

Two suggestions:

  1. Filter the name = 'Mozilla Support Hours' clause in this CTE so we know which TZ values are actually possible, and document the mapping is exhaustive for that schedule, or
  2. Replace ELSE time_zone with ELSE 'UTC' plus a defensive ASSERT / data check that fails the run if an unmapped label appears, so the breakage is loud rather than silent.

Either way, please add a short comment naming the assumption — today the only protection is the schedule's name filter on line 140.

Comment on lines +367 to +377
test_tickets AS (
SELECT DISTINCT
tt.ticket_id
FROM
`moz-fx-sumo-prod.zendesk.ticket_tag` AS tt
JOIN
tickets_in_window AS tw
ON tw.ticket_id = tt.ticket_id
WHERE
tt.tag LIKE '%test%'
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tag LIKE '%test%' is overly broad.

This will also match tags such as latest, tested, manifest, protest, contest, attestation, etc., flagging legitimate tickets as is_excluded_from_sla = TRUE and silently dropping them from KPI denominators.

If the intent is the literal token test, prefer either:

  • An explicit allow-list of known test tags (tag IN ('test', 'qa-test', 'internal-test', …)), or
  • Word-boundary matching: REGEXP_CONTAINS(tag, r'(?:^|[-_])test(?:$|[-_])').

Worth confirming with the SUMO support team which tags are actually used to mark test tickets and pinning the list.

Comment on lines +230 to +254
-- Intersect each week's window with the schedule. The s.id = wp.schedule_id filter is
-- critical — without it every schedule in the table contributes, multiplying minutes.
intercepted_periods AS (
SELECT
wp.ticket_id,
wp.event_type,
wp.schedule_id,
wp.schedule_tz,
wp.week_number,
wp.ticket_created_at,
wp.event_at,
wp.calendar_minutes,
s.start_time AS schedule_interval_start,
LEAST(wp.ticket_week_end_time, s.end_time) - GREATEST(
wp.ticket_week_start_time,
s.start_time
) AS scheduled_minutes
FROM
weekly_periods AS wp
JOIN
`moz-fx-sumo-prod.zendesk.schedule` AS s
ON s.id = wp.schedule_id
AND wp.ticket_week_start_time <= s.end_time
AND wp.ticket_week_end_time >= s.start_time
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schedule table is read twice; consider consolidating.

active_schedule already pre-filters to the single Mozilla Support Hours row but only keeps schedule_id and schedule_tz. Here the full schedule table is re-scanned to get start_time/end_time, with s.id = wp.schedule_id as the only safety net (called out correctly in the comment above).

Two equivalent simplifications that remove the foot-gun entirely:

  1. Pre-filter in this join itself: JOIN \moz-fx-sumo-prod.zendesk.schedule` AS s ON s.name = 'Mozilla Support Hours' AND s.id = wp.schedule_id`, or
  2. Extend active_schedule to also keep start_time/end_time (one row per slot, not aggregated) and join only against that.

Option 2 is cleaner and means there's no scenario where someone deletes the s.id = wp.schedule_id predicate and silently multiplies minutes.

Comment on lines +187 to +191
-- GREATEST guards against any data weirdness where event_at < created_at.
GREATEST(
0,
ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 0)
) AS raw_delta_in_minutes
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integer-minute rounding here can drift from calendar_minutes.

raw_delta_in_minutes is rounded to whole minutes (decimals=0) while calendar_minutes upstream and business_minutes downstream are rounded to one decimal. Because the weekly-period expansion uses raw_delta_in_minutes to derive the upper bound of GENERATE_ARRAY(...), a ticket whose actual delta is e.g. 90.4 minutes uses 90 minutes for the schedule intersection, which can produce business-minute totals slightly inconsistent with calendar-minute totals (and with what Zendesk Explore shows).

If the goal is to keep GENERATE_ARRAY integer-bounded, leave this as-is but compute the schedule-intersection bounds against (start_time_in_minutes_from_week + raw_delta_in_minutes_float) so the precision is preserved at the boundary. Otherwise consider rounding everything consistently to 1 decimal.

Comment on lines +43 to +47
bigquery:
time_partitioning:
type: day
field: ticket_created_date
require_partition_filter: false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding clustering.

The table will be filtered downstream almost exclusively by product, automation_category, ticket_group, and is_excluded_from_sla (see the KPI view). Adding a clustering block alongside time_partitioning would meaningfully reduce bytes scanned for those queries — example:

bigquery:
  time_partitioning:
    type: day
    field: ticket_created_date
    require_partition_filter: false
  clustering:
    fields:
      - product
      - automation_category

Not blocking, but cheap to add at table-creation time and expensive to add later (requires a backfill).

Comment on lines +1 to +3
CREATE OR REPLACE VIEW
`moz-fx-data-shared-prod.sumo_metrics_derived.sumo_zendesk_sla_kpis_v1`
AS
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View name carries a _v1 version suffix.

Per docs/reference/recommended_practices.md: "Views should generally not be named with a version suffix; a view represents a stable interface for users and whenever possible should maintain compatibility with existing queries."

Suggest renaming the directory to sumo_zendesk_sla_kpis/ (no suffix) so future schema iterations can land non-breaking changes without a v2 cutover for downstream dashboards. Some _derived views in the repo do carry a _v1_live suffix, but that's a different convention used for live (streaming) variants and doesn't apply here.

Also worth considering: the description in the PR body refers to this as sumo_zendesk_kpis_v1, but the directory is named sumo_zendesk_sla_kpis_v1. Pick one canonical name and update the metadata cross-reference in zendesk_ticket_sla_v1/metadata.yaml (line 32).

Comment on lines +23 to +36
automation_category,
COUNT(*) AS tickets_resolved,
COUNTIF(is_one_touch = 1) AS one_touch_tickets,
COUNTIF(rating_category = 'good') AS satisfied_surveys,
COUNTIF(rating_category IN ('good', 'bad')) AS rated_surveys,
-- FCR (%) = one-touch resolved tickets / total resolved tickets * 100.
-- Mirrors Zendesk Explore's "% One-touch tickets" metric.
SAFE_DIVIDE(COUNTIF(is_one_touch = 1), COUNT(*)) * 100 AS fcr_percentage,
-- CSAT (%) = good ratings / (good + bad ratings) * 100.
-- Tickets without a rating are excluded from the denominator.
SAFE_DIVIDE(
COUNTIF(rating_category = 'good'),
COUNTIF(rating_category IN ('good', 'bad'))
) * 100 AS csat_percentage
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KPI numerators count tickets without a CSAT survey as "satisfied = no".

COUNTIF(rating_category = 'good') returns satisfied_surveys, but the field naming is slightly misleading — satisfied_surveys actually counts the subset of resolved tickets (after is_excluded_from_sla = FALSE) that received a good rating, not the subset of surveys. Likewise rated_surveys counts the rated subset of resolved tickets, not the survey universe.

Two minor improvements:

  1. Rename to satisfied_tickets / rated_tickets so the grain matches the column name, or
  2. Filter resolved_tickets to WHERE rating_category IS NOT NULL in a separate CTE for the survey numerators so the names are accurate.

Also: csat_percentage will be NULL when no tickets in the group received any rating (because SAFE_DIVIDE(0, 0) IS NULL). Consumers should be aware — worth documenting on the metadata description.

Comment on lines +82 to +87
- name: is_one_touch
type: INTEGER
mode: NULLABLE
description: |
1 if the ticket was resolved with fewer than two agent replies (FCR proxy);
0 otherwise. Unresolved tickets are 0.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_one_touch would read more naturally as BOOLEAN.

It's only ever 0 or 1 and is consumed as a flag (the KPI view does COUNTIF(is_one_touch = 1)). A BOOLEAN column with COUNTIF(is_one_touch) is more idiomatic and matches survey_responded / is_excluded_from_sla already in this schema.

Same observation for the INTEGER choice — is_excluded_from_sla is BOOLEAN, so the schema currently uses both conventions for what are conceptually flags. Aligning on BOOLEAN everywhere is the cleaner choice; just be aware it's a breaking change for any consumer that already does is_one_touch = 1.

Comment on lines +8 to +26
WITH tickets_in_window AS (
SELECT
t.id AS ticket_id,
t.created_at AS ticket_created_at,
DATE(t.created_at) AS ticket_created_date,
t.status,
t.group_id,
t.custom_product,
COALESCE(m.product_mapping, t.custom_product) AS product
FROM
`moz-fx-data-shared-prod.zendesk_syndicate.ticket` AS t
LEFT JOIN
`moz-fx-data-shared-prod.static.cx_product_mappings_v1` AS m
ON m.product = t.custom_product
AND m.source = 'Zendesk'
WHERE
DATE(t.created_at)
BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 13 MONTH)
AND CURRENT_DATE()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed source projects — please double-check.

tickets_in_window reads from moz-fx-data-shared-prod.zendesk_syndicate.ticket, while every other Zendesk source in this query (ticket_comment, user, ticket_field_history, ticket_tag, schedule, schedule_holiday, csat_survey_answer, ticket_csat_survey, group) reads from moz-fx-sumo-prod.zendesk.*.

Two risks:

  1. Schema drift between the two copies — if zendesk_syndicate.ticket lags or has different filters than moz-fx-sumo-prod.zendesk.ticket, the join keys (ticket_id) may silently miss tickets that exist in one but not the other.
  2. Cross-project query cost — depending on access patterns, querying both projects in one job is fine but worth verifying the dry-run cost.

If moz-fx-sumo-prod.zendesk.ticket exists, prefer reading from a single project for consistency. If zendesk_syndicate.ticket is the canonical source for SUMO derived datasets going forward, please add a one-line comment explaining why so future readers don't second-guess the choice.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment was marked as low quality.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@phil-lee70 phil-lee70 requested a review from kpham-mozilla May 13, 2026 16:23
@phil-lee70 phil-lee70 added this pull request to the merge queue May 15, 2026
@phil-lee70 phil-lee70 removed this pull request from the merge queue due to a manual request May 15, 2026
@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

Integration report for "feat(DENG-11100): Create new base table zendesk_ticket_sla_v1 + KPI View"

sql.diff

Click to expand!
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/dags/bqetl_sumo_metrics.py /tmp/workspace/generated-dags/dags/bqetl_sumo_metrics.py
--- /tmp/workspace/main-generated-sql/dags/bqetl_sumo_metrics.py	2026-05-15 13:13:06.931745150 +0000
+++ /tmp/workspace/generated-dags/dags/bqetl_sumo_metrics.py	2026-05-15 13:13:05.651765696 +0000
@@ -153,6 +153,18 @@
         task_concurrency=1,
     )
 
+    sumo_metrics_derived__zendesk_ticket_sla__v1 = bigquery_etl_query(
+        task_id="sumo_metrics_derived__zendesk_ticket_sla__v1",
+        destination_table="zendesk_ticket_sla_v1",
+        dataset_id="sumo_metrics_derived",
+        project_id="moz-fx-data-shared-prod",
+        owner="phlee@mozilla.com",
+        email=["phlee@mozilla.com", "telemetry-alerts@mozilla.com"],
+        date_partition_parameter=None,
+        depends_on_past=False,
+        task_concurrency=1,
+    )
+
     sumo_metrics_derived__zendesk_tickets_base__v1 = bigquery_etl_query(
         task_id="sumo_metrics_derived__zendesk_tickets_base__v1",
         destination_table="zendesk_tickets_base_v1",
Only in /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived: sumo_zendesk_sla_kpis
Only in /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived: zendesk_ticket_sla_v1
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/metadata.yaml /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/metadata.yaml
--- /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/metadata.yaml	1970-01-01 00:00:00.000000000 +0000
+++ /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/metadata.yaml	2026-05-15 13:13:04.092791047 +0000
@@ -0,0 +1,36 @@
+friendly_name: SUMO Zendesk SLA KPIs (CSAT, FCR, TTFR, TRT)
+description: "Daily SLA KPI view for SUMO Zendesk support tickets, aggregated from\n\
+  zendesk_ticket_sla_v1. All metrics are attributed to the ticket's resolution\ndate\
+  \ and mirror Zendesk Explore.\n\nGrain: resolved_date x product x automation_category.\n\
+  \nTickets where is_excluded_from_sla = TRUE in the base table are filtered out\n\
+  (Appbot non-English, Sumo Test / VPN QA groups, %test% tags). Unresolved\ntickets\
+  \ are also excluded \u2014 these KPIs are about resolved-ticket outcomes.\n\nExposes\
+  \ sum/count component columns rather than precomputed rates so KPIs\nremain correct\
+  \ under any downstream filter or grouping. Compute as:\n  FCR %        = SUM(one_touch_tickets)\
+  \                    / NULLIF(SUM(tickets_resolved), 0) * 100\n  CSAT %       =\
+  \ SUM(satisfied_surveys)                    / NULLIF(SUM(rated_surveys),     0)\
+  \ * 100\n  Avg TTFR min = SUM(first_reply_business_minutes_sum)     / NULLIF(SUM(replied_tickets),\
+  \   0)\n  Avg TRT min  = SUM(full_resolution_business_minutes_sum) / NULLIF(SUM(tickets_resolved),\
+  \ 0)\n\nKPI definitions:\n- FCR: one-touch tickets (resolved with fewer than two\
+  \ agent replies) divided\n  by total resolved tickets. Mirrors \"% One-touch tickets\"\
+  \ in Zendesk Explore.\n- CSAT: 'good' ratings divided by rated tickets ('good' +\
+  \ 'bad'). Tickets\n  without a rating are excluded from the denominator.\n- TTFR\
+  \ (Time to First Response): business minutes between ticket creation and\n  the\
+  \ first public agent/admin reply, computed against Mozilla Support Hours\n  with\
+  \ holiday subtraction. Tickets auto-solved without an agent reply are\n  excluded\
+  \ from the average via replied_tickets.\n- TRT (Total Resolution Time): business\
+  \ minutes between ticket creation and\n  full resolution (last 'solved' timestamp,\
+  \ falling back to 'closed').\n"
+owners:
+- phlee@mozilla.com
+- mozilla/customer_experience
+labels:
+  owner1: phlee
+workgroup_access:
+- role: roles/bigquery.dataViewer
+  members:
+  - workgroup:mozilla-confidential/data-viewers
+  - workgroup:sumo/bq-automation
+references:
+  view.sql:
+  - moz-fx-data-shared-prod.sumo_metrics_derived.zendesk_ticket_sla_v1
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/view.sql /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/view.sql
--- /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/view.sql	1970-01-01 00:00:00.000000000 +0000
+++ /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/sumo_zendesk_sla_kpis/view.sql	2026-05-15 13:13:04.097790965 +0000
@@ -0,0 +1,46 @@
+CREATE OR REPLACE VIEW
+  `moz-fx-data-shared-prod.sumo_metrics_derived.sumo_zendesk_sla_kpis`
+AS
+-- SLA KPI components aggregated from zendesk_ticket_sla_v1, attributed to the
+-- date the ticket was resolved (mirrors Zendesk Explore).
+--
+-- Exposes sum/count component columns rather than precomputed rates so the
+-- KPIs remain correct under any downstream filter or grouping. Compute as:
+--   FCR %        = SUM(one_touch_tickets)                    / NULLIF(SUM(tickets_resolved), 0) * 100
+--   CSAT %       = SUM(satisfied_surveys)                    / NULLIF(SUM(rated_surveys),     0) * 100
+--   Avg TTFR min = SUM(first_reply_business_minutes_sum)     / NULLIF(SUM(replied_tickets),   0)
+--   Avg TRT min  = SUM(full_resolution_business_minutes_sum) / NULLIF(SUM(tickets_resolved), 0)
+WITH resolved_tickets AS (
+  SELECT
+    DATE(full_resolution_at) AS resolved_date,
+    product,
+    automation_category,
+    is_one_touch,
+    rating_category,
+    first_reply_time_business_minutes,
+    full_resolution_time_business_minutes
+  FROM
+    `moz-fx-data-shared-prod.sumo_metrics_derived.zendesk_ticket_sla_v1`
+  WHERE
+    NOT is_excluded_from_sla
+    AND resolution_status = 'Resolved'
+)
+SELECT
+  resolved_date AS `date`,
+  product,
+  automation_category,
+  COUNT(*) AS tickets_resolved,
+  COUNTIF(is_one_touch = 1) AS one_touch_tickets,
+  COUNTIF(rating_category = 'good') AS satisfied_surveys,
+  COUNTIF(rating_category IN ('good', 'bad')) AS rated_surveys,
+  -- TTFR denominator: not every resolved ticket has a first agent reply
+  -- (auto-solved tickets can resolve without one), so use a dedicated count.
+  COUNTIF(first_reply_time_business_minutes IS NOT NULL) AS replied_tickets,
+  SUM(first_reply_time_business_minutes) AS first_reply_business_minutes_sum,
+  SUM(full_resolution_time_business_minutes) AS full_resolution_business_minutes_sum
+FROM
+  resolved_tickets
+GROUP BY
+  resolved_date,
+  product,
+  automation_category
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/metadata.yaml /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/metadata.yaml
--- /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/metadata.yaml	1970-01-01 00:00:00.000000000 +0000
+++ /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/metadata.yaml	2026-05-15 13:13:04.092791047 +0000
@@ -0,0 +1,62 @@
+friendly_name: Zendesk Ticket SLA (Per-Ticket)
+description: "Ticket-level SLA snapshot for SUMO Zendesk support tickets.\n\nGrain:\
+  \ One row per ticket (rolling 13-month window keyed on ticket_created_date).\n\n\
+  Single source of truth for:\n- First Reply Time (TTFR) \u2014 calendar and business\
+  \ minutes\n- Full Resolution Time (FRT) \u2014 calendar and business minutes\n-\
+  \ First Contact Resolution / one-touch flag (is_one_touch)\n- CSAT survey response\
+  \ and rating_category\n- Reopen count and resolution status\n\nBusiness minutes\
+  \ are computed against Zendesk's \"Mozilla Support Hours\" schedule\n(Mon-Fri 8AM-5PM\
+  \ CT, DST-aware) with holidays subtracted via schedule_holiday.\nMirrors Zendesk's\
+  \ UI calculation and Fivetran's reference query.\n\nLate-arriving updates: full_resolution_at,\
+  \ rating_category, and reopen_count can\nchange weeks after a ticket is created\
+  \ (status flips, late survey responses).\nThis table is rebuilt non-incrementally\
+  \ over a rolling 13-month window so those\nchanges flow through without manual partition\
+  \ rewrites.\n\nFilter dimensions:\n- product (mapped via cx_product_mappings_v1)\n\
+  - ticket_group (Appbot - English / Appbot - Non-English / All Other Tickets)\n-\
+  \ automation_category (automation / human-handled, with reopens reclassified)\n\
+  - is_excluded_from_sla \u2014 true for Appbot non-English, Sumo Test / VPN QA groups,\n\
+  \  and tickets tagged '%test%'. Most SLA dashboards filter where false.\n\nUsed\
+  \ by:\n- SUMO support SLA dashboards (TTFR, FRT, FCR, CSAT)\n- sumo_zendesk_sla_kpis_v1\
+  \ (KPI view layer)\n- Ad-hoc SLA distribution analyses (medians, percentiles)\n"
+owners:
+- phlee@mozilla.com
+- mozilla/customer_experience
+labels:
+  application: sumo
+  team: support
+  type: base_metrics
+  schedule: daily
+  dag: bqetl_sumo_metrics
+  owner1: phlee
+scheduling:
+  dag_name: bqetl_sumo_metrics
+  date_partition_parameter: null
+  depends_on_past: false
+bigquery:
+  time_partitioning:
+    type: day
+    field: ticket_created_date
+    require_partition_filter: false
+    expiration_days: null
+  clustering:
+    fields:
+    - product
+    - automation_category
+workgroup_access:
+- role: roles/bigquery.dataViewer
+  members:
+  - workgroup:mozilla-confidential/data-viewers
+  - workgroup:sumo/bq-automation
+references:
+  query.sql:
+  - moz-fx-data-shared-prod.static.cx_product_mappings_v1
+  - moz-fx-data-shared-prod.zendesk_syndicate.csat_survey_answer
+  - moz-fx-data-shared-prod.zendesk_syndicate.group
+  - moz-fx-data-shared-prod.zendesk_syndicate.schedule
+  - moz-fx-data-shared-prod.zendesk_syndicate.schedule_holiday
+  - moz-fx-data-shared-prod.zendesk_syndicate.ticket
+  - moz-fx-data-shared-prod.zendesk_syndicate.ticket_comment
+  - moz-fx-data-shared-prod.zendesk_syndicate.ticket_csat_survey
+  - moz-fx-data-shared-prod.zendesk_syndicate.ticket_field_history
+  - moz-fx-data-shared-prod.zendesk_syndicate.ticket_tag
+  - moz-fx-data-shared-prod.zendesk_syndicate.user
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/query.sql /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/query.sql
--- /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/query.sql	1970-01-01 00:00:00.000000000 +0000
+++ /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/query.sql	2026-05-15 13:13:04.097790965 +0000
@@ -0,0 +1,481 @@
+-- Ticket-level SLA snapshot for Zendesk support tickets.
+-- One row per ticket with first reply, full resolution, FCR, and CSAT metrics.
+-- Business minutes computed against "Mozilla Support Hours" with holiday subtraction,
+-- mirroring the calculation Zendesk surfaces in its UI.
+--
+-- Rebuilt over a rolling 13-month window so late-arriving updates
+-- (full_resolution status flips, CSAT survey responses, reopens) flow through.
+WITH tickets_in_window AS (
+  SELECT
+    t.id AS ticket_id,
+    t.created_at AS ticket_created_at,
+    DATE(t.created_at) AS ticket_created_date,
+    t.status,
+    t.group_id,
+    t.assignee_id,
+    t.custom_product,
+    COALESCE(m.product_mapping, t.custom_product) AS product
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket` AS t
+  LEFT JOIN
+    `moz-fx-data-shared-prod.static.cx_product_mappings_v1` AS m
+    ON m.product = t.custom_product
+    AND m.source = 'Zendesk'
+  WHERE
+    DATE(t.created_at)
+    BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 13 MONTH)
+    AND CURRENT_DATE()
+    AND t.status != 'deleted'
+),
+-- Public agent/admin replies, scoped to in-window tickets.
+ticket_comments_with_role AS (
+  SELECT
+    tc.ticket_id,
+    tc.created AS comment_created_at,
+    u.role AS author_role
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_comment` AS tc
+  JOIN
+    `moz-fx-data-shared-prod.zendesk_syndicate.user` AS u
+    ON tc.user_id = u.id
+  JOIN
+    tickets_in_window AS tw
+    ON tw.ticket_id = tc.ticket_id
+  WHERE
+    tc.public = TRUE
+    AND u.role IN ('agent', 'admin')
+),
+first_agent_reply AS (
+  SELECT
+    ticket_id,
+    MIN(comment_created_at) AS first_reply_at
+  FROM
+    ticket_comments_with_role
+  GROUP BY
+    ticket_id
+),
+agent_reply_counts AS (
+  SELECT
+    ticket_id,
+    COUNT(*) AS agent_reply_count
+  FROM
+    ticket_comments_with_role
+  GROUP BY
+    ticket_id
+),
+-- Status history scoped to in-window tickets, used for solve/close timestamps and reopen counts.
+status_history AS (
+  SELECT
+    h.ticket_id,
+    h.value AS status_value,
+    h.updated AS status_updated_at
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_field_history` AS h
+  JOIN
+    tickets_in_window AS tw
+    ON tw.ticket_id = h.ticket_id
+  WHERE
+    h.field_name = 'status'
+),
+-- Full resolution = last 'solved' timestamp; falls back to last 'closed' for tickets that
+-- went straight from open → closed without a 'solved' event.
+ticket_full_solved AS (
+  SELECT
+    ticket_id,
+    COALESCE(
+      MAX(CASE WHEN status_value = 'solved' THEN status_updated_at END),
+      MAX(CASE WHEN status_value = 'closed' THEN status_updated_at END)
+    ) AS full_resolution_at,
+    MAX(CASE WHEN status_value = 'solved' THEN status_updated_at END) AS solved_at,
+    MAX(CASE WHEN status_value = 'closed' THEN status_updated_at END) AS closed_at
+  FROM
+    status_history
+  GROUP BY
+    ticket_id
+),
+-- A reopen is a 'solved' → 'open' transition.
+reopens AS (
+  SELECT
+    ticket_id,
+    COUNTIF(prev_status = 'solved' AND status_value = 'open') AS reopen_count
+  FROM
+    (
+      SELECT
+        ticket_id,
+        status_value,
+        LAG(status_value, 1, 'new') OVER (
+          PARTITION BY
+            ticket_id
+          ORDER BY
+            status_updated_at
+        ) AS prev_status
+      FROM
+        status_history
+    )
+  GROUP BY
+    ticket_id
+),
+-- Active schedule for business-hours math. ticket_schedule (Enterprise-only) isn't in
+-- this dataset, so a single schedule applies to all tickets.
+-- Map Zendesk's friendly time-zone label to a DST-aware IANA zone for DATETIME().
+active_schedule AS (
+  SELECT
+    id AS schedule_id,
+    ANY_VALUE(
+      CASE
+        time_zone
+        WHEN 'Central Time (US & Canada)'
+          THEN 'America/Chicago'
+        WHEN 'Eastern Time (US & Canada)'
+          THEN 'America/New_York'
+        WHEN 'Mountain Time (US & Canada)'
+          THEN 'America/Denver'
+        WHEN 'Pacific Time (US & Canada)'
+          THEN 'America/Los_Angeles'
+        -- Fallback to UTC so unmapped Zendesk labels don't blow up DATETIME().
+        ELSE 'UTC'
+      END
+    ) AS schedule_tz
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.schedule`
+  WHERE
+    name = 'Mozilla Support Hours'
+  GROUP BY
+    id
+),
+-- Unify first_reply and full_resolution into one event stream so the schedule/holiday
+-- pipeline runs once per (ticket × event) instead of being duplicated per metric.
+ticket_events AS (
+  SELECT
+    tw.ticket_id,
+    tw.ticket_created_at,
+    far.first_reply_at AS event_at,
+    'first_reply' AS event_type
+  FROM
+    tickets_in_window AS tw
+  JOIN
+    first_agent_reply AS far
+    ON tw.ticket_id = far.ticket_id
+  UNION ALL
+  SELECT
+    tw.ticket_id,
+    tw.ticket_created_at,
+    tfs.full_resolution_at,
+    'full_resolution'
+  FROM
+    tickets_in_window AS tw
+  JOIN
+    ticket_full_solved AS tfs
+    ON tw.ticket_id = tfs.ticket_id
+  WHERE
+    tfs.full_resolution_at IS NOT NULL
+),
+-- Convert ticket times into the schedule's local time zone. schedule.start_time/end_time
+-- are minutes-from-Sunday-midnight in the schedule's own TZ, not UTC.
+ticket_event_window AS (
+  SELECT
+    te.ticket_id,
+    te.event_type,
+    te.ticket_created_at,
+    te.event_at,
+    a.schedule_id,
+    a.schedule_tz,
+    ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1) AS calendar_minutes,
+    DATETIME_DIFF(
+      DATETIME(te.ticket_created_at, a.schedule_tz),
+      DATETIME_TRUNC(DATETIME(te.ticket_created_at, a.schedule_tz), WEEK),
+      SECOND
+    ) / 60.0 AS start_time_in_minutes_from_week,
+    -- GREATEST guards against any data weirdness where event_at < created_at.
+    GREATEST(
+      0,
+      ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 0)
+    ) AS raw_delta_in_minutes
+  FROM
+    ticket_events AS te
+  CROSS JOIN
+    active_schedule AS a
+),
+-- Expand each (ticket × event) into one row per calendar week the event spans.
+weekly_periods AS (
+  SELECT
+    ticket_id,
+    event_type,
+    ticket_created_at,
+    event_at,
+    schedule_id,
+    schedule_tz,
+    calendar_minutes,
+    start_time_in_minutes_from_week,
+    raw_delta_in_minutes,
+    week_number,
+    GREATEST(
+      0,
+      start_time_in_minutes_from_week - week_number * (7 * 24 * 60)
+    ) AS ticket_week_start_time,
+    LEAST(
+      start_time_in_minutes_from_week + raw_delta_in_minutes - week_number * (7 * 24 * 60),
+      (7 * 24 * 60)
+    ) AS ticket_week_end_time
+  FROM
+    ticket_event_window,
+    UNNEST(
+      GENERATE_ARRAY(
+        0,
+        CAST(
+          FLOOR((start_time_in_minutes_from_week + raw_delta_in_minutes) / (7 * 24 * 60)) AS INT64
+        ),
+        1
+      )
+    ) AS week_number
+),
+-- Intersect each week's window with the schedule. The s.id = wp.schedule_id filter is
+-- critical — without it every schedule in the table contributes, multiplying minutes.
+intercepted_periods AS (
+  SELECT
+    wp.ticket_id,
+    wp.event_type,
+    wp.schedule_id,
+    wp.schedule_tz,
+    wp.week_number,
+    wp.ticket_created_at,
+    wp.event_at,
+    wp.calendar_minutes,
+    s.start_time AS schedule_interval_start,
+    LEAST(wp.ticket_week_end_time, s.end_time) - GREATEST(
+      wp.ticket_week_start_time,
+      s.start_time
+    ) AS scheduled_minutes
+  FROM
+    weekly_periods AS wp
+  JOIN
+    `moz-fx-data-shared-prod.zendesk_syndicate.schedule` AS s
+    ON s.id = wp.schedule_id
+    AND wp.ticket_week_start_time <= s.end_time
+    AND wp.ticket_week_end_time >= s.start_time
+),
+-- Calendar date each scheduled interval falls on, used for holiday matching.
+intercepted_with_dates AS (
+  SELECT
+    ip.*,
+    DATE_ADD(
+      DATE_TRUNC(DATE(DATETIME(ticket_created_at, schedule_tz)), WEEK),
+      INTERVAL CAST(7 * week_number + FLOOR(schedule_interval_start / 1440) AS INT64) DAY
+    ) AS schedule_interval_date
+  FROM
+    intercepted_periods AS ip
+),
+holiday_minutes_per_event AS (
+  SELECT
+    iwd.ticket_id,
+    iwd.event_type,
+    SUM(iwd.scheduled_minutes) AS holiday_minutes
+  FROM
+    intercepted_with_dates AS iwd
+  JOIN
+    `moz-fx-data-shared-prod.zendesk_syndicate.schedule_holiday` AS sh
+    ON sh.schedule_id = iwd.schedule_id
+    AND iwd.schedule_interval_date
+    BETWEEN DATE(sh.start_date)
+    AND DATE(sh.end_date)
+  GROUP BY
+    iwd.ticket_id,
+    iwd.event_type
+),
+-- Drives off ticket_events (not intercepted_periods) so every event emits a row —
+-- including auto-solved tickets whose interval is too short or entirely outside
+-- business hours to overlap a schedule slot. Those produce business_minutes = 0
+-- instead of dropping out, which keeps full_resolution_at populated downstream.
+business_event_times AS (
+  SELECT
+    te.ticket_id,
+    te.event_type,
+    te.event_at,
+    ROUND(TIMESTAMP_DIFF(te.event_at, te.ticket_created_at, SECOND) / 60.0, 1) AS calendar_minutes,
+    ROUND(COALESCE(sched.business_minutes, 0), 1) AS business_minutes
+  FROM
+    ticket_events AS te
+  LEFT JOIN
+    (
+      SELECT
+        ip.ticket_id,
+        ip.event_type,
+        SUM(ip.scheduled_minutes) - COALESCE(MAX(hm.holiday_minutes), 0) AS business_minutes
+      FROM
+        intercepted_periods AS ip
+      LEFT JOIN
+        holiday_minutes_per_event AS hm
+        ON ip.ticket_id = hm.ticket_id
+        AND ip.event_type = hm.event_type
+      GROUP BY
+        ip.ticket_id,
+        ip.event_type
+    ) AS sched
+    ON te.ticket_id = sched.ticket_id
+    AND te.event_type = sched.event_type
+),
+-- Appbot/non-Appbot classification used to filter out non-English Appbot reviews.
+appbot_class AS (
+  SELECT
+    tt.ticket_id,
+    CASE
+      WHEN LOGICAL_OR(tt.tag = 'appbot')
+        AND LOGICAL_OR(tt.tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
+        THEN 'Appbot - English'
+      WHEN LOGICAL_OR(tt.tag = 'appbot')
+        AND NOT LOGICAL_OR(tt.tag IN ('english', 'usa', 'unitedkingdom', 'canada', 'australia'))
+        THEN 'Appbot - Non-English'
+      ELSE 'All Other Tickets'
+    END AS ticket_group
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_tag` AS tt
+  JOIN
+    tickets_in_window AS tw
+    ON tw.ticket_id = tt.ticket_id
+  GROUP BY
+    tt.ticket_id
+),
+-- Tag-based automation classification. Reopened automation tickets are reclassified
+-- as human-handled — once a human had to step in, the resolution wasn't really automated.
+automation_tags AS (
+  SELECT
+    tt.ticket_id,
+    MAX(
+      CASE
+        WHEN tt.tag IN (
+            'ssa-sign-in-failure-automation',
+            'ssa-connection-issues-automation',
+            'ssa-sync-data-automation',
+            'appbot-autosolve',
+            'ssa-experiment-2fa-automation',
+            'ssa-experiment-pwrdreset-automation',
+            'ssa-experiment-emailverify-automation',
+            'ssa-experiment-4-star',
+            'ssa-experiment-5-star',
+            'loginless-autosolve'
+          )
+          THEN 1
+        ELSE 0
+      END
+    ) AS is_automated
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_tag` AS tt
+  JOIN
+    tickets_in_window AS tw
+    ON tw.ticket_id = tt.ticket_id
+  GROUP BY
+    tt.ticket_id
+),
+test_tickets AS (
+  SELECT DISTINCT
+    tt.ticket_id
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_tag` AS tt
+  JOIN
+    tickets_in_window AS tw
+    ON tw.ticket_id = tt.ticket_id
+  WHERE
+    -- Word-boundary match so 'test', 'sumo-test', 'test-only' match,
+    -- but 'latest', 'manifest', 'contest', etc. don't.
+    REGEXP_CONTAINS(tt.tag, r'(^|[-_])test([-_]|$)')
+),
+csat AS (
+  -- One row per ticket: pick the latest survey response so a ticket with
+  -- multiple surveys (or multiple rating_scale answers per survey) doesn't
+  -- fan out and multiply downstream KPI numerators.
+  SELECT
+    t_s.ticket_id,
+    a_s.rating_category
+  FROM
+    `moz-fx-data-shared-prod.zendesk_syndicate.csat_survey_answer` AS a_s
+  JOIN
+    `moz-fx-data-shared-prod.zendesk_syndicate.ticket_csat_survey` AS t_s
+    ON a_s.survey_response_id = t_s.survey_response_id
+    AND a_s.type = 'rating_scale'
+  QUALIFY
+    ROW_NUMBER() OVER (PARTITION BY t_s.ticket_id ORDER BY t_s.survey_response_id DESC) = 1
+)
+SELECT
+  tw.ticket_id,
+  tw.ticket_created_date,
+  tw.ticket_created_at,
+  tw.product,
+  tw.custom_product AS raw_custom_product,
+  COALESCE(ac.ticket_group, 'All Other Tickets') AS ticket_group,
+  CASE
+    WHEN COALESCE(aut.is_automated, 0) = 1
+      AND COALESCE(r.reopen_count, 0) = 0
+      AND res.calendar_minutes < 1
+      AND res.event_at IS NOT NULL
+      AND assignee.role IS NULL
+      THEN 'automation'
+    ELSE 'human-handled'
+  END AS automation_category,
+  fr.event_at AS first_reply_at,
+  fr.calendar_minutes AS first_reply_time_calendar_minutes,
+  fr.business_minutes AS first_reply_time_business_minutes,
+  res.event_at AS full_resolution_at,
+  res.calendar_minutes AS full_resolution_time_calendar_minutes,
+  res.business_minutes AS full_resolution_time_business_minutes,
+  CASE
+    WHEN fr.event_at IS NULL
+      THEN 'No reply yet'
+    ELSE 'Replied'
+  END AS reply_status,
+  CASE
+    WHEN res.event_at IS NULL
+      THEN 'Unresolved'
+    ELSE 'Resolved'
+  END AS resolution_status,
+  COALESCE(arc.agent_reply_count, 0) AS agent_reply_count,
+  CASE
+    WHEN res.event_at IS NOT NULL
+      AND COALESCE(arc.agent_reply_count, 0) < 2
+      THEN 1
+    ELSE 0
+  END AS is_one_touch,
+  COALESCE(r.reopen_count, 0) AS reopen_count,
+  csat.rating_category,
+  csat.rating_category IS NOT NULL AS survey_responded,
+  -- Excluded from SLA dashboards: Appbot non-English, test groups, and tagged-as-test tickets.
+  -- Keep the row but expose a flag so consumers can filter consistently.
+  (
+    COALESCE(ac.ticket_group, 'All Other Tickets') = 'Appbot - Non-English'
+    OR COALESCE(g.name, '') IN ('Sumo Test', 'VPN QA')
+    OR tt.ticket_id IS NOT NULL
+  ) AS is_excluded_from_sla,
+  CURRENT_TIMESTAMP() AS etl_timestamp
+FROM
+  tickets_in_window AS tw
+LEFT JOIN
+  business_event_times AS fr
+  ON tw.ticket_id = fr.ticket_id
+  AND fr.event_type = 'first_reply'
+LEFT JOIN
+  business_event_times AS res
+  ON tw.ticket_id = res.ticket_id
+  AND res.event_type = 'full_resolution'
+LEFT JOIN
+  agent_reply_counts AS arc
+  ON tw.ticket_id = arc.ticket_id
+LEFT JOIN
+  appbot_class AS ac
+  ON tw.ticket_id = ac.ticket_id
+LEFT JOIN
+  automation_tags AS aut
+  ON tw.ticket_id = aut.ticket_id
+LEFT JOIN
+  reopens AS r
+  ON tw.ticket_id = r.ticket_id
+LEFT JOIN
+  csat
+  ON tw.ticket_id = csat.ticket_id
+LEFT JOIN
+  test_tickets AS tt
+  ON tw.ticket_id = tt.ticket_id
+LEFT JOIN
+  `moz-fx-data-shared-prod.zendesk_syndicate.group` AS g
+  ON tw.group_id = g.id
+LEFT JOIN
+  `moz-fx-data-shared-prod.zendesk_syndicate.user` AS assignee
+  ON tw.assignee_id = assignee.id
diff -bur --no-dereference --new-file /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/schema.yaml /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/schema.yaml
--- /tmp/workspace/main-generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/schema.yaml	1970-01-01 00:00:00.000000000 +0000
+++ /tmp/workspace/generated-sql/sql/moz-fx-data-shared-prod/sumo_metrics_derived/zendesk_ticket_sla_v1/schema.yaml	2026-05-15 13:13:04.097790965 +0000
@@ -0,0 +1,111 @@
+fields:
+- name: ticket_id
+  type: INTEGER
+  mode: NULLABLE
+  description: Zendesk ticket ID (primary key for this table).
+- name: ticket_created_date
+  type: DATE
+  mode: NULLABLE
+  description: Date the ticket was created (UTC). Used as partition column.
+- name: ticket_created_at
+  type: TIMESTAMP
+  mode: NULLABLE
+  description: Full timestamp the ticket was created (UTC).
+- name: product
+  type: STRING
+  mode: NULLABLE
+  description: |
+    Mozilla product the ticket is for, mapped via static.cx_product_mappings_v1
+    (source = 'Zendesk'). Falls back to the raw custom_product if no mapping exists.
+- name: raw_custom_product
+  type: STRING
+  mode: NULLABLE
+  description: Raw custom_product value from Zendesk before product mapping is applied.
+- name: ticket_group
+  type: STRING
+  mode: NULLABLE
+  description: |
+    Appbot classification derived from ticket tags:
+    - 'Appbot - English' — appbot tag plus english/usa/uk/canada/australia
+    - 'Appbot - Non-English' — appbot tag without an English-region tag
+    - 'All Other Tickets' — non-Appbot tickets
+- name: automation_category
+  type: STRING
+  mode: NULLABLE
+  description: |
+    'automation' if resolved by an automation/macro tag and never reopened;
+    'human-handled' otherwise. Reopened automation tickets are reclassified
+    as human-handled.
+- name: first_reply_at
+  type: TIMESTAMP
+  mode: NULLABLE
+  description: Timestamp of the first public agent or admin comment. NULL if no reply yet.
+- name: first_reply_time_calendar_minutes
+  type: FLOAT
+  mode: NULLABLE
+  description: Wall-clock minutes from ticket_created_at to first_reply_at.
+- name: first_reply_time_business_minutes
+  type: FLOAT
+  mode: NULLABLE
+  description: |
+    Business minutes from ticket_created_at to first_reply_at, computed against
+    "Mozilla Support Hours" with holidays subtracted. Zero when the interval
+    falls entirely outside business hours.
+- name: full_resolution_at
+  type: TIMESTAMP
+  mode: NULLABLE
+  description: |
+    Latest 'solved' timestamp; falls back to latest 'closed' for tickets that
+    went open → closed without a 'solved' event. NULL while unresolved.
+- name: full_resolution_time_calendar_minutes
+  type: FLOAT
+  mode: NULLABLE
+  description: Wall-clock minutes from ticket_created_at to full_resolution_at.
+- name: full_resolution_time_business_minutes
+  type: FLOAT
+  mode: NULLABLE
+  description: |
+    Business minutes from ticket_created_at to full_resolution_at, computed
+    against "Mozilla Support Hours" with holidays subtracted.
+- name: reply_status
+  type: STRING
+  mode: NULLABLE
+  description: "'Replied' if first_reply_at is set, otherwise 'No reply yet'."
+- name: resolution_status
+  type: STRING
+  mode: NULLABLE
+  description: "'Resolved' if full_resolution_at is set, otherwise 'Unresolved'."
+- name: agent_reply_count
+  type: INTEGER
+  mode: NULLABLE
+  description: Count of public agent/admin comments on this ticket.
+- name: is_one_touch
+  type: INTEGER
+  mode: NULLABLE
+  description: |
+    1 if the ticket was resolved with fewer than two agent replies (FCR proxy);
+    0 otherwise. Unresolved tickets are 0.
+- name: reopen_count
+  type: INTEGER
+  mode: NULLABLE
+  description: Number of 'solved' → 'open' transitions in the ticket's status history.
+- name: rating_category
+  type: STRING
+  mode: NULLABLE
+  description: |
+    CSAT survey rating category (e.g. 'good', 'bad'). NULL when no survey response.
+- name: survey_responded
+  type: BOOLEAN
+  mode: NULLABLE
+  description: TRUE when a CSAT rating_category exists for this ticket.
+- name: is_excluded_from_sla
+  type: BOOLEAN
+  mode: NULLABLE
+  description: |
+    TRUE for tickets typically excluded from SLA reporting:
+    Appbot non-English reviews, Sumo Test / VPN QA groups, and tickets tagged
+    with anything matching '%test%'. Downstream views should filter where FALSE.
+- name: etl_timestamp
+  type: TIMESTAMP
+  mode: NULLABLE
+  description: Timestamp when this snapshot was loaded into BigQuery.

Link to full diff

@phil-lee70 phil-lee70 added this pull request to the merge queue May 15, 2026
Merged via the queue into main with commit fea512a May 15, 2026
36 checks passed
@phil-lee70 phil-lee70 deleted the deng-11100 branch May 15, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants