Skip to content

Commit e394b5a

Browse files
authored
fix(webapp): timestamp live metric responses (#4719)
## Summary Records when live metric responses arrive and uses that timestamp to evaluate gauge freshness and waiting duration. Cached or failed responses remain untrusted until revalidated, while rendered values stay stable between polling updates.
1 parent 34211e6 commit e394b5a

4 files changed

Lines changed: 95 additions & 26 deletions

File tree

  • apps/webapp/app
    • hooks
    • routes
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues
      • resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam

apps/webapp/app/hooks/useMetricResourceQuery.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,30 @@ export type MetricResourceTimeRange = {
1313
to: string | null;
1414
};
1515

16+
export function useIsMetricResponseFresh(
17+
responseReceivedAt: number | null,
18+
dataTimestamp: number,
19+
maxAgeMs: number
20+
) {
21+
const expiresAt =
22+
responseReceivedAt !== null && Number.isFinite(dataTimestamp) ? dataTimestamp + maxAgeMs : null;
23+
const [expiredAt, setExpiredAt] = useState<number | null>(null);
24+
25+
useEffect(() => {
26+
if (expiresAt === null) return;
27+
28+
const timeout = setTimeout(() => setExpiredAt(expiresAt), Math.max(0, expiresAt - Date.now()));
29+
return () => clearTimeout(timeout);
30+
}, [expiresAt]);
31+
32+
return (
33+
expiresAt !== null &&
34+
responseReceivedAt !== null &&
35+
responseReceivedAt < expiresAt &&
36+
expiredAt !== expiresAt
37+
);
38+
}
39+
1640
export type MetricResourceQueryOptions = {
1741
organizationId: string;
1842
projectId: string;
@@ -102,6 +126,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
102126
);
103127
const [isLoading, setIsLoading] = useState(true);
104128
const [failed, setFailed] = useState(false);
129+
const [responseReceivedAt, setResponseReceivedAt] = useState<number | null>(null);
130+
const [lastSuccessfulResponseAt, setLastSuccessfulResponseAt] = useState<number | null>(null);
105131
const abortRef = useRef<AbortController | null>(null);
106132
const loadedKeyRef = useRef<string | null>(null);
107133

@@ -111,6 +137,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
111137
loadedKeyRef.current = cacheKey;
112138
setRows(null);
113139
setFailed(false);
140+
setResponseReceivedAt(null);
141+
setLastSuccessfulResponseAt(null);
114142
setIsLoading(false);
115143
return;
116144
}
@@ -125,6 +153,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
125153
loadedKeyRef.current = cacheKey;
126154
setRows(responseCache.get(cacheKey) ?? null);
127155
setFailed(false);
156+
setResponseReceivedAt(null);
157+
setLastSuccessfulResponseAt(null);
128158
}
129159
setIsLoading(true);
130160
fetch("/resources/metric", {
@@ -150,17 +180,22 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
150180
if (controller.signal.aborted) return;
151181
if (data.success) {
152182
cacheSet(cacheKey, data.data.rows);
183+
const receivedAt = Date.now();
153184
setRows(data.data.rows);
154185
setFailed(false);
186+
setResponseReceivedAt(receivedAt);
187+
setLastSuccessfulResponseAt(receivedAt);
155188
} else {
156189
setFailed(true);
190+
setResponseReceivedAt(null);
157191
}
158192
setIsLoading(false);
159193
})
160194
.catch((error) => {
161195
if (error instanceof DOMException && error.name === "AbortError") return;
162196
if (!controller.signal.aborted) {
163197
setFailed(true);
198+
setResponseReceivedAt(null);
164199
setIsLoading(false);
165200
}
166201
});
@@ -191,5 +226,12 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
191226
callback: load,
192227
});
193228

