Skip to content

Commit bf99269

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
feat(webapp): Queue size and webhook limit metrics
Adds queue-size and webhook limit metrics to the `metrics` query table so alerts can watch rate limits, concurrency, and queue capacity. Mono-RevId: 5d2716bcd252bd59d921fddd08e57ef4c2774eb6
1 parent 4d9e4cf commit bf99269

24 files changed

Lines changed: 2183 additions & 279 deletions

‎.server-changes/limit-metrics.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
The `metrics` query table now includes queue size (`queue.size.*`) and webhook limit metrics (`webhook.waiters.*`, `webhook.processing.*`, `webhook.ingress.*`), so you can chart and alert on queue capacity and webhook rate limits.

‎apps/webapp/app/entry.server.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { initAlertEngineWorker } from "~/v3/alertEngineWorker.server";
1515
import { initLogsSearchProjectorWorker } from "~/v3/logsSearchProjectorWorker.server";
1616
import { initQueueMetricsConsumer, initQueueMetricsEmitter } from "~/v3/queueMetrics.server";
1717
import { initApiRateLimitMetrics } from "~/services/apiRateLimitMetrics.server";
18+
import { initWebhookMetrics } from "~/services/webhookMetrics.server";
1819
import { bootstrap } from "./bootstrap";
1920
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
2021
import type { OperatingSystemPlatform } from "./components/primitives/OperatingSystemProvider";
@@ -301,6 +302,7 @@ initLogsSearchProjectorWorker();
301302
initQueueMetricsEmitter();
302303
initQueueMetricsConsumer();
303304
initApiRateLimitMetrics();
305+
initWebhookMetrics();
304306

