-
Notifications
You must be signed in to change notification settings - Fork 135
feat: global daily aggregation + admin stats page #2195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 23 commits
b5bee40
3a98438
7b5503c
3004478
5cf713b
456a972
a4fb0a5
d7857a5
b1d5116
6e07fa1
f10636a
b357d44
f0d3ca0
a5fc6c4
8b70e48
f0dbb15
00ea25d
4fbf03c
7360f84
a22ceed
7154f5f
81dcc7a
41f19fb
3914289
1aea710
ed16f82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,8 @@ import { | |
| tables, | ||
| projectHourlyStats, | ||
| projectHourlyModelStats, | ||
| globalModelStats, | ||
| globalSourceStats, | ||
| modelProviderMappingHistory, | ||
| modelHistory, | ||
| } from "@llmgateway/db"; | ||
|
|
@@ -806,6 +808,264 @@ admin.openapi(getTimeseries, async (c) => { | |
| }); | ||
| }); | ||
|
|
||
| const globalStatsRangeSchema = z.enum(["7d", "30d", "90d", "365d"]); | ||
| const globalStatsGroupBySchema = z.enum(["model", "source"]); | ||
|
|
||
| const globalStatsMetricsSchema = z.object({ | ||
| requestCount: z.number(), | ||
| errorCount: z.number(), | ||
| cacheCount: z.number(), | ||
| inputTokens: z.number(), | ||
| cachedTokens: z.number(), | ||
| outputTokens: z.number(), | ||
| totalTokens: z.number(), | ||
| cost: z.number(), | ||
| inputCost: z.number(), | ||
| cachedInputCost: z.number(), | ||
| outputCost: z.number(), | ||
| }); | ||
|
|
||
| const globalStatsTimeseriesPointSchema = globalStatsMetricsSchema | ||
| .extend({ | ||
| date: z.string(), | ||
| }) | ||
| .openapi({}); | ||
|
|
||
| const globalStatsBreakdownItemSchema = globalStatsMetricsSchema | ||
| .extend({ | ||
| key: z.string(), | ||
| label: z.string(), | ||
| }) | ||
| .openapi({}); | ||
|
|
||
| const globalStatsResponseSchema = z.object({ | ||
| range: globalStatsRangeSchema, | ||
| groupBy: globalStatsGroupBySchema, | ||
| totals: globalStatsMetricsSchema, | ||
| timeseries: z.array(globalStatsTimeseriesPointSchema), | ||
| breakdown: z.array(globalStatsBreakdownItemSchema), | ||
| }); | ||
|
|
||
| const getGlobalStats = createRoute({ | ||
| method: "get", | ||
| path: "/global-stats", | ||
| request: { | ||
| query: z.object({ | ||
| range: globalStatsRangeSchema.default("30d").optional(), | ||
| groupBy: globalStatsGroupBySchema.default("model").optional(), | ||
| }), | ||
| }, | ||
| responses: { | ||
| 200: { | ||
| content: { | ||
| "application/json": { | ||
| schema: globalStatsResponseSchema.openapi({}), | ||
| }, | ||
| }, | ||
| description: "Global aggregated stats grouped by model or x-source.", | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| admin.openapi(getGlobalStats, async (c) => { | ||
| const query = c.req.valid("query"); | ||
| const range = query.range ?? "30d"; | ||
| const groupBy = query.groupBy ?? "model"; | ||
|
|
||
| const rangeDays: Record<typeof range, number> = { | ||
| "7d": 7, | ||
| "30d": 30, | ||
| "90d": 90, | ||
| "365d": 365, | ||
| }; | ||
| const days = rangeDays[range]; | ||
|
|
||
| const dayMs = 24 * 60 * 60 * 1000; | ||
| const startDate = new Date(); | ||
| startDate.setUTCHours(0, 0, 0, 0); | ||
| const startMs = startDate.getTime() - (days - 1) * dayMs; // eslint-disable-line no-mixed-operators | ||
| startDate.setTime(startMs); | ||
|
|
||
| const sourceTable = | ||
| groupBy === "model" ? globalModelStats : globalSourceStats; | ||
|
|
||
| const metricSums = { | ||
| requestCount: | ||
| sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as( | ||
| "requestCount", | ||
| ), | ||
| errorCount: | ||
| sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as( | ||
| "errorCount", | ||
| ), | ||
| cacheCount: | ||
| sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as( | ||
| "cacheCount", | ||
| ), | ||
|
Comment on lines
+894
to
+904
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's look at the admin.ts file structure and the specific lines mentioned
head -n 910 apps/api/src/routes/admin.ts | tail -n 50Repository: theopenco/llmgateway Length of output: 1453 🏁 Script executed: # Let's get a broader view of the function containing these lines
head -n 920 apps/api/src/routes/admin.ts | tail -n 150Repository: theopenco/llmgateway Length of output: 4079 🏁 Script executed: # Search for all similar SUM casts in the file to see if this is a pattern
rg "SUM\(\$\{" apps/api/src/routes/admin.ts -A 1Repository: theopenco/llmgateway Length of output: 46 🏁 Script executed: # Check the schema to understand the source data types for these count fields
rg "requestCount|errorCount|cacheCount" apps/api/src/routes/admin.ts -B 2 -A 2 | head -n 80Repository: theopenco/llmgateway Length of output: 2169 🏁 Script executed: # Find imports related to SQL and the sourceTable to understand types
head -n 50 apps/api/src/routes/admin.tsRepository: theopenco/llmgateway Length of output: 1090 🏁 Script executed: # Check the database schema definition for these tables
rg "globalDailyModelStats|globalDailySourceStats" --type ts -A 5 -B 2 | head -n 100Repository: theopenco/llmgateway Length of output: 6610 🏁 Script executed: # Look for schema/table definitions in the db module
fd -e ts -path "*/db/*" | head -20Repository: theopenco/llmgateway Length of output: 233 🏁 Script executed: # Search for table schema definitions
rg "export const globalDaily" -A 10 | head -n 50Repository: theopenco/llmgateway Length of output: 2027 🏁 Script executed: # Get the full column definitions for globalDailyModelStats and globalDailySourceStats
rg "export const globalDailyModelStats" packages/db/src/schema.ts -A 30Repository: theopenco/llmgateway Length of output: 1348 🏁 Script executed: # Also check the sourceStats schema
rg "export const globalDailySourceStats" packages/db/src/schema.ts -A 30Repository: theopenco/llmgateway Length of output: 1401 🏁 Script executed: # Look for the column definitions - search for requestCount, errorCount, cacheCount
rg "requestCount|errorCount|cacheCount" packages/db/src/schema.ts -B 2 -A 2Repository: theopenco/llmgateway Length of output: 3453 🏁 Script executed: # Search for other instances of SUM on these count fields in the codebase
rg "SUM.*requestCount|SUM.*errorCount|SUM.*cacheCount" --type ts -B 2 -A 2Repository: theopenco/llmgateway Length of output: 8047 🏁 Script executed: # Check if there are any other aggregate queries on the daily stats tables
rg "globalDailyModelStats|globalDailySourceStats" apps/api/src/routes/admin.ts | grep -i "select\|sum\|aggregate"Repository: theopenco/llmgateway Length of output: 46 Remove At your stated scale (1000 r/s), summing request/error/cache counts over 365 days can exceed the 32-bit integer limit (~2.1B). These casts on lines 894, 898, and 902 force PostgreSQL SUM results back to Suggested fix- requestCount:
- sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
+ requestCount:
+ sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::bigint`.as(
"requestCount",
),
- errorCount:
- sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as(
+ errorCount:
+ sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::bigint`.as(
"errorCount",
),
- cacheCount:
- sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as(
+ cacheCount:
+ sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::bigint`.as(
"cacheCount",
),🤖 Prompt for AI Agents |
||
| inputTokens: | ||
| sql<number>`COALESCE(SUM(CAST(${sourceTable.inputTokens} AS NUMERIC)), 0)::float8`.as( | ||
| "inputTokens", | ||
| ), | ||
| cachedTokens: | ||
| sql<number>`COALESCE(SUM(CAST(${sourceTable.cachedTokens} AS NUMERIC)), 0)::float8`.as( | ||
| "cachedTokens", | ||
| ), | ||
| outputTokens: | ||
| sql<number>`COALESCE(SUM(CAST(${sourceTable.outputTokens} AS NUMERIC)), 0)::float8`.as( | ||
| "outputTokens", | ||
| ), | ||
| totalTokens: | ||
| sql<number>`COALESCE(SUM(CAST(${sourceTable.totalTokens} AS NUMERIC)), 0)::float8`.as( | ||
| "totalTokens", | ||
| ), | ||
| cost: sql<number>`COALESCE(SUM(${sourceTable.cost}), 0)::float8`.as("cost"), | ||
| inputCost: | ||
| sql<number>`COALESCE(SUM(${sourceTable.inputCost}), 0)::float8`.as( | ||
| "inputCost", | ||
| ), | ||
| cachedInputCost: | ||
| sql<number>`COALESCE(SUM(${sourceTable.cachedInputCost}), 0)::float8`.as( | ||
| "cachedInputCost", | ||
| ), | ||
| outputCost: | ||
| sql<number>`COALESCE(SUM(${sourceTable.outputCost}), 0)::float8`.as( | ||
| "outputCost", | ||
| ), | ||
| }; | ||
|
|
||
| const dateExpr = | ||
| sql<string>`to_char(${sourceTable.dayTimestamp}, 'YYYY-MM-DD')`.as("date"); | ||
|
|
||
| const timeseriesRows = await db | ||
| .select({ | ||
| date: dateExpr, | ||
| ...metricSums, | ||
| }) | ||
| .from(sourceTable) | ||
| .where(gte(sourceTable.dayTimestamp, startDate)) | ||
| .groupBy(sourceTable.dayTimestamp) | ||
| .orderBy(asc(sourceTable.dayTimestamp)); | ||
|
Comment on lines
+883
to
+947
|
||
|
|
||
| const timeseriesMap = new Map< | ||
| string, | ||
| z.infer<typeof globalStatsTimeseriesPointSchema> | ||
| >(); | ||
| for (const row of timeseriesRows) { | ||
| timeseriesMap.set(row.date, { | ||
| date: row.date, | ||
| requestCount: Number(row.requestCount), | ||
| errorCount: Number(row.errorCount), | ||
| cacheCount: Number(row.cacheCount), | ||
| inputTokens: Number(row.inputTokens), | ||
| cachedTokens: Number(row.cachedTokens), | ||
| outputTokens: Number(row.outputTokens), | ||
| totalTokens: Number(row.totalTokens), | ||
| cost: Number(row.cost), | ||
| inputCost: Number(row.inputCost), | ||
| cachedInputCost: Number(row.cachedInputCost), | ||
| outputCost: Number(row.outputCost), | ||
| }); | ||
| } | ||
|
|
||
| const totals: z.infer<typeof globalStatsMetricsSchema> = { | ||
| requestCount: 0, | ||
| errorCount: 0, | ||
| cacheCount: 0, | ||
| inputTokens: 0, | ||
| cachedTokens: 0, | ||
| outputTokens: 0, | ||
| totalTokens: 0, | ||
| cost: 0, | ||
| inputCost: 0, | ||
| cachedInputCost: 0, | ||
| outputCost: 0, | ||
| }; | ||
|
|
||
| const timeseries: z.infer<typeof globalStatsTimeseriesPointSchema>[] = []; | ||
| for (let i = 0; i < days; i++) { | ||
| const cur = new Date(startDate.getTime() + i * dayMs); // eslint-disable-line no-mixed-operators | ||
| const dateStr = cur.toISOString().split("T")[0]; | ||
| const point = timeseriesMap.get(dateStr) ?? { | ||
| date: dateStr, | ||
| requestCount: 0, | ||
| errorCount: 0, | ||
| cacheCount: 0, | ||
| inputTokens: 0, | ||
| cachedTokens: 0, | ||
| outputTokens: 0, | ||
| totalTokens: 0, | ||
| cost: 0, | ||
| inputCost: 0, | ||
| cachedInputCost: 0, | ||
| outputCost: 0, | ||
| }; | ||
| timeseries.push(point); | ||
| totals.requestCount += point.requestCount; | ||
| totals.errorCount += point.errorCount; | ||
| totals.cacheCount += point.cacheCount; | ||
| totals.inputTokens += point.inputTokens; | ||
| totals.cachedTokens += point.cachedTokens; | ||
| totals.outputTokens += point.outputTokens; | ||
| totals.totalTokens += point.totalTokens; | ||
| totals.cost += point.cost; | ||
| totals.inputCost += point.inputCost; | ||
| totals.cachedInputCost += point.cachedInputCost; | ||
| totals.outputCost += point.outputCost; | ||
| } | ||
|
|
||
| const breakdownRows = | ||
| groupBy === "model" | ||
| ? await db | ||
| .select({ | ||
| usedModel: globalModelStats.usedModel, | ||
| usedProvider: globalModelStats.usedProvider, | ||
| ...metricSums, | ||
| }) | ||
| .from(globalModelStats) | ||
| .where(gte(globalModelStats.dayTimestamp, startDate)) | ||
| .groupBy(globalModelStats.usedModel, globalModelStats.usedProvider) | ||
| .orderBy(desc(metricSums.requestCount)) | ||
| : await db | ||
| .select({ | ||
| source: globalSourceStats.source, | ||
| ...metricSums, | ||
| }) | ||
| .from(globalSourceStats) | ||
| .where(gte(globalSourceStats.dayTimestamp, startDate)) | ||
| .groupBy(globalSourceStats.source) | ||
| .orderBy(desc(metricSums.requestCount)); | ||
|
Comment on lines
+1033
to
+1036
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| const breakdown: z.infer<typeof globalStatsBreakdownItemSchema>[] = | ||
| breakdownRows.map((row) => { | ||
| const isModel = "usedModel" in row; | ||
| const key = isModel ? `${row.usedProvider}/${row.usedModel}` : row.source; | ||
| const label = isModel ? row.usedModel : row.source; | ||
|
|
||
| return { | ||
| key, | ||
| label, | ||
| requestCount: Number(row.requestCount), | ||
| errorCount: Number(row.errorCount), | ||
| cacheCount: Number(row.cacheCount), | ||
| inputTokens: Number(row.inputTokens), | ||
| cachedTokens: Number(row.cachedTokens), | ||
| outputTokens: Number(row.outputTokens), | ||
| totalTokens: Number(row.totalTokens), | ||
| cost: Number(row.cost), | ||
| inputCost: Number(row.inputCost), | ||
| cachedInputCost: Number(row.cachedInputCost), | ||
| outputCost: Number(row.outputCost), | ||
| }; | ||
| }); | ||
|
|
||
| return c.json({ | ||
| range, | ||
| groupBy, | ||
| totals, | ||
| timeseries, | ||
| breakdown, | ||
| }); | ||
| }); | ||
|
|
||
| admin.openapi(getOrganizations, async (c) => { | ||
| const query = c.req.valid("query"); | ||
| const limit = query.limit ?? 50; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For high-traffic ranges this
SUM(... )::intcan make/admin/global-statsfail withinteger out of range: PostgreSQL'sSUM(integer)can exceed 2,147,483,647 over 30/90/365 days, and the commit explicitly targets up to ~1000 r/s where 30 days is ~2.6B requests. Keep these count sums as bigint/numeric or cast tofloat8before returning; the same applies to the adjacenterrorCountandcacheCountsums.Useful? React with 👍 / 👎.