194-
return { rows: rows ?? [], isLoading, showLoading: isLoading && !rows, failed };
229+
return {
230+
rows: rows ?? [],
231+
isLoading,
232+
showLoading: isLoading && !rows,
233+
failed,
234+
responseReceivedAt,
235+
lastSuccessfulResponseAt,
236+
};
195237
}

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import { ChartCard } from "~/components/primitives/charts/ChartCard";
7474
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
7575
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
7676
import {
77+
useIsMetricResponseFresh,
7778
useMetricResourceQuery,
7879
type MetricResourceTimeRange,
7980
} from "~/hooks/useMetricResourceQuery";
@@ -420,27 +421,30 @@ function QueuesWithMetricsView() {
420421
// Empty rows (quiet env, or the very first fetch still in flight) fall back to the loader values,
421422
// so we never flash a stale 0. Fixed 15m window, env-wide (no queue filter), CH-only recurring
422423
// load; pauses while the tab is hidden (handled inside the hook).
423-
const { rows: liveBlockRows } = useMetricResourceQuery(QUEUE_LIVE_BLOCKS_QUERY, {
424-
organizationId: organization.id,
425-
projectId: project.id,
426-
environmentId: env.id,
427-
timeRange: { period: QUEUE_LIVE_BLOCKS_PERIOD, from: null, to: null },
428-
defaultPeriod: QUEUE_LIVE_BLOCKS_PERIOD,
429-
fillGaps: false,
430-
refreshIntervalMs: 15_000,
431-
});
424+
const { rows: liveBlockRows, responseReceivedAt } = useMetricResourceQuery(
425+
QUEUE_LIVE_BLOCKS_QUERY,
426+
{
427+
organizationId: organization.id,
428+
projectId: project.id,
429+
environmentId: env.id,
430+
timeRange: { period: QUEUE_LIVE_BLOCKS_PERIOD, from: null, to: null },
431+
defaultPeriod: QUEUE_LIVE_BLOCKS_PERIOD,
432+
fillGaps: false,
433+
refreshIntervalMs: 15_000,
434+
}
435+
);
432436
const lastLiveBlockRow =
433437
liveBlockRows.length > 0 ? liveBlockRows[liveBlockRows.length - 1] : null;
434438
// Only trust the gauge while its newest bucket is fresh. A row painted from the hook's cache on
435439
// client-side nav-back (responseCache), or a quiet env whose latest bucket is minutes old, must
436440
// not override the loader's Redis-exact live values with a stale count.
437441
const lastLiveBucketMs = lastLiveBlockRow ? tileTimeToMs(lastLiveBlockRow.t) : NaN;
438-
const freshLiveBlockRow =
439-
lastLiveBlockRow &&
440-
Number.isFinite(lastLiveBucketMs) &&
441-
Date.now() - lastLiveBucketMs < LIVE_GAUGE_FRESH_MS
442-
? lastLiveBlockRow
443-
: null;
442+
const liveBlockIsFresh = useIsMetricResponseFresh(
443+
responseReceivedAt,
444+
lastLiveBucketMs,
445+
LIVE_GAUGE_FRESH_MS
446+
);
447+
const freshLiveBlockRow = lastLiveBlockRow && liveBlockIsFresh ? lastLiveBlockRow : null;
444448
const envQueuedLive = freshLiveBlockRow
445449
? tileNumber(freshLiveBlockRow.env_queued)
446450
: environment.queued;

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
toNumber,
3434
useQueueMetric,
3535
} from "~/components/queues/QueueMetricCards";
36+
import { useIsMetricResponseFresh } from "~/hooks/useMetricResourceQuery";
3637
import { findProjectBySlug } from "~/models/project.server";
3738
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
3839
import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server";
@@ -1121,7 +1122,7 @@ function QueueStats({
11211122
// Latest gauges from ClickHouse, polled every 15s so the live blocks keep ticking after first
11221123
// paint. Read the newest bucket (largest t); until the first poll lands liveRows is empty and the
11231124
// *Live values stay null, so the blocks show the loader values instead of flashing 0.
1124-
const { rows: liveRows } = useQueueMetric(
1125+
const { rows: liveRows, responseReceivedAt } = useQueueMetric(
11251126
`SELECT timeBucket() AS t, max(max_running) AS running, max(max_queued) AS queued, max(max_limit) AS q_limit, max(max_ck_wait_ms) AS ck_wait FROM queue_metrics GROUP BY t ORDER BY t`,
11261127
{
11271128
ids,
@@ -1137,8 +1138,11 @@ function QueueStats({
11371138
// Redis/PG value instead of lingering on a stale count.
11381139
const latest = liveRows.length > 0 ? liveRows[liveRows.length - 1] : undefined;
11391140
const latestBucketMs = latest ? clickhouseTimeToMs(latest.t) : NaN;
1140-
const liveFresh =
1141-
Number.isFinite(latestBucketMs) && Date.now() - latestBucketMs < LIVE_GAUGE_FRESH_MS;
1141+
const liveFresh = useIsMetricResponseFresh(
1142+
responseReceivedAt,
1143+
latestBucketMs,
1144+
LIVE_GAUGE_FRESH_MS
1145+
);
11421146
const fresh = latest && liveFresh ? latest : undefined;
11431147
const runningLive = fresh ? toNumber(fresh.running) : null;
11441148
const queuedLive = fresh ? toNumber(fresh.queued) : null;

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import { useEnvironment } from "~/hooks/useEnvironment";
9191
import { useOrganization } from "~/hooks/useOrganizations";
9292
import { useProject } from "~/hooks/useProject";
9393
import { useSearchParams } from "~/hooks/useSearchParam";
94+
import { useIsMetricResponseFresh } from "~/hooks/useMetricResourceQuery";
9495
import { useHasAdminAccess } from "~/hooks/useUser";
9596
import { redirectWithErrorMessage } from "~/models/message.server";
9697
import {
@@ -176,7 +177,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
176177
envParam,
177178
run: result.run,
178179
});
179-
return typedjson({ type: "run" as const, run: result.run, queueMetrics });
180+
return typedjson({
181+
type: "run" as const,
182+
run: result.run,
183+
queueMetrics,
184+
loadedAt: Date.now(),
185+
});
180186
}
181187
return typedjson({ type: "span" as const, span: result.span });
182188
} catch (error) {
@@ -270,6 +276,7 @@ export function SpanView({
270276
<RunBody
271277
run={fetcher.data.run}
272278
queueMetrics={fetcher.data.queueMetrics}
279+
loadedAt={fetcher.data.loadedAt}
273280
runParam={runParam}
274281
spanId={spanId}
275282
closePanel={closePanel}
@@ -395,12 +402,14 @@ function applySpanOverrides(span: Span, spanOverrides?: SpanOverride): Span {
395402
function RunBody({
396403
run,
397404
queueMetrics,
405+
loadedAt,
398406
runParam,
399407
spanId,
400408
closePanel,
401409
}: {
402410
run: SpanRun;
403411
queueMetrics: RunQueueMetrics | null;
412+
loadedAt: number;
404413
runParam: string;
405414
spanId: string;
406415
closePanel?: () => void;
@@ -1154,6 +1163,7 @@ function RunBody({
11541163
waiting={queueMetrics.waiting}
11551164
status={run.status}
11561165
createdAt={run.createdAt}
1166+
loadedAt={loadedAt}
11571167
runFriendlyId={run.friendlyId}
11581168
/>
11591169
) : null}
@@ -1310,6 +1320,7 @@ function WaitingInQueueBlock({
13101320
waiting,
13111321
status,
13121322
createdAt,
1323+
loadedAt,
13131324
runFriendlyId,
13141325
}: {
13151326
queueName: string;
@@ -1318,11 +1329,16 @@ function WaitingInQueueBlock({
13181329
waiting: RunQueueWaiting;
13191330
status: SpanRun["status"];
13201331
createdAt: Date;
1332+
loadedAt: number;
13211333
runFriendlyId: string;
13221334
}) {
13231335
// Latest gauges from ClickHouse (as on the queue page), polled so the blocks keep ticking. Trust
13241336
// the newest bucket only while fresh; otherwise fall back to the loader's live values.
1325-
const { rows: liveRows } = useQueueMetric(
1337+
const {
1338+
rows: liveRows,
1339+
responseReceivedAt,
1340+
lastSuccessfulResponseAt,
1341+
} = useQueueMetric(
13261342
`SELECT timeBucket() AS t, max(max_running) AS running, max(max_queued) AS queued, max(max_limit) AS q_limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
13271343
{
13281344
ids: waiting.ids,
@@ -1334,10 +1350,13 @@ function WaitingInQueueBlock({
13341350
);
13351351
const latest = liveRows.length > 0 ? liveRows[liveRows.length - 1] : undefined;
13361352
const latestBucketMs = latest ? clickhouseTimeToMs(latest.t) : NaN;
1337-
const fresh =
1338-
latest && Number.isFinite(latestBucketMs) && Date.now() - latestBucketMs < LIVE_GAUGE_FRESH_MS
1339-
? latest
1340-
: undefined;
1353+
const now = Math.max(loadedAt, lastSuccessfulResponseAt ?? loadedAt);
1354+
const liveFresh = useIsMetricResponseFresh(
1355+
responseReceivedAt,
1356+
latestBucketMs,
1357+
LIVE_GAUGE_FRESH_MS
1358+
);
1359+
const fresh = latest && liveFresh ? latest : undefined;
13411360

13421361
const key = waiting.concurrencyKey;
13431362
const running = fresh ? toNumber(fresh.running) : waiting.running;
@@ -1352,7 +1371,7 @@ function WaitingInQueueBlock({
13521371
const showAtLimit = status === "PENDING" && atLimit && !paused;
13531372
const pct =
13541373
limit && limit > 0 ? Math.min(100, Math.round((runningAgainstLimit / limit) * 100)) : null;
1355-
const waitedMs = Math.max(0, Date.now() - new Date(createdAt).getTime());
1374+
const waitedMs = Math.max(0, now - new Date(createdAt).getTime());
13561375

13571376
// Why the run is held, surfaced as a warning icon on the Status tile (queue-page style) rather
13581377
// than a separate sentence.

0 commit comments

Comments
 (0)