Skip to content

Commit 461cebf

Browse files
committed
fix(webapp): make the Queues hero charts environment-wide
The four charts above the queues table reused the loader's already-paginated queue array as a ClickHouse `queue IN (...)` filter, so they aggregated over at most the 25 queues on the current page. Paging or re-sorting changed the values, and a name search that matched nothing blanked the whole chart row. They now read `env_metrics`, the environment-level rollup that already exists for this (the built-in Queues dashboard and the health report read it), which is both correct and queue-count-independent: no `GROUP BY queue` across an entire environment and no client-side summing. Two related fixes ride along: Scheduling delay and throttling are event-driven, so at the 10-second bucket a short range picks, most buckets hold no samples at all and were drawn as 0ms — measured at 232 of 349 buckets over an hour. TRQL grows a `minBucketSeconds` floor, plumbed through the metric resource route, and the hero tiles set 60s (one floor for all four, since the shared hover crosshair needs identical x-axes). Buckets that still have no samples now render as a gap rather than a dive to zero. Note that `wait_ms_count` only counts `wait_ms > 0`, so "nothing started" and "everything started instantly" are indistinguishable in storage; both read as a gap. Recharts was resolving victory-vendor's CJS entry on the server and its ESM entry in the browser. Those bundle different d3-shape builds — the CJS one predates d3-path's digit rounding — so every server-rendered curve carried full-precision coordinates while the client rounded to 3 decimals, and React reported a hydration mismatch on every chart. Bundling recharts for SSR makes both sides resolve the same ESM build.
1 parent 5f29ae4 commit 461cebf

12 files changed

Lines changed: 209 additions & 104 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
The four charts at the top of the Queues page now always cover the whole environment, so paging through or re-sorting your queues no longer changes them. The scheduling delay chart also leaves a gap where no runs started, instead of dropping to zero.

apps/webapp/app/hooks/useMetricResourceQuery.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type MetricResourceQueryOptions = {
2121
defaultPeriod: string;
2222
queues?: string[];
2323
fillGaps?: boolean;
24+
/** Floor for the query's bucket width, for series too sparse to read at the range's width. */
25+
minBucketSeconds?: number;
2426
refreshIntervalMs?: number;
2527
};
2628

@@ -56,6 +58,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
5658
environmentId,
5759
defaultPeriod,
5860
fillGaps,
61+
minBucketSeconds,
5962
refreshIntervalMs = 60_000,
6063
} = opts;
6164
const { period, from, to } = opts.timeRange;
@@ -71,10 +74,22 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
7174
from ?? "",
7275
to ?? "",
7376
fillGaps ? 1 : 0,
77+
minBucketSeconds ?? "",
7478
queuesKey ?? "",
7579
query,
7680
].join("|"),
77-
[organizationId, projectId, environmentId, resolvedPeriod, from, to, fillGaps, queuesKey, query]
81+
[
82+
organizationId,
83+
projectId,
84+
environmentId,
85+
resolvedPeriod,
86+
from,
87+
to,
88+
fillGaps,
89+
minBucketSeconds,
90+
queuesKey,
91+
query,
92+
]
7893
);
7994

8095
const [rows, setRows] = useState<MetricResourceRow[] | null>(
@@ -112,6 +127,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
112127
organizationId,
113128
projectId,
114129
environmentId,
130+
...(minBucketSeconds !== undefined ? { minBucketSeconds } : {}),
115131
...(queuesKey !== undefined ? { queues: queuesKey.split(",") } : {}),
116132
}),
117133
signal: controller.signal,
@@ -142,6 +158,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
142158
from,
143159
to,
144160
fillGaps,
161+
minBucketSeconds,
145162
organizationId,
146163
projectId,
147164
environmentId,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx

Lines changed: 40 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -391,13 +391,6 @@ function QueuesWithMetricsView() {
391391

392392
const metricsByQueue = metrics?.byQueue ?? {};
393393

394-
// The four header charts mirror exactly the queue set the table is showing (post-search,
395-
// post-pagination). These are the `task/`-prefixed queue_name values the queue_metrics table
396-
// stores, so the client-side tile queries scope to the same rows the loader listed.
397-
const chartQueueNames = success
398-
? queues.map((q) => (q.type === "task" ? `task/${q.name}` : q.name))
399-
: [];
400-
401394
const organization = useOrganization();
402395
const project = useProject();
403396
const env = useEnvironment();
@@ -635,21 +628,14 @@ function QueuesWithMetricsView() {
635628
</MetricsLayout.Grid>
636629
) : null}
637630

