SLA hit rate by metric
slas stores two targets per priority side by side: first_response_hours and resolve_hours.
That wide shape is fine for reading, but awkward for reporting, where you want one row per priority
and metric.
Report how often each target was met. Return one row per (priority, metric) with:
| column | type | meaning |
|---|---|---|
priority |
text | the priority |
metric |
text | 'first_response' or 'resolve' |
target_hours |
integer | the target from slas |
tickets |
integer | tickets measured for this metric |
met_pct |
decimal | percentage of those tickets that met the target, rounded to 1 decimal |
How to measure:
- first_response: from the ticket’s
opened_atto its earliestfirst_responseevent inticket_events. Tickets with nofirst_responseevent aren’t measured. - resolve: from
opened_attoclosed_at. Open tickets (closed_atisNULL) aren’t measured. - A ticket meets a target when the time taken is less than or equal to the target. Exactly on target counts as met.
- Leave out any (priority, metric) pair with no measured tickets.
Row order doesn’t matter.
Tables: tickets, ticket_events, slas.
helpdesk
A support desk. Synthetic data, generated by scripts/gen-datasets.mjs.
Click a table to preview its first 10 rows.
agents 20 rows
20 agents in 3 teams. Agents 1–3 are managers (manager_id NULL); everyone else reports to one of them.
tickets 800 rows
800 tickets from 2025-01-01. About 8% are still open (closed_at NULL).
ticket_events 2,779 rows
opened, first_response, reassigned, customer_reply, closed.
slas 4 rows
response and resolve targets in hours, per priority.
Hints get more specific as you go. Reveal one, try again, then come back.
Turn both sides into the same long shape: targets as (priority, metric, target_hours), and
measurements as (priority, metric, time_taken). Then it’s an ordinary join and group.
To unpivot slas portably, cross join it with a two-row list of metric names and pick the matching
column with CASE: CROSS JOIN (VALUES ('first_response'), ('resolve')) AS m(metric). DuckDB also
has UNPIVOT slas ON first_response_hours, resolve_hours INTO NAME metric VALUE target_hours, but
then you’d have to rename the metric values.
Build the measurements with UNION ALL: one SELECT for first responses (join to
MIN(event_ts) per ticket from ticket_events), one for resolves. Subtracting timestamps gives an
interval. Compare it to to_hours(target_hours).
COUNT(*) FILTER (WHERE took <= to_hours(target_hours)) counts the hits. Multiply by 100.0 before
dividing.
Try one more hint first? The solution is always here, but working it out is what makes it stick.
Approach
Unpivot the targets into one row per (priority, metric). Shape the measurements the same way. Join on both keys and aggregate.
WITH targets AS (
-- unpivot slas: one row per (priority, metric)
SELECT s.priority,
m.metric,
CASE m.metric
WHEN 'first_response' THEN s.first_response_hours
WHEN 'resolve' THEN s.resolve_hours
END AS target_hours
FROM slas s
CROSS JOIN (VALUES ('first_response'), ('resolve')) AS m(metric)
),
first_responses AS (
SELECT ticket_id, MIN(event_ts) AS responded_at
FROM ticket_events
WHERE event_type = 'first_response'
GROUP BY ticket_id
),
elapsed AS (
-- the measurements in the same long shape: one row per (ticket, metric)
SELECT t.priority, 'first_response' AS metric, f.responded_at - t.opened_at AS took
FROM tickets t
JOIN first_responses f ON f.ticket_id = t.ticket_id
UNION ALL
SELECT t.priority, 'resolve' AS metric, t.closed_at - t.opened_at AS took
FROM tickets t
WHERE t.closed_at IS NOT NULL
)
SELECT g.priority,
g.metric,
g.target_hours,
COUNT(*) AS tickets,
ROUND(100.0 * COUNT(*) FILTER (WHERE e.took <= to_hours(g.target_hours)) / COUNT(*), 1) AS met_pct
FROM targets g
JOIN elapsed e ON e.priority = g.priority AND e.metric = g.metric
GROUP BY g.priority, g.metric, g.target_hours;
Why it works
Unpivoting turns column names into values. Once the metric is a value, “first response” and
“resolve” stop being two special cases with two code paths. They’re two keys in one join, and a
third metric later is one more row in the VALUES list and one more branch of the UNION ALL.
The cross join makes two copies of every SLA row, one per metric. The CASE picks the column that
matches the copy. That’s all UNPIVOT does internally.
The inner join does the “leave out” rule for free. A priority with no measured tickets for a metric
has no rows in elapsed, so its target row finds no partner and drops out.
took stays an interval inside the query and is compared to to_hours(target_hours), so a ticket
answered 4 hours and 30 seconds after opening misses a 4-hour target. The interval never reaches the
output.
MIN(event_ts) guards against a ticket with more than one first_response event. Today there’s one
per ticket, but the rule says “earliest”, and the data may not stay this clean.
Under the hood
The cross join is tiny: 4 SLA rows × 2 metrics. The heavy work is the ticket_events scan with a
hash aggregate for MIN, a hash join to tickets, and a UNION ALL, which just concatenates two
streams with no deduplication. The final join probes a hash table of 8 target rows, and the
GROUP BY keeps two counters per group.
Per-problem discussion threads are coming. Until then, questions and better solutions are welcome on LinkedIn or by email at hello@builtfrombits.com.
Press ⌘ ↵ or Run to check the visible tests. Submit runs the hidden ones too. Everything runs in your browser.