Skip to content
6 changes: 6 additions & 0 deletions .server-changes/queue-metrics-remember-period.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

The Queues pages now open on the last hour instead of the last day, and remember the time period you picked when you navigate between queues or reload the page.
3 changes: 1 addition & 2 deletions apps/webapp/app/components/queues/QueueMetricCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useSearchParams } from "~/hooks/useSearchParam";
import { QUEUE_METRICS_DEFAULT_PERIOD } from "~/components/queues/queueMetricsPeriod";
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";

Expand All @@ -34,8 +35,6 @@ export const QUEUE_METRIC_COLORS = {
ckWait: "#F59E0B",
};

export const QUEUE_METRICS_DEFAULT_PERIOD = "1d";

export type QueueMetricIds = {
organizationId: string;
projectId: string;
Expand Down
31 changes: 31 additions & 0 deletions apps/webapp/app/components/queues/queueMetricsPeriod.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getCachedLimit } from "~/services/platform.v3.server";
import { logger } from "~/services/logger.server";
import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod";

/**
* The furthest back this org can query queue metrics: their plan's query period, capped at the
* 30 day retention. Same limit `executeQuery` enforces, so the queue-metric queries that bypass it
* and go straight to ClickHouse stay in step with the ones that don't.
*
* Read through the limit cache: the queues page revalidates on an interval, so this runs far more
* often than a one-off page load. Never throws, so a cache or platform outage costs the caller its
* time filter rather than the whole page: the retention cap is the widest window the data can cover
* anyway, and the queries stay tenant-scoped either way.
*/
export async function queueMetricsMaxPeriodDays(organizationId: string): Promise<number> {
try {
const cached = await getCachedLimit(
organizationId,
"queryPeriodDays",
QUEUE_METRICS_RETENTION_DAYS
);
const planPeriodDays = cached.val ?? QUEUE_METRICS_RETENTION_DAYS;
return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS);
} catch (error) {
logger.warn("Queue metrics query period limit unavailable, falling back to retention", {
organizationId,
error,
});
return QUEUE_METRICS_RETENTION_DAYS;
}
}
Comment thread
ericallam marked this conversation as resolved.
131 changes: 131 additions & 0 deletions apps/webapp/app/components/queues/queueMetricsPeriod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import parse from "parse-duration";
import { useEffect } from "react";

/**
* The time window the queue-metrics pages (queues list + queue detail) use when the URL carries
* no explicit period, and the memory that makes the user's last pick stick.
*
* The last period picked is stored in a cookie rather than localStorage so the loaders can read it
* and the first render already uses the remembered window (with localStorage the page would paint
* the default and then re-fetch). Absolute from/to ranges are never remembered: they'd pin later
* visits to a window that goes stale.
*/
export const QUEUE_METRICS_DEFAULT_PERIOD = "1h";

const COOKIE_NAME = "queueMetricsPeriod";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;

/**
* The shape TimeFilter writes: a count plus a minute/hour/day unit. The count is unbounded here
* because the picker accepts any positive integer for a custom duration (`10000m` is a little under
* 7 days); the retention bound below is what rules a window out.
*/
const PERIOD_PATTERN = /^\d+[mhd]$/;

/** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */
export const QUEUE_METRICS_RETENTION_DAYS = 30;

const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const MAX_PERIOD_MS = QUEUE_METRICS_RETENTION_DAYS * DAY_MS;

function isPeriod(value: string | undefined | null): value is string {
if (typeof value !== "string" || !PERIOD_PATTERN.test(value)) return false;
const ms = parse(value);
return typeof ms === "number" && ms > 0 && ms <= MAX_PERIOD_MS;
}

/** Loader side: the remembered period, falling back to the default when nothing usable is stored. */
export function queueMetricsPeriodFromRequest(request: Request): string {
const header = request.headers.get("cookie");
if (!header) return QUEUE_METRICS_DEFAULT_PERIOD;

for (const part of header.split(";")) {
const separator = part.indexOf("=");
if (separator === -1) continue;
if (part.slice(0, separator).trim() !== COOKIE_NAME) continue;
const value = part.slice(separator + 1).trim();
return isPeriod(value) ? value : QUEUE_METRICS_DEFAULT_PERIOD;
}

return QUEUE_METRICS_DEFAULT_PERIOD;
}