638-
{/* Env saturation, Backlog, Scheduling delay p95, Throttled viz — full-size, synced,
639-
drag-to-zoom line charts (Agent page pattern). Four chart tiles: 2x2 below lg, 4-up
640-
from lg, derived from the tile count. `kind="charts"` bakes the fixed row height.
641-
Only when there are queues to chart: not-success states (engine-version, no tasks) and a
642-
filtered-to-empty list leave chartQueueNames empty, where the tiles would just render
643-
four "No activity" cards above the blank state. */}
644-
{chartQueueNames.length > 0 ? (
631+
{success && (hasFilters || totalQueues !== 0) ? (
645632
<ChartSyncProvider onZoom={zoomToTimeFilter}>
646633
<MetricsLayout.Grid kind="charts">
647634
{QUEUE_HEADER_TILES.map((tile) => (
648635
<QueueEnvMetricChart
649636
key={tile.id}
650637
tile={tile}
651638
timeRange={timeRange}
652-
queueNames={chartQueueNames}
653639
referenceLines={
654640
tile.id === "saturation"
655641
? [
@@ -1202,8 +1188,7 @@ export function QueueFilters() {
12021188

12031189
type MetricTileRow = Record<string, number | string | null>;
12041190

1205-
/** One charted point per time bucket, already aggregated across the visible queue set. */
1206-
type TilePoint = { bucket: number; value: number };
1191+
type TilePoint = { bucket: number; value: number | null };
12071192

12081193
// Inline colour swatch matching the chart's warning ("yellow") line — used in tooltip copy that
12091194
// refers to that colour instead of naming it, so the swatch always matches the chart.
@@ -1235,10 +1220,8 @@ type QueueHeaderTile = {
12351220
/** Hover tooltip explaining the headline readout next to the title (e.g. what "9% of current
12361221
* period" means). Without it the readout has no tooltip. */
12371222
totalTooltip?: string;
1238-
// Rows can be one-per-bucket (p95, throttled: aggregated across the set in ClickHouse) or
1239-
// one-per-(bucket, queue) (saturation, backlog: summed across the set here, since summing a
1240-
// gauge across queues can't be a flat aggregate without double-counting sub-buckets). Either
1241-
// way derive returns the per-bucket points the chart draws.
1223+
/** Turns one row per bucket into the per-bucket points the chart draws. A null value is a
1224+
* bucket the metric has nothing to say about, and the line breaks there rather than reading 0. */
12421225
derive: (rows: MetricTileRow[]) => {
12431226
points: TilePoint[];
12441227
total: number;
@@ -1257,75 +1240,52 @@ function tileTimeToMs(value: number | string | null): number {
12571240
return Date.parse(s.endsWith("Z") ? s : `${s}Z`);
12581241
}
12591242

1260-
// Sums a per-(bucket, queue) row set into one value per bucket. `read` pulls the queue's
1261-
// contribution; `envColumn`, when set, carries an env-wide column (identical across the set's
1262-
// rows in a bucket) through as the max, so saturation can divide by the env limit.
1263-
function sumByBucket(
1264-
rows: MetricTileRow[],
1265-
read: (row: MetricTileRow) => number,
1266-
envColumn?: string
1267-
): Array<{ bucket: number; sum: number; env: number }> {
1268-
const byBucket = new Map<number, { sum: number; env: number }>();
1269-
for (const row of rows) {
1270-
const bucket = tileTimeToMs(row.t);
1271-
if (!Number.isFinite(bucket)) continue;
1272-
const entry = byBucket.get(bucket) ?? { sum: 0, env: 0 };
1273-
entry.sum += read(row);
1274-
if (envColumn) entry.env = Math.max(entry.env, tileNumber(row[envColumn]));
1275-
byBucket.set(bucket, entry);
1276-
}
1277-
return [...byBucket.entries()]
1278-
.map(([bucket, { sum, env }]) => ({ bucket, sum, env }))
1279-
.sort((a, b) => a.bucket - b.bucket);
1243+
/** Peak of a series, ignoring the buckets it has nothing to say about. */
1244+
function peakOf(points: TilePoint[]): number {
1245+
return points.reduce((max, p) => (p.value === null ? max : Math.max(max, p.value)), 0);
12801246
}
12811247

1282-
// Header tiles fetch their own TRQL query client-side (resources.metric) with fillGaps, scoped to
1283-
// the visible queue set (queue_metrics WHERE queue IN <set>). Saturation and backlog GROUP BY the
1284-
// queue too and sum here; p95 merges quantile states and throttled sums counters in ClickHouse.
12851248
const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
12861249
{
12871250
id: "saturation",
12881251
label: "Env saturation",
12891252
description: (
12901253
<>
1291-
How much of the environment's concurrency these queues are using. Turns <WarningSwatch />{" "}
1292-
above 100%, when they're into burst capacity.
1254+
How much of the environment's concurrency is in use. Turns <WarningSwatch /> above 100%,
1255+
when it's into burst capacity.
12931256
</>
12941257
),
12951258
color: "var(--color-queues)",
12961259
legend: [
12971260
{ color: "var(--color-queues)", label: "Saturation" },
12981261
{ color: "var(--color-warning)", label: "Over limit" },
12991262
],
1300-
// Numerator: running summed across the visible set. Denominator: the env-wide limit (same for
1301-
// every queue in a bucket), so the line reads as the set's share of the environment capacity.
1302-
query: `SELECT timeBucket() AS t,\n queue,\n max(max_running) AS running,\n max(max_env_limit) AS env_limit\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`,
1263+
query: `SELECT timeBucket() AS t,\n max(max_env_running) AS running,\n max(max_env_limit) AS env_limit\nFROM env_metrics\nGROUP BY t\nORDER BY t`,
13031264
formatValue: (v) => (v > 100 ? `${v}% — over the environment limit` : `${v}%`),
13041265
formatAxis: (v) => `${v}%`,
13051266
derive: (rows) => {
1306-
const points = sumByBucket(rows, (r) => tileNumber(r.running), "env_limit").map(
1307-
({ bucket, sum, env }) => ({
1308-
bucket,
1309-
value: env > 0 ? Math.round((sum / env) * 100) : 0,
1310-
})
1311-
);
1312-
const peak = points.reduce((max, p) => Math.max(max, p.value), 0);
1313-
return { points, total: peak, formatTotal: (v) => `${v}% peak` };
1267+
const points = rows.map((r) => {
1268+
const limit = tileNumber(r.env_limit);
1269+
return {
1270+
bucket: tileTimeToMs(r.t),
1271+
value: limit > 0 ? Math.round((tileNumber(r.running) / limit) * 100) : 0,
1272+
};
1273+
});
1274+
return { points, total: peakOf(points), formatTotal: (v) => `${v}% peak` };
13141275
},
13151276
},
13161277
{
13171278
id: "backlog",
13181279
label: "Backlog",
1319-
description: "How many runs are waiting across these queues, over time.",
1280+
description: "How many runs are waiting across the environment, over time.",
13201281
color: "var(--color-queues)",
1321-
query: `SELECT timeBucket() AS t,\n queue,\n max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`,
1282+
query: `SELECT timeBucket() AS t,\n max(max_env_queued) AS queued\nFROM env_metrics\nGROUP BY t\nORDER BY t`,
13221283
derive: (rows) => {
1323-
const points = sumByBucket(rows, (r) => tileNumber(r.queued)).map(({ bucket, sum }) => ({
1324-
bucket,
1325-
value: sum,
1284+
const points = rows.map((r) => ({
1285+
bucket: tileTimeToMs(r.t),
1286+
value: tileNumber(r.queued),
13261287
}));
1327-
const peak = points.reduce((max, p) => Math.max(max, p.value), 0);
1328-
return { points, total: peak, formatTotal: (v) => `${v.toLocaleString()} peak` };
1288+
return { points, total: peakOf(points), formatTotal: (v) => `${v.toLocaleString()} peak` };
13291289
},
13301290
},
13311291
{
@@ -1343,14 +1303,15 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
13431303
{ color: "var(--color-queues)", label: "p95" },
13441304
{ color: "var(--color-warning)", label: "Over 1 min" },
13451305
],
1346-
// quantilesMerge over the set's rows in a bucket is the true p95 across the union of samples
1347-
// (merging quantile states is valid; averaging per-queue percentiles would not be).
1348-
query: `SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1306+
query: `SELECT timeBucket() AS t,\n round(quantilesTDigestMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95,\n sum(wait_ms_count) AS samples\nFROM env_metrics\nGROUP BY t\nORDER BY t`,
13491307
formatValue: formatWaitMs,
13501308
formatAxis: formatWaitMs,
13511309
derive: (rows) => {
1352-
const points = rows.map((r) => ({ bucket: tileTimeToMs(r.t), value: tileNumber(r.p95) }));
1353-
const worst = points.reduce((max, p) => Math.max(max, p.value), 0);
1310+
const points = rows.map((r) => ({
1311+
bucket: tileTimeToMs(r.t),
1312+
value: tileNumber(r.samples) > 0 ? tileNumber(r.p95) : null,
1313+
}));
1314+
const worst = peakOf(points);
13541315
return {
13551316
points,
13561317
total: worst,
@@ -1366,7 +1327,7 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
13661327
totalTooltip: "The share of the selected window with at least one blocked dequeue.",
13671328
color: "var(--color-queues)",
13681329
legend: [{ color: "var(--color-warning)", label: "Throttled" }],
1369-
query: `SELECT timeBucket() AS t,\n sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1330+
query: `SELECT timeBucket() AS t,\n sum(throttled_count) AS throttled\nFROM env_metrics\nGROUP BY t\nORDER BY t`,
13701331
derive: (rows) => {
13711332
const points = rows.map((r) => ({
13721333
bucket: tileTimeToMs(r.t),
@@ -1376,7 +1337,7 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
13761337
// scales with poll rate and window length); the fraction of buckets with a throttle is.
13771338
// The data path fills gaps (zero-fill for this counter), so every bucket in the window is
13781339
// present and `points.length` is the honest denominator.
1379-
const nonzero = points.filter((p) => p.value > 0).length;
1340+
const nonzero = points.filter((p) => p.value !== null && p.value > 0).length;
13801341
const pct = points.length > 0 ? Math.round((nonzero / points.length) * 100) : 0;
13811342
return {
13821343
points,
@@ -1388,10 +1349,12 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
13881349
},
13891350
];
13901351

1391-
// When a search matches no queues the set is empty. We still fetch (hooks can't be conditional),
1392-
// but with a queue name that can't exist so the IN filter returns nothing and the tile falls
1393-
// through to its "No activity" empty state instead of silently widening to the whole environment.
1394-
const NO_QUEUES_SENTINEL = "__no_queues__";
1352+
/**
1353+
* Bucket floor shared by every hero tile. Scheduling delay and throttling are event-driven, so at
1354+
* the 10-second width a short range would otherwise pick, most buckets hold no samples at all. One
1355+
* floor for all four keeps their x-axes identical, which the shared hover crosshair relies on.
1356+
*/
1357+
const HERO_CHART_MIN_BUCKET_SECONDS = 60;
13951358

13961359
type TileTimeRange = MetricResourceTimeRange;
13971360

@@ -1401,16 +1364,13 @@ type TileTimeRange = MetricResourceTimeRange;
14011364
function QueueEnvMetricChart({
14021365
tile,
14031366
timeRange,
1404-
queueNames,
14051367
referenceLines,
14061368
thresholdStroke,
14071369
warningOverlay,
14081370
solidWarning = false,
14091371
}: {
14101372
tile: QueueHeaderTile;
14111373
timeRange: TileTimeRange;
1412-
/** The visible queue set (post-search, post-pagination) the chart scopes to. */
1413-
queueNames: string[];
14141374
referenceLines?: Array<{
14151375
y: number;
14161376
label?: string;
@@ -1427,17 +1387,14 @@ function QueueEnvMetricChart({
14271387
const project = useProject();
14281388
const environment = useEnvironment();
14291389

1430-
// Scope to exactly the queues the table is showing. Empty set => sentinel that matches nothing,
1431-
// so the tile shows "No activity" rather than the whole environment. The hook re-fetches when
1432-
// this list changes (it keys on the joined names), so search/pagination reflow the charts.
14331390
const { rows, showLoading, failed } = useMetricResourceQuery(tile.query, {
14341391
organizationId: organization.id,
14351392
projectId: project.id,
14361393
environmentId: environment.id,
14371394
timeRange,
14381395
defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD,
14391396
fillGaps: true,
1440-
queues: queueNames.length > 0 ? queueNames : [NO_QUEUES_SENTINEL],
1397+
minBucketSeconds: HERO_CHART_MIN_BUCKET_SECONDS,
14411398
});
14421399

14431400
const { points, total, formatTotal, totalClassName } = tile.derive(rows);
@@ -1461,7 +1418,7 @@ function QueueEnvMetricChart({
14611418
() => buildActivityTimeAxis(data),
14621419
[data]
14631420
);
1464-
const hasData = data.length > 0 && data.some((p) => (p[tile.id] as number) > 0);
1421+
const hasData = data.length > 0 && data.some((p) => Number(p[tile.id] ?? 0) > 0);
14651422

14661423
// Peak readout lives in the card title (ChartCard has no dedicated value slot). A zero/empty
14671424
// total renders no readout at all (skipping "0% peak", "0 peak", "0" and the p95 "–" placeholder)

apps/webapp/app/routes/resources.metric.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const MetricWidgetQuery = z.object({
5252
tags: z.array(z.string()).optional(),
5353
// Opt into server-side gap fill (carry-forward for gauges, zero-fill for counters).
5454
fillGaps: z.boolean().optional(),
55+
minBucketSeconds: z.number().int().positive().max(86_400).optional(),
5556
userAuthoredQuery: z.boolean().optional(),
5657
});
5758

@@ -89,6 +90,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
8990
providers,
9091
tags: _tags,
9192
fillGaps,
93+
minBucketSeconds,
9294
userAuthoredQuery,
9395
} = submission.data;
9496

@@ -128,6 +130,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
128130
operations,
129131
providers,
130132
fillGaps,
133+
minBucketSeconds,
131134
userAuthoredQuery,
132135
// Set higher concurrency if many widgets are on screen at once
133136
customOrgConcurrencyLimit: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT,

0 commit comments

Comments
 (0)