305307
bootstrap().catch((error) => {
306308
logError(error);

‎apps/webapp/app/env.server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,18 @@ const EnvironmentSchema = z
839839
.positive()
840840
.default(10_000),
841841

842+
QUEUE_SIZE_METRICS_INTERVAL_SEC: z.coerce
843+
.number()
844+
.int()
845+
.positive()
846+
.refine((seconds) => 60 % seconds === 0, { message: "must divide 60" })
847+
.default(30),
848+
QUEUE_SIZE_METRICS_MAX_ENVS: z.coerce.number().int().positive().default(2000),
849+
850+
WEBHOOK_METRICS_FLUSH_INTERVAL_MS: z.coerce.number().int().positive().default(10_000),
851+
WEBHOOK_METRICS_MAX_ENTRIES: z.coerce.number().int().positive().default(10_000),
852+
WEBHOOK_METRICS_MAX_ENDPOINTS: z.coerce.number().int().positive().default(500),
853+
842854
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
843855
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
844856

‎apps/webapp/app/services/apiRateLimitMetrics.server.ts‎

Lines changed: 9 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { env } from "~/env.server";
33
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
44
import { singleton } from "~/utils/singleton";
55
import { meter } from "~/v3/tracer.server";
6-
import { ApiRateLimitMetricsAggregator } from "./apiRateLimitMetricsAggregator.server";
6+
import {
7+
createApiRateLimitMetricsAggregator,
8+
type ApiRateLimitMetricsAggregator,
9+
} from "./apiRateLimitMetricsAggregator.server";
710
import {
811
apiRateLimitMetricsInsertSettings,
912
exportApiRateLimitMetricRows,
@@ -13,7 +16,6 @@ import type {
1316
RateLimitTenant,
1417
} from "./authorizationRateLimitMiddleware.server";
1518
import { logger } from "./logger.server";
16-
import { signalsEmitter } from "./signals.server";
1719

1820
function enabledByEnv(): boolean {
1921
return env.API_RATE_LIMIT_METRICS_ENABLED !== "0";
@@ -86,68 +88,16 @@ function createAggregator(): ApiRateLimitMetricsAggregator {
8688
"API rate limit metric rows lost because ClickHouse rejected the insert request; failures while writing a queued async insert are not visible here",
8789
});
8890

89-
const aggregator = new ApiRateLimitMetricsAggregator({
91+
const aggregator = createApiRateLimitMetricsAggregator({
9092
bucketSeconds: env.API_RATE_LIMIT_METRICS_BUCKET_SECONDS,
9193
maxEntries: env.API_RATE_LIMIT_METRICS_MAX_ENTRIES,
9294
isEnabled: createRuntimeGate(),
9395
sink: (rows) => exportRows(rows, (failed) => insertFailedCounter.add(failed)),
94-
onDropped: (count) => droppedCounter.add(count, { reason: "cap" }),
96+
counters: { dropped: droppedCounter, rowsFlushed: flushedCounter },
97+
flushIntervalMs: env.API_RATE_LIMIT_METRICS_FLUSH_INTERVAL_MS,
98+
ready: clickhouseFactory.isReady(),
9599
});
96-
97-
let routingReady = false;
98-
clickhouseFactory
99-
.isReady()
100-
.then(() => {
101-
routingReady = true;
102-
})
103-
.catch((error) => {
104-
logger.error("api rate limit metrics: data store registry never became ready", {
105-
error: error instanceof Error ? error.message : String(error),
106-
});
107-
});
108-
109-
let deferredWarned = false;
110-
const flush = () => {
111-
if (!routingReady) {
112-
if (aggregator.size > 0 && !deferredWarned) {
113-
deferredWarned = true;
114-
logger.warn("api rate limit metrics: flush deferred, data store routing not ready", {
115-
pendingEntries: aggregator.size,
116-
});
117-
}
118-
return;
119-
}
120-
try {
121-
const flushed = aggregator.flush();
122-
if (flushed > 0) {
123-
flushedCounter.add(flushed);
124-
}
125-
} catch (error) {
126-
logger.error("api rate limit metrics: flush failed", {
127-
error: error instanceof Error ? error.message : String(error),
128-
});
129-
}
130-
};
131-
132-
const interval = setInterval(flush, env.API_RATE_LIMIT_METRICS_FLUSH_INTERVAL_MS);
133-
interval.unref();
134-
135-
const shutdown = () => {
136-
clearInterval(interval);
137-
if (!routingReady) {
138-
const dropped = aggregator.discard();
139-
if (dropped > 0) {
140-
droppedCounter.add(dropped, { reason: "shutdown_before_ready" });
141-
logger.warn("api rate limit metrics: shutting down before routing was ready, dropping", {
142-
droppedObservations: dropped,
143-
});
144-
}
145-
return;
146-
}
147-
flush();
148-
};
149-
signalsEmitter.on("SIGTERM", shutdown);
150-
signalsEmitter.on("SIGINT", shutdown);
100+
aggregator.start();
151101

152102
return aggregator;
153103
}
Lines changed: 50 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import type { MetricsV1Input } from "@internal/clickhouse";
21
import type {
32
RateLimiterConfig,
43
RateLimitObservation,
54
RateLimitTenant,
65
} from "./authorizationRateLimitMiddleware.server";
7-
import { logger } from "./logger.server";
6+
import {
7+
createMetricsAggregator,
8+
type MetricsAggregatorOptions,
9+
type MetricUpdate,
10+
} from "./metricsAggregator.server";
811
import { parseDuration } from "./realtime/duration.server";
912

1013
export const API_RATE_LIMIT_METRIC_NAMES = {
@@ -53,192 +56,61 @@ export function rateLimitConfigToLimits(config: RateLimiterConfig): RateLimitLim
5356
return { perSecond: config.tokens / windowSeconds, burst: config.tokens };
5457
}
5558

56-
export type ApiRateLimitMetricsAggregatorOptions = {
57-
bucketSeconds: number;
58-
/** Hard cap on distinct (environment, bucket) entries held between flushes. */
59-
maxEntries: number;
60-
/** Runtime gate, evaluated per observation once a tenant is known. */
61-
isEnabled: (tenant: RateLimitTenant) => boolean;
62-
/** Receives every flushed batch. A rejected promise or a throw is logged and the batch dropped. */
63-
sink: (rows: MetricsV1Input[]) => void | Promise<void>;
64-
now?: () => number;
65-
onDropped?: (count: number) => void;
66-
};
67-
68-
type Entry = {
69-
organizationId: string;
70-
projectId: string;
71-
environmentId: string;
72-
bucketStartMs: number;
73-
allowed: number;
74-
denied: number;
75-
remainingMin: number;
76-
limits: RateLimitLimits | undefined;
77-
};
59+
export type ApiRateLimitMetricsAggregatorOptions = Omit<
60+
MetricsAggregatorOptions<RateLimitTenant>,
61+
"name"
62+
>;
7863

7964
/**
80-
* In-process counter for the API rate limiter's allow/deny decisions, keyed by
81-
* (environment, time bucket). Every flush emits the counts accumulated since the previous
82-
* flush as delta rows, so several replicas (or one replica across a flush boundary) may
83-
* emit rows for the same key and sum() at read time stays exact.
65+
* Counts the API rate limiter's allow/deny decisions per (environment, time bucket) as
66+
* delta rows, so sum() at read time stays exact across replicas and flushes.
8467
*/
85-
export class ApiRateLimitMetricsAggregator {
86-
private readonly entries = new Map<string, Entry>();
87-
private readonly bucketMs: number;
88-
private readonly maxEntries: number;
89-
private readonly isEnabled: (tenant: RateLimitTenant) => boolean;
90-
private readonly sink: (rows: MetricsV1Input[]) => void | Promise<void>;
91-
private readonly now: () => number;
92-
private readonly onDropped?: (count: number) => void;
93-
94-
constructor(options: ApiRateLimitMetricsAggregatorOptions) {
95-
this.bucketMs = options.bucketSeconds * 1000;
96-
this.maxEntries = options.maxEntries;
97-
this.isEnabled = options.isEnabled;
98-
this.sink = options.sink;
99-
this.now = options.now ?? Date.now;
100-
this.onDropped = options.onDropped;
101-
}
102-
103-
get size(): number {
104-
return this.entries.size;
105-
}
68+
export function createApiRateLimitMetricsAggregator(options: ApiRateLimitMetricsAggregatorOptions) {
69+
const metrics = createMetricsAggregator({ ...options, name: "api rate limit metrics" });
10670

107-
record(observation: RateLimitObservation): void {
71+
const record = (observation: RateLimitObservation): void => {
10872
const tenant = observation.tenant;
109-
if (!tenant || !this.isEnabled(tenant)) {
73+
if (!tenant) {
11074
return;
11175
}
76+
metrics.record(tenant, observationUpdates(observation), () => limitUpdates(observation.config));
77+
};
11278

113-
const bucketStartMs = Math.floor(this.now() / this.bucketMs) * this.bucketMs;
114-
const key = `${tenant.environmentId}\0${bucketStartMs}`;
115-
const remaining = Math.max(0, observation.remaining);
116-
117-
let entry = this.entries.get(key);
118-
if (!entry) {
119-
if (this.entries.size >= this.maxEntries) {
120-
this.onDropped?.(1);
121-
return;
122-
}
123-
entry = {
124-
organizationId: tenant.organizationId,
125-
projectId: tenant.projectId,
126-
environmentId: tenant.environmentId,
127-
bucketStartMs,
128-
allowed: 0,
129-
denied: 0,
130-
remainingMin: remaining,
131-
limits: rateLimitConfigToLimits(observation.config),
132-
};
133-
this.entries.set(key, entry);
134-
}
135-
136-
if (observation.success) {
137-
entry.allowed++;
138-
} else {
139-
entry.denied++;
140-
}
141-
entry.remainingMin = Math.min(entry.remainingMin, remaining);
142-
}
143-
144-
/**
145-
* Throws away everything held without emitting it and returns how many observations
146-
* were folded into the discarded entries, so callers can account for them as dropped.
147-
*/
148-
discard(): number {
149-
let observations = 0;
150-
for (const entry of this.entries.values()) {
151-
observations += entry.allowed + entry.denied;
152-
}
153-
this.entries.clear();
154-
return observations;
155-
}
156-
157-
flush(): number {
158-
if (this.entries.size === 0) {
159-
return 0;
160-
}
161-
162-
const rows: MetricsV1Input[] = [];
163-
for (const entry of this.entries.values()) {
164-
rows.push(...entryToRows(entry));
165-
}
166-
this.entries.clear();
167-
168-
const dropBatch = (error: unknown) => {
169-
logger.error("api rate limit metrics: sink failed, dropping batch", {
170-
rows: rows.length,
171-
error: error instanceof Error ? error.message : String(error),
172-
});
173-
};
174-
try {
175-
const result = this.sink(rows);
176-
if (result instanceof Promise) {
177-
result.catch(dropBatch);
178-
}
179-
} catch (error) {
180-
dropBatch(error);
181-
}
182-
183-
return rows.length;
184-
}
185-
}
186-
187-
function entryToRows(entry: Entry): MetricsV1Input[] {
188-
const base = {
189-
organization_id: entry.organizationId,
190-
project_id: entry.projectId,
191-
environment_id: entry.environmentId,
192-
metric_subject: "",
193-
bucket_start: formatBucketStart(entry.bucketStartMs),
194-
attributes: {},
79+
return {
80+
record,
81+
size: metrics.size,
82+
discard: metrics.discard,
83+
flush: metrics.flush,
84+
start: metrics.start,
19585
};
86+
}
19687

197-
const rows: MetricsV1Input[] = [];
198-
if (entry.allowed > 0) {
199-
rows.push({
200-
...base,
201-
metric_name: API_RATE_LIMIT_METRIC_NAMES.allowed,
202-
metric_type: "sum",
203-
value: entry.allowed,
204-
});
205-
}
206-
if (entry.denied > 0) {
207-
rows.push({
208-
...base,
209-
metric_name: API_RATE_LIMIT_METRIC_NAMES.denied,
210-
metric_type: "sum",
211-
value: entry.denied,
212-
});
213-
}
214-
rows.push({
215-
...base,
216-
metric_name: API_RATE_LIMIT_METRIC_NAMES.remainingMin,
217-
metric_type: "gauge",
218-
value: entry.remainingMin,
219-
});
220-
if (entry.limits) {
221-
rows.push(
222-
{
223-
...base,
224-
metric_name: API_RATE_LIMIT_METRIC_NAMES.limitPerSecond,
225-
metric_type: "gauge",
226-
value: entry.limits.perSecond,
227-
},
228-
{
229-
...base,
230-
metric_name: API_RATE_LIMIT_METRIC_NAMES.limitBurst,
231-
metric_type: "gauge",
232-
value: entry.limits.burst,
233-
}
234-
);
235-
}
236-
return rows;
88+
export type ApiRateLimitMetricsAggregator = ReturnType<typeof createApiRateLimitMetricsAggregator>;
89+
90+
function observationUpdates(observation: RateLimitObservation): MetricUpdate[] {
91+
return [
92+
{
93+
name: observation.success
94+
? API_RATE_LIMIT_METRIC_NAMES.allowed
95+
: API_RATE_LIMIT_METRIC_NAMES.denied,
96+
reducer: "sum",
97+
value: 1,
98+
},
99+
{
100+
name: API_RATE_LIMIT_METRIC_NAMES.remainingMin,
101+
reducer: "min",
102+
value: Math.max(0, observation.remaining),
103+
},
104+
];
237105
}
238106

239-
function formatBucketStart(epochMs: number): string {
240-
return new Date(epochMs)
241-
.toISOString()
242-
.replace("T", " ")
243-
.replace(/\.\d{3}Z$/, "");
107+
function limitUpdates(config: RateLimiterConfig): MetricUpdate[] {
108+
const limits = rateLimitConfigToLimits(config);
109+
if (!limits) {
110+
return [];
111+
}
112+
return [
113+
{ name: API_RATE_LIMIT_METRIC_NAMES.limitPerSecond, reducer: "first", value: limits.perSecond },
114+
{ name: API_RATE_LIMIT_METRIC_NAMES.limitBurst, reducer: "first", value: limits.burst },
115+
];
244116
}

0 commit comments

Comments
 (0)