/**
* Remember the period currently in the URL so the next visit to a queue-metrics page opens on it.
* Pass the raw `period` search param: an absent one (the page is on its default) or an absolute
* from/to range leaves the stored value alone.
*/
export function useRememberQueueMetricsPeriod(period: string | undefined) {
useEffect(() => {
if (!isPeriod(period)) return;
document.cookie = `${COOKIE_NAME}=${period}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; samesite=lax`;
}, [period]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* The window the page should show: a usable period in the URL wins, an absolute range means "no
* period", and everything else (including a period the picker could never produce, e.g. a
* hand-edited `?period=garbage`) falls back to the remembered default the loader resolved. The
* result is held inside the org's plan query period, since that is the window the data will cover.
*
* Both the loaders and the client-side chart queries resolve through here, so they can't disagree
* about the window.
*/
export function resolveQueueMetricsPeriod({
period,
from,
to,
defaultPeriod,
maxPeriodDays,
}: {
period: string | undefined;
from: string | undefined;
to: string | undefined;
defaultPeriod: string;
maxPeriodDays: number;
}): string | null {
if (isPeriod(period)) return clampQueueMetricsPeriod(period, maxPeriodDays);
if (from || to) return null;
return clampQueueMetricsPeriod(defaultPeriod, maxPeriodDays);
}
Comment thread
ericallam marked this conversation as resolved.

/**
* Hold a period inside a day budget (the org's plan query period). A period longer than the plan
* allows becomes the plan's maximum, so the picker shows the window the data covers.
*
* The budget is whatever the plan says, not necessarily a whole number of days, so the replacement
* is expressed in the largest unit that divides it: rounding down keeps the period inside the
* budget rather than a hair over it.
*/
export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): string {
const maxMs = maxPeriodDays * DAY_MS;
const ms = parse(period);
if (typeof ms === "number" && ms > 0 && ms <= maxMs) return period;

const days = Math.floor(maxMs / DAY_MS);
if (days >= 1) return `${days}d`;
const hours = Math.floor(maxMs / HOUR_MS);
if (hours >= 1) return `${hours}h`;
return `${Math.max(1, Math.floor(maxMs / MINUTE_MS))}m`;
}

