ldrouter 1.18.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  import { and, eq, gte, lte, sql, desc } from 'drizzle-orm';
3
3
  import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
+ import { GatewayError } from '../../errors.js';
5
6
  import { toSummary, loadSummaryMaps } from './requests.js';
6
7
  const PRESETS = {
7
8
  today: () => {
@@ -62,6 +63,19 @@ export async function registerStatsRoutes(app) {
62
63
  const fromIso = from.toISOString();
63
64
  const toIso = to.toISOString();
64
65
  const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
66
+ const utcToday = new Date().toISOString().slice(0, 10);
67
+ const aggregateRows = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
68
+ const overlappingAggregateDays = [...new Set(aggregateRows.map((r) => r.day))].filter((day) => day < utcToday && `${day}T00:00:00.000Z` <= toIso && `${day}T23:59:59.999Z` >= fromIso);
69
+ const aggregateDays = overlappingAggregateDays.filter((day) => `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
70
+ if (aggregateDays.length !== overlappingAggregateDays.length) {
71
+ throw new GatewayError('invalid_request_error', 'Statistics ranges overlapping compacted history must include complete UTC days.', { status: 400 });
72
+ }
73
+ const percentilesAvailable = aggregateDays.length === 0;
74
+ const aggregateOnly = aggregateDays.length > 0;
75
+ const rawConds = aggregateDays.length
76
+ ? and(...conds, sql `substr(created_at,1,10) NOT IN (${sql.join(aggregateDays.map((day) => sql `${day}`), sql `, `)})`)
77
+ : and(...conds);
78
+ const aggregateTotals = aggregateRows.filter((r) => aggregateDays.includes(r.day)).reduce((a, r) => ({ total: a.total + r.requests, success: a.success + r.successfulRequests, failed: a.failed + r.failedRequests, input: a.input + r.inputTokens, output: a.output + r.outputTokens, cacheRead: a.cacheRead + r.cacheReadTokens, cacheWrite: a.cacheWrite + r.cacheWriteTokens, reasoning: a.reasoning + r.reasoningTokens, gateway: a.gateway + r.gatewayCacheHits, fallback: a.fallback + r.fallbackRequests, latencySum: a.latencySum + r.latencySuccessSumMs, latencyCount: a.latencyCount + r.latencySuccessCount, ttftSum: a.ttftSum + r.ttftSumMs, ttftCount: a.ttftCount + r.ttftCount }), { total: 0, success: 0, failed: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, gateway: 0, fallback: 0, latencySum: 0, latencyCount: 0, ttftSum: 0, ttftCount: 0 });
65
79
  const summary = db
66
80
  .select({
67
81
  total: sql `COUNT(*)`,
@@ -76,13 +90,14 @@ export async function registerStatsRoutes(app) {
76
90
  fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
77
91
  })
78
92
  .from(schema.requests)
79
- .where(and(...conds))
93
+ .where(rawConds)
80
94
  .get();
81
- const cacheHitRate = cacheHitRateFor(db, conds);
95
+ // Aggregate rows retain cache counts but not provider-specific prompt denominator semantics.
96
+ const cacheHitRate = aggregateDays.length ? null : cacheHitRateFor(db, conds);
82
97
  const latRows = db
83
98
  .select({ v: schema.requests.totalLatencyMs })
84
99
  .from(schema.requests)
85
- .where(and(...conds, eq(schema.requests.success, true)))
100
+ .where(and(rawConds, eq(schema.requests.success, true)))
86
101
  .all();
87
102
  const latencies = latRows.map((r) => r.v).filter((v) => v > 0).sort((a, b) => a - b);
88
103
  const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
@@ -90,32 +105,32 @@ export async function registerStatsRoutes(app) {
90
105
  const ttftRows = db
91
106
  .select({ v: schema.requests.ttftMs })
92
107
  .from(schema.requests)
93
- .where(and(...conds, sql `ttft_ms IS NOT NULL`))
108
+ .where(and(rawConds, sql `ttft_ms IS NOT NULL`))
94
109
  .all();
95
110
  const ttfts = ttftRows.map((r) => r.v).filter((v) => v !== null).sort((a, b) => a - b);
96
111
  const avgTtft = ttfts.length ? ttfts.reduce((a, b) => a + b, 0) / ttfts.length : null;
97
112
  const p95Ttft = ttfts.length ? percentile(ttfts, 95) : null;
98
- const total = Number(summary?.total ?? 0);
99
- const success = Number(summary?.success ?? 0);
100
- const failed = Number(summary?.failed ?? 0);
113
+ const total = Number(summary?.total ?? 0) + aggregateTotals.total;
114
+ const success = Number(summary?.success ?? 0) + aggregateTotals.success;
115
+ const failed = Number(summary?.failed ?? 0) + aggregateTotals.failed;
101
116
  const statsSummary = {
102
117
  totalRequests: total,
103
118
  successfulRequests: success,
104
119
  failedRequests: failed,
105
120
  successRate: total ? success / total : 0,
106
- inputTokens: Number(summary?.inputTokens ?? 0),
107
- outputTokens: Number(summary?.outputTokens ?? 0),
108
- totalTokens: Number(summary?.inputTokens ?? 0) + Number(summary?.outputTokens ?? 0),
109
- cacheReadTokens: Number(summary?.cacheRead ?? 0),
110
- cacheWriteTokens: Number(summary?.cacheWrite ?? 0),
111
- reasoningTokens: Number(summary?.reasoning ?? 0),
112
- averageLatencyMs: avg,
113
- p95LatencyMs: p95,
114
- averageTtftMs: avgTtft,
115
- p95TtftMs: p95Ttft,
121
+ inputTokens: Number(summary?.inputTokens ?? 0) + aggregateTotals.input,
122
+ outputTokens: Number(summary?.outputTokens ?? 0) + aggregateTotals.output,
123
+ totalTokens: Number(summary?.inputTokens ?? 0) + aggregateTotals.input + Number(summary?.outputTokens ?? 0) + aggregateTotals.output,
124
+ cacheReadTokens: Number(summary?.cacheRead ?? 0) + aggregateTotals.cacheRead,
125
+ cacheWriteTokens: Number(summary?.cacheWrite ?? 0) + aggregateTotals.cacheWrite,
126
+ reasoningTokens: Number(summary?.reasoning ?? 0) + aggregateTotals.reasoning,
127
+ averageLatencyMs: aggregateOnly ? (aggregateTotals.latencySum + latencies.reduce((a, b) => a + b, 0)) / (aggregateTotals.latencyCount + latencies.length || 1) : avg,
128
+ p95LatencyMs: percentilesAvailable ? p95 : null,
129
+ averageTtftMs: aggregateOnly ? (aggregateTotals.ttftSum + ttfts.reduce((a, b) => a + b, 0)) / (aggregateTotals.ttftCount + ttfts.length || 1) : avgTtft,
130
+ p95TtftMs: percentilesAvailable ? p95Ttft : null,
116
131
  cacheHitRate,
117
- gatewayCacheHitRate: total ? Number(summary?.gatewayCacheHits ?? 0) / total : 0,
118
- fallbackRate: total ? Number(summary?.fallbacks ?? 0) / total : 0,
132
+ gatewayCacheHitRate: total ? (Number(summary?.gatewayCacheHits ?? 0) + aggregateTotals.gateway) / total : 0,
133
+ fallbackRate: total ? (Number(summary?.fallbacks ?? 0) + aggregateTotals.fallback) / total : 0,
119
134
  };
120
135
  // Time-bucketed series
121
136
  const bucketExpr = bucket === 'hour' ? sql `strftime('%Y-%m-%dT%H:00:00Z', created_at)` : sql `strftime('%Y-%m-%dT00:00:00Z', created_at)`;
@@ -130,10 +145,18 @@ export async function registerStatsRoutes(app) {
130
145
  cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
131
146
  })
132
147
  .from(schema.requests)
133
- .where(and(...conds))
148
+ .where(rawConds)
134
149
  .groupBy(bucketExpr)
135
150
  .orderBy(bucketExpr)
136
151
  .all();
152
+ const seriesMap = new Map(seriesRows.map((r) => [r.t, { t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens), avgLatency: Number(r.avgLatency ?? 0), cacheRead: Number(r.cacheRead ?? 0) }]));
153
+ for (const r of aggregateRows.filter((x) => aggregateDays.includes(x.day))) {
154
+ const t = `${r.day}T00:00:00Z`;
155
+ const old = seriesMap.get(t) ?? { t, requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, avgLatency: 0, cacheRead: 0 };
156
+ const oldSuccesses = old.requests - old.errors;
157
+ const count = oldSuccesses + r.latencySuccessCount;
158
+ seriesMap.set(t, { t, requests: old.requests + r.requests, errors: old.errors + r.failedRequests, inputTokens: old.inputTokens + r.inputTokens, outputTokens: old.outputTokens + r.outputTokens, avgLatency: count ? (old.avgLatency * oldSuccesses + r.latencySuccessSumMs) / count : 0, cacheRead: old.cacheRead + r.cacheReadTokens });
159
+ }
137
160
  // Top models
138
161
  const topModels = db
139
162
  .select({
@@ -143,7 +166,7 @@ export async function registerStatsRoutes(app) {
143
166
  tokens: sql `COALESCE(SUM(total_tokens),0)`,
144
167
  })
145
168
  .from(schema.requests)
146
- .where(and(...conds))
169
+ .where(rawConds)
147
170
  .groupBy(schema.requests.finalModelId)
148
171
  // Order by the aggregate expression itself: SQLite rejects ORDER BY on a
149
172
  // quoted select alias ("no such column: c").
@@ -152,6 +175,23 @@ export async function registerStatsRoutes(app) {
152
175
  .all();
153
176
  const models = db.select().from(schema.models).all();
154
177
  const modelMap = new Map(models.map((m) => [m.id, m]));
178
+ const mergedModels = new Map();
179
+ for (const r of topModels) {
180
+ const id = r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown';
181
+ const v = mergedModels.get(id) ?? { requests: 0, errors: 0, tokens: 0 };
182
+ v.requests += Number(r.c);
183
+ v.errors += Number(r.err);
184
+ v.tokens += Number(r.tokens);
185
+ mergedModels.set(id, v);
186
+ }
187
+ for (const r of aggregateRows.filter((x) => aggregateDays.includes(x.day))) {
188
+ const id = r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown';
189
+ const v = mergedModels.get(id) ?? { requests: 0, errors: 0, tokens: 0 };
190
+ v.requests += r.requests;
191
+ v.errors += r.failedRequests;
192
+ v.tokens += r.inputTokens + r.outputTokens;
193
+ mergedModels.set(id, v);
194
+ }
155
195
  const topKeys = db
156
196
  .select({
157
197
  apiKeyId: schema.requests.apiKeyId,
@@ -159,13 +199,29 @@ export async function registerStatsRoutes(app) {
159
199
  tokens: sql `COALESCE(SUM(total_tokens),0)`,
160
200
  })
161
201
  .from(schema.requests)
162
- .where(and(...conds))
202
+ .where(rawConds)
163
203
  .groupBy(schema.requests.apiKeyId)
164
204
  .orderBy(desc(sql `COUNT(*)`))
165
205
  .limit(10)
166
206
  .all();
167
207
  const keys = db.select().from(schema.apiKeys).all();
168
208
  const keyMap = new Map(keys.map((k) => [k.id, k]));
209
+ const mergedKeys = new Map();
210
+ for (const r of topKeys) {
211
+ const id = r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown';
212
+ const v = mergedKeys.get(id) ?? { requests: 0, tokens: 0 };
213
+ v.requests += Number(r.c);
214
+ v.tokens += Number(r.tokens);
215
+ mergedKeys.set(id, v);
216
+ }
217
+ const keyAggregateRows = db.select().from(schema.dailyApiKeyStats).where(and(gte(schema.dailyApiKeyStats.day, fromIso.slice(0, 10)), lte(schema.dailyApiKeyStats.day, toIso.slice(0, 10)))).all().filter((r) => aggregateDays.includes(r.day));
218
+ for (const r of keyAggregateRows) {
219
+ const id = r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown';
220
+ const v = mergedKeys.get(id) ?? { requests: 0, tokens: 0 };
221
+ v.requests += r.requests;
222
+ v.tokens += r.totalTokens;
223
+ mergedKeys.set(id, v);
224
+ }
169
225
  // ─── Previous period (for delta badges in summary cards) ───────────────
170
226
  const durationMs = to.getTime() - from.getTime();
171
227
  const prevFrom = new Date(from.getTime() - durationMs).toISOString();
@@ -200,43 +256,44 @@ export async function registerStatsRoutes(app) {
200
256
  .groupBy(schema.models.providerId)
201
257
  .all();
202
258
  const countMap = new Map(modelCounts.map((r) => [r.providerId, Number(r.c)]));
203
- const providers = providerAggs
204
- .map((r) => {
205
- const p = providerLookup.get(r.providerId);
259
+ const providerTotals = new Map(providerAggs.map((r) => [r.providerId, { total: Number(r.total), errors: Number(r.err), latencySum: Number(r.avgLat) * (Number(r.total) - Number(r.err)), latencyCount: Number(r.total) - Number(r.err) }]));
260
+ const providerAggregateRows = db.select().from(schema.dailyProviderStats).where(and(gte(schema.dailyProviderStats.day, fromIso.slice(0, 10)), lte(schema.dailyProviderStats.day, toIso.slice(0, 10)))).all().filter((r) => aggregateDays.includes(r.day));
261
+ for (const r of providerAggregateRows) {
262
+ const a = providerTotals.get(r.providerId) ?? { total: 0, errors: 0, latencySum: 0, latencyCount: 0 };
263
+ a.total += r.attempts;
264
+ a.errors += r.failedAttempts;
265
+ a.latencySum += r.latencySuccessSumMs;
266
+ a.latencyCount += r.latencySuccessCount;
267
+ providerTotals.set(r.providerId, a);
268
+ }
269
+ const providers = [...providerTotals].map(([providerId, r]) => {
270
+ const p = providerLookup.get(providerId);
206
271
  if (!p)
207
272
  return null;
208
- const total = Number(r.total);
273
+ const total = r.total;
209
274
  return {
210
- id: r.providerId,
275
+ id: providerId,
211
276
  name: p.name,
212
277
  slug: p.slug,
213
278
  health: p.healthState,
214
279
  enabled: p.enabled,
215
- modelCount: countMap.get(r.providerId) ?? 0,
280
+ modelCount: countMap.get(providerId) ?? 0,
216
281
  requests: total,
217
- errorRate: total > 0 ? Number(r.err) / total : 0,
218
- avgLatencyMs: Number(r.avgLat),
282
+ errorRate: total > 0 ? r.errors / total : 0,
283
+ avgLatencyMs: r.latencyCount ? r.latencySum / r.latencyCount : 0,
219
284
  };
220
285
  })
221
286
  .filter((x) => x !== null)
222
287
  .sort((a, b) => b.requests - a.requests);
223
288
  const range = { from: fromIso, to: toIso, bucket };
224
289
  return {
290
+ percentilesAvailable,
225
291
  range,
226
292
  summary: statsSummary,
227
293
  previous: previousSummary,
228
- series: seriesRows.map((r) => ({ t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens), avgLatency: Number(r.avgLatency ?? 0), cacheRead: Number(r.cacheRead ?? 0) })),
229
- topModels: topModels.map((r) => ({
230
- publicId: r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown',
231
- requests: Number(r.c),
232
- errorRate: Number(r.c) > 0 ? Number(r.err) / Number(r.c) : 0,
233
- totalTokens: Number(r.tokens),
234
- })),
235
- topApiKeys: topKeys.map((r) => ({
236
- name: r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown',
237
- requests: Number(r.c),
238
- totalTokens: Number(r.tokens),
239
- })),
294
+ series: [...seriesMap.values()].sort((a, b) => a.t.localeCompare(b.t)),
295
+ topModels: [...mergedModels].map(([publicId, v]) => ({ publicId, requests: v.requests, errorRate: v.requests ? v.errors / v.requests : 0, totalTokens: v.tokens })).sort((a, b) => b.requests - a.requests).slice(0, 10),
296
+ topApiKeys: [...mergedKeys].map(([name, v]) => ({ name, requests: v.requests, totalTokens: v.tokens })).sort((a, b) => b.requests - a.requests).slice(0, 10),
240
297
  recent,
241
298
  providers,
242
299
  };
@@ -247,6 +304,12 @@ export async function registerStatsRoutes(app) {
247
304
  * incremental aggregation). Returns counts sufficient to derive ratios.
248
305
  */
249
306
  function buildSummary(db, fromIso, toIso) {
307
+ // For previous period, use the same aggregate/raw partition as the primary range.
308
+ const days = db.select().from(schema.dailyModelStats).where(and(gte(schema.dailyModelStats.day, fromIso.slice(0, 10)), lte(schema.dailyModelStats.day, toIso.slice(0, 10)))).all();
309
+ const daySet = [...new Set(days.map((x) => x.day))].filter((day) => day < new Date().toISOString().slice(0, 10) && `${day}T00:00:00.000Z` >= fromIso && `${day}T23:59:59.999Z` <= toIso);
310
+ const aggregates = days.filter((x) => daySet.includes(x.day));
311
+ const rawOnly = daySet.length ? and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso), sql `substr(created_at,1,10) NOT IN (${sql.join(daySet.map((day) => sql `${day}`), sql `, `)})`) : and(gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso));
312
+ const agg = aggregates.reduce((a, x) => ({ total: a.total + x.requests, success: a.success + x.successfulRequests, failed: a.failed + x.failedRequests, input: a.input + x.inputTokens, output: a.output + x.outputTokens, cacheRead: a.cacheRead + x.cacheReadTokens, cacheWrite: a.cacheWrite + x.cacheWriteTokens, reasoning: a.reasoning + x.reasoningTokens, gateway: a.gateway + x.gatewayCacheHits, fallback: a.fallback + x.fallbackRequests, latency: a.latency + x.latencySuccessSumMs, latencyCount: a.latencyCount + x.latencySuccessCount, ttft: a.ttft + x.ttftSumMs, ttftCount: a.ttftCount + x.ttftCount }), { total: 0, success: 0, failed: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, gateway: 0, fallback: 0, latency: 0, latencyCount: 0, ttft: 0, ttftCount: 0 });
250
313
  const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
251
314
  const r = db
252
315
  .select({
@@ -258,37 +321,42 @@ function buildSummary(db, fromIso, toIso) {
258
321
  cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
259
322
  cacheWrite: sql `COALESCE(SUM(cache_write_tokens),0)`,
260
323
  reasoning: sql `COALESCE(SUM(reasoning_tokens),0)`,
261
- avgLatency: sql `COALESCE(AVG(CASE WHEN success=1 THEN total_latency_ms END),0)`,
262
- avgTtft: sql `AVG(CASE WHEN success=1 AND ttft_ms IS NOT NULL THEN ttft_ms END)`,
324
+ latencySum: sql `COALESCE(SUM(CASE WHEN success=1 THEN total_latency_ms ELSE 0 END),0)`,
325
+ latencyCount: sql `SUM(CASE WHEN success=1 AND total_latency_ms > 0 THEN 1 ELSE 0 END)`,
326
+ ttftSum: sql `COALESCE(SUM(CASE WHEN success=1 THEN COALESCE(ttft_ms,0) ELSE 0 END),0)`,
327
+ ttftCount: sql `SUM(CASE WHEN success=1 AND ttft_ms IS NOT NULL THEN 1 ELSE 0 END)`,
263
328
  gatewayCacheHits: sql `SUM(CASE WHEN gateway_cache_hit=1 THEN 1 ELSE 0 END)`,
264
329
  fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
265
330
  })
266
331
  .from(schema.requests)
267
- .where(and(...conds))
332
+ .where(rawOnly)
268
333
  .get();
269
- const total = Number(r?.total ?? 0);
270
- const success = Number(r?.success ?? 0);
271
- const cacheRead = Number(r?.cacheRead ?? 0);
272
- const inputTokens = Number(r?.inputTokens ?? 0);
273
- const cacheHitRate = cacheHitRateFor(db, conds);
334
+ const total = Number(r?.total ?? 0) + agg.total;
335
+ const success = Number(r?.success ?? 0) + agg.success;
336
+ const failed = Number(r?.failed ?? 0) + agg.failed;
337
+ const inputTokens = Number(r?.inputTokens ?? 0) + agg.input;
338
+ const outputTokens = Number(r?.outputTokens ?? 0) + agg.output;
339
+ const latencyCount = Number(r?.latencyCount ?? 0) + agg.latencyCount;
340
+ const ttftCount = Number(r?.ttftCount ?? 0) + agg.ttftCount;
341
+ const cacheHitRate = daySet.length ? null : cacheHitRateFor(db, conds);
274
342
  return {
275
343
  totalRequests: total,
276
344
  successfulRequests: success,
277
- failedRequests: Number(r?.failed ?? 0),
345
+ failedRequests: failed,
278
346
  successRate: total ? success / total : 0,
279
347
  inputTokens,
280
- outputTokens: Number(r?.outputTokens ?? 0),
281
- totalTokens: inputTokens + Number(r?.outputTokens ?? 0),
282
- cacheReadTokens: cacheRead,
283
- cacheWriteTokens: Number(r?.cacheWrite ?? 0),
284
- reasoningTokens: Number(r?.reasoning ?? 0),
285
- averageLatencyMs: Number(r?.avgLatency ?? 0),
286
- p95LatencyMs: 0, // caller fills via percentile query
287
- averageTtftMs: (r?.avgTtft ?? null),
348
+ outputTokens,
349
+ totalTokens: inputTokens + outputTokens,
350
+ cacheReadTokens: Number(r?.cacheRead ?? 0) + agg.cacheRead,
351
+ cacheWriteTokens: Number(r?.cacheWrite ?? 0) + agg.cacheWrite,
352
+ reasoningTokens: Number(r?.reasoning ?? 0) + agg.reasoning,
353
+ averageLatencyMs: latencyCount ? (Number(r?.latencySum ?? 0) + agg.latency) / latencyCount : 0,
354
+ p95LatencyMs: null,
355
+ averageTtftMs: ttftCount ? (Number(r?.ttftSum ?? 0) + agg.ttft) / ttftCount : null,
288
356
  p95TtftMs: null,
289
357
  cacheHitRate,
290
- gatewayCacheHitRate: total ? Number(r?.gatewayCacheHits ?? 0) / total : 0,
291
- fallbackRate: total ? Number(r?.fallbacks ?? 0) / total : 0,
358
+ gatewayCacheHitRate: total ? (Number(r?.gatewayCacheHits ?? 0) + agg.gateway) / total : 0,
359
+ fallbackRate: total ? (Number(r?.fallbacks ?? 0) + agg.fallback) / total : 0,
292
360
  };
293
361
  }
294
362
  function percentile(sorted, p) {