|
1 | | -import type { MetricsV1Input } from "@internal/clickhouse"; |
2 | 1 | import type { |
3 | 2 | RateLimiterConfig, |
4 | 3 | RateLimitObservation, |
5 | 4 | RateLimitTenant, |
6 | 5 | } from "./authorizationRateLimitMiddleware.server"; |
7 | | -import { logger } from "./logger.server"; |
| 6 | +import { |
| 7 | + createMetricsAggregator, |
| 8 | + type MetricsAggregatorOptions, |
| 9 | + type MetricUpdate, |
| 10 | +} from "./metricsAggregator.server"; |
8 | 11 | import { parseDuration } from "./realtime/duration.server"; |
9 | 12 |
|
10 | 13 | export const API_RATE_LIMIT_METRIC_NAMES = { |
@@ -53,192 +56,61 @@ export function rateLimitConfigToLimits(config: RateLimiterConfig): RateLimitLim |
53 | 56 | return { perSecond: config.tokens / windowSeconds, burst: config.tokens }; |
54 | 57 | } |
55 | 58 |
|
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 | +>; |
78 | 63 |
|
79 | 64 | /** |
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. |
84 | 67 | */ |
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" }); |
106 | 70 |
|
107 | | - record(observation: RateLimitObservation): void { |
| 71 | + const record = (observation: RateLimitObservation): void => { |
108 | 72 | const tenant = observation.tenant; |
109 | | - if (!tenant || !this.isEnabled(tenant)) { |
| 73 | + if (!tenant) { |
110 | 74 | return; |
111 | 75 | } |
| 76 | + metrics.record(tenant, observationUpdates(observation), () => limitUpdates(observation.config)); |
| 77 | + }; |
112 | 78 |
|
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, |
195 | 85 | }; |
| 86 | +} |
196 | 87 |
|
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 | + ]; |
237 | 105 | } |
238 | 106 |
|
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 | + ]; |
244 | 116 | } |
0 commit comments