/**
* Pull a window forward to the earliest time the org's plan can query, the same clip `executeQuery`
* applies to every metric query. Queue-metric queries that go straight to ClickHouse (the queues
* list table, the concurrency-keys endpoint) have to apply it themselves, otherwise a hand-typed
* `?period=` reaches further back than the plan allows.
*
* A range that ends before the plan's earliest queryable time collapses to an empty window rather
* than an inverted one, which is what the enforced lower bound in `executeQuery` yields for the
* same request: no rows.
*/
export function clipQueueMetricsWindow(
window: { from: Date; to: Date },
maxPeriodDays: number
): { from: Date; to: Date } {
const earliest = new Date(Date.now() - maxPeriodDays * DAY_MS);
const from = window.from < earliest ? earliest : window.from;
return { from, to: window.to < from ? from : window.to };
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { BigNumber } from "~/components/metrics/BigNumber";
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server";
import {
QUEUE_METRICS_DEFAULT_PERIOD,
QUEUE_METRICS_RETENTION_DAYS,
clampQueueMetricsPeriod,
clipQueueMetricsWindow,
queueMetricsPeriodFromRequest,
resolveQueueMetricsPeriod,
useRememberQueueMetricsPeriod,
} from "~/components/queues/queueMetricsPeriod";
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";

const SearchParamsSchema = z.object({
query: z.string().optional(),
Expand All @@ -112,8 +122,6 @@ const SearchParamsSchema = z.object({
sort: z.enum(["busiest", "queued", "name"]).optional(),
});

const QUEUE_METRICS_DEFAULT_PERIOD = "1d";

// The live "Queued" / "Running" header blocks poll ClickHouse on a short cadence so they stay
// current after first paint. They read the env-wide gauges from env_metrics (the env-level rollup
// of queue_metrics, cheapest for a dimension-free query), always over a fixed 15m window regardless
Expand Down Expand Up @@ -163,6 +171,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
// no metrics query fires.
const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug });

const maxPeriodDays = queueMetricsUiEnabled
? await queueMetricsMaxPeriodDays(environment.organizationId)
: QUEUE_METRICS_RETENTION_DAYS;
const defaultPeriod = clampQueueMetricsPeriod(
queueMetricsPeriodFromRequest(request),
maxPeriodDays
);
Comment thread
ericallam marked this conversation as resolved.

try {
const queueListPresenter = new QueueListPresenter();
const queues = await queueListPresenter.call({
Expand Down Expand Up @@ -194,12 +210,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const queueNames = queues.queues.map((q) =>
q.type === "task" ? `task/${q.name}` : q.name
);
const timeRange = timeFilterFromTo({
period,
from: parseFiniteInt(from),
to: parseFiniteInt(to),
defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD,
});
const timeRange = clipQueueMetricsWindow(
timeFilterFromTo({
period:
resolveQueueMetricsPeriod({ period, from, to, defaultPeriod, maxPeriodDays }) ??
undefined,
from: parseFiniteInt(from),
to: parseFiniteInt(to),
defaultPeriod,
}),
maxPeriodDays
);
const queueMetrics =
queueNames.length > 0
? await presenter.getQueueListMetrics({
Expand Down Expand Up @@ -239,6 +260,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
metrics,
allocation,
queueMetricsUiEnabled,
defaultPeriod,
maxPeriodDays,
});
} catch (error) {
console.error(error);
Expand Down Expand Up @@ -362,6 +385,8 @@ function QueuesWithMetricsView() {
autoReloadPollIntervalMs,
metrics,
allocation,
defaultPeriod,
maxPeriodDays,
} = useTypedLoaderData<typeof loader>();

const metricsByQueue = metrics?.byQueue ?? {};
Expand All @@ -377,18 +402,21 @@ function QueuesWithMetricsView() {
const project = useProject();
const env = useEnvironment();
const plan = useCurrentPlan();
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
// plans whose query-period limit was raised above it — a longer window would render empty.
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);

// The header tiles fetch client-side with the same period/from/to the TimeFilter writes.
const { value } = useSearchParams();
const timeRange = {
period: value("period") ?? null,
period: resolveQueueMetricsPeriod({
period: value("period"),
from: value("from"),
to: value("to"),
defaultPeriod,
maxPeriodDays,
}),
from: value("from") ?? null,
to: value("to") ?? null,
};
useRememberQueueMetricsPeriod(value("period"));

useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });

Expand Down Expand Up @@ -473,7 +501,8 @@ function QueuesWithMetricsView() {
</div>
<div className="flex items-center gap-1.5">
<TimeFilter
defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD}
period={timeRange.period ?? undefined}
defaultPeriod={defaultPeriod}
labelName="Period"
maxPeriodDays={maxPeriodDays}
shortcut={{ key: "d" }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncConte
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
import {
QUEUE_METRIC_COLORS as COLORS,
QUEUE_METRICS_DEFAULT_PERIOD,
QueueMetricChartCard as QueueDetailChartCard,
type QueueMetricIds as Ids,
type QueueMetricTimeRange as TimeRangeParams,
Expand Down Expand Up @@ -55,7 +54,6 @@ import type {
ConcurrencyKeyRow,
ConcurrencyKeysResponse,
} from "~/routes/resources.queues.concurrency-keys";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder";
Expand All @@ -67,6 +65,13 @@ import {
QueueOverrideConcurrencyButton,
QueuePauseResumeButton,
} from "~/components/queues/QueueControls";
import {
clampQueueMetricsPeriod,
queueMetricsPeriodFromRequest,
resolveQueueMetricsPeriod,
useRememberQueueMetricsPeriod,
} from "~/components/queues/queueMetricsPeriod";
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
import { LinkButton } from "~/components/primitives/Buttons";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { InfoPanel } from "~/components/primitives/InfoPanel";
Expand Down Expand Up @@ -106,6 +111,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const queue = retrieve.queue;
const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name;

const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId);

const [ckBreakdown, oldestQueuedAt] = await Promise.all([
engine.concurrencyKeyBreakdown(environment, fullName, { limit: CK_LIVE_LIMIT }),
// Enqueue time of the oldest run still waiting in the queue right now (any queue, keyed or
Expand Down Expand Up @@ -134,6 +141,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
oldestQueuedAt: oldestQueuedAt ?? null,
loadedAt: Date.now(),
backPath: url.pathname.replace(/\/[^/]+$/, ""),
defaultPeriod: clampQueueMetricsPeriod(queueMetricsPeriodFromRequest(request), maxPeriodDays),
maxPeriodDays,
ids: {
organizationId: environment.organizationId,
projectId: environment.projectId,
Expand Down Expand Up @@ -210,19 +219,23 @@ export default function Page() {
loadedAt,
backPath,
ids,
defaultPeriod,
maxPeriodDays,
} = useTypedLoaderData<typeof loader>();
const plan = useCurrentPlan();
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
// plans whose query-period limit was raised above it — a longer window would render empty.
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);

const { value, replace } = useSearchParams();
const timeRange: TimeRangeParams = {
period: value("period") ?? null,
period: resolveQueueMetricsPeriod({
period: value("period"),
from: value("from"),
to: value("to"),
defaultPeriod,
maxPeriodDays,
}),
from: value("from") ?? null,
to: value("to") ?? null,
};
useRememberQueueMetricsPeriod(value("period"));

// The Concurrency keys tab exists only for queues with key activity: live keys in the
// ckIndex, or nonzero CK history in the selected range (one cached scalar query decides).
Expand Down Expand Up @@ -283,7 +296,8 @@ export default function Page() {
/>
) : null}
<TimeFilter
defaultPeriod={QUEUE_METRICS_DEFAULT_PERIOD}
period={timeRange.period ?? undefined}
defaultPeriod={defaultPeriod}
labelName="Period"
maxPeriodDays={maxPeriodDays}
shortcut={{ key: "d" }}
Expand Down
Loading
Loading