@oneuptime/common 12.0.24 → 12.0.25

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.
Files changed (43) hide show
  1. package/Models/AnalyticsModels/Span.ts +101 -0
  2. package/Server/API/BaseAPI.ts +0 -24
  3. package/Server/API/SlackAPI.ts +0 -2
  4. package/Server/Middleware/SlackAuthorization.ts +96 -18
  5. package/Server/Utils/Telemetry/LlmMetricSpend.ts +56 -5
  6. package/Server/Utils/Telemetry/LlmSpan.ts +46 -0
  7. package/Server/Utils/Workspace/Slack/Actions/Auth.ts +0 -12
  8. package/Tests/App/Dashboard/LlmCallsTableIdentity.test.tsx +322 -0
  9. package/Tests/App/Dashboard/LlmOverview.test.tsx +335 -0
  10. package/Tests/App/Dashboard/LlmSpanDisplay.test.ts +391 -0
  11. package/Tests/App/Dashboard/LlmUsageBreakdown.test.tsx +1007 -0
  12. package/Tests/Server/API/BaseAPI.test.ts +41 -0
  13. package/Tests/Server/API/BaseAPIUpdatePayloadValidation.test.ts +9 -16
  14. package/Tests/Server/Middleware/SlackAuthorization.test.ts +262 -5
  15. package/Tests/Server/Utils/Telemetry/LlmCostBudgetEvaluator.test.ts +37 -18
  16. package/Tests/Server/Utils/Telemetry/LlmMetricSpend.test.ts +143 -2
  17. package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +804 -0
  18. package/Tests/Types/Telemetry/LlmMetricConventions.test.ts +391 -0
  19. package/Tests/Utils/Telemetry/LlmMetricQuery.test.ts +298 -0
  20. package/Types/Telemetry/LlmConventions.ts +255 -0
  21. package/Types/Telemetry/LlmMetricConventions.ts +212 -7
  22. package/Utils/Telemetry/LlmMetricQuery.ts +83 -0
  23. package/build/dist/Models/AnalyticsModels/Span.js +89 -0
  24. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  25. package/build/dist/Server/API/BaseAPI.js +3 -19
  26. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  27. package/build/dist/Server/API/SlackAPI.js +0 -2
  28. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  29. package/build/dist/Server/Middleware/SlackAuthorization.js +58 -7
  30. package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
  31. package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js +52 -3
  32. package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js.map +1 -1
  33. package/build/dist/Server/Utils/Telemetry/LlmSpan.js +24 -1
  34. package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
  35. package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js +0 -10
  36. package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js.map +1 -1
  37. package/build/dist/Types/Telemetry/LlmConventions.js +236 -0
  38. package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -1
  39. package/build/dist/Types/Telemetry/LlmMetricConventions.js +197 -5
  40. package/build/dist/Types/Telemetry/LlmMetricConventions.js.map +1 -1
  41. package/build/dist/Utils/Telemetry/LlmMetricQuery.js +57 -1
  42. package/build/dist/Utils/Telemetry/LlmMetricQuery.js.map +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,1007 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ jest,
7
+ test,
8
+ } from "@jest/globals";
9
+ import "@testing-library/jest-dom";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ within,
17
+ } from "@testing-library/react";
18
+ import * as React from "react";
19
+ import { MemoryRouter } from "react-router-dom";
20
+ import getJestMockFunction, { MockFunction } from "../../MockType";
21
+
22
+ /*
23
+ * ---------------------------------------------------------------------------
24
+ * The "Usage" leaderboard — who is spending what
25
+ * ---------------------------------------------------------------------------
26
+ *
27
+ * These properties of the page are invisible in a snapshot and each is a real
28
+ * reporting bug if it regresses:
29
+ *
30
+ * - the ranking. A leaderboard that is not ordered by spend does not answer
31
+ * the question it exists for.
32
+ * - the Unattributed row. Spans without an identity attribute are a real
33
+ * bucket of money. Dropping them would make this page quietly disagree
34
+ * with the Overview KPIs, which count every LLM span.
35
+ * - the service-id resolution. A raw ObjectID in a manager-facing table is
36
+ * useless.
37
+ * - the span-first / metric-fallback rule. Metrics stand in for spans ONLY
38
+ * when spans reported nothing, and the two are NEVER summed — an emitter
39
+ * producing both signals would otherwise have every dollar counted twice.
40
+ * - the metric rollup's grouping keys. Grouping on user.email alone
41
+ * collapses an id-only fleet (Cursor, Claude Code on API-key auth) into
42
+ * one Unattributed row.
43
+ * - what a FAILED cost aggregate renders. Cost is the ranking dimension, so
44
+ * rows built without it are a table of unmeasured zeros.
45
+ */
46
+
47
+ const aggregateMock: MockFunction = getJestMockFunction();
48
+ const getListMock: MockFunction = getJestMockFunction();
49
+ const getCurrentProjectIdMock: MockFunction = getJestMockFunction();
50
+
51
+ jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
52
+ return {
53
+ __esModule: true,
54
+ default: {
55
+ aggregate: (...args: Array<unknown>) => {
56
+ return aggregateMock(...args);
57
+ },
58
+ },
59
+ };
60
+ });
61
+
62
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
63
+ return {
64
+ __esModule: true,
65
+ default: {
66
+ getList: (...args: Array<unknown>) => {
67
+ return getListMock(...args);
68
+ },
69
+ },
70
+ };
71
+ });
72
+
73
+ jest.mock("../../../UI/Utils/Project", () => {
74
+ return {
75
+ __esModule: true,
76
+ default: {
77
+ getCurrentProjectId: (...args: Array<unknown>) => {
78
+ return getCurrentProjectIdMock(...args);
79
+ },
80
+ },
81
+ };
82
+ });
83
+
84
+ import LlmUsageBreakdown from "../../../../App/FeatureSet/Dashboard/src/Components/AI/LlmUsageBreakdown";
85
+ import Service from "../../../Models/DatabaseModels/Service";
86
+ import AggregatedModel from "../../../Types/BaseDatabase/AggregatedModel";
87
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
88
+ import Includes from "../../../Types/BaseDatabase/Includes";
89
+ import ObjectID from "../../../Types/ObjectID";
90
+ import { JSONObject } from "../../../Types/JSON";
91
+ import {
92
+ LlmMetricUserAttributeKeys,
93
+ LlmMicroUsdCostMetricNames,
94
+ } from "../../../Types/Telemetry/LlmMetricConventions";
95
+ import { METRIC_USER_ATTRIBUTE_KEY } from "../../../Utils/Telemetry/LlmMetricQuery";
96
+
97
+ const PROJECT_ID: ObjectID = new ObjectID(
98
+ "11111111-1111-4111-8111-111111111111",
99
+ );
100
+
101
+ const CHECKOUT_SERVICE_ID: string = "22222222-2222-4222-8222-222222222222";
102
+
103
+ interface AggregateCall {
104
+ modelType: { new (): unknown };
105
+ aggregateBy: JSONObject;
106
+ }
107
+
108
+ type RowFunction = (data: {
109
+ value: number;
110
+ columns?: JSONObject | undefined;
111
+ attributes?: JSONObject | undefined;
112
+ }) => AggregatedModel;
113
+
114
+ const row: RowFunction = (data: {
115
+ value: number;
116
+ columns?: JSONObject | undefined;
117
+ attributes?: JSONObject | undefined;
118
+ }): AggregatedModel => {
119
+ const aggregatedRow: AggregatedModel = {
120
+ timestamp: new Date("2026-08-20T00:00:00.000Z"),
121
+ value: data.value,
122
+ ...(data.columns || {}),
123
+ };
124
+
125
+ if (data.attributes) {
126
+ aggregatedRow["attributes"] = data.attributes;
127
+ }
128
+
129
+ return aggregatedRow;
130
+ };
131
+
132
+ type ResultFunction = (rows: Array<AggregatedModel>) => AggregatedResult;
133
+
134
+ const result: ResultFunction = (
135
+ rows: Array<AggregatedModel>,
136
+ ): AggregatedResult => {
137
+ return { data: rows };
138
+ };
139
+
140
+ type ModelNameFunction = (call: AggregateCall) => string;
141
+
142
+ /*
143
+ * The component passes the model CLASS, so the class name is what tells a
144
+ * Span aggregate apart from a Metric one without importing either model's
145
+ * decorators into the test.
146
+ */
147
+ const modelNameOf: ModelNameFunction = (call: AggregateCall): string => {
148
+ return (call.modelType as unknown as { name: string }).name;
149
+ };
150
+
151
+ type ColumnNameFunction = (call: AggregateCall) => string;
152
+
153
+ const columnOf: ColumnNameFunction = (call: AggregateCall): string => {
154
+ return String(call.aggregateBy["aggregateColumnName"]);
155
+ };
156
+
157
+ type IsMicroUsdFunction = (call: AggregateCall) => boolean;
158
+
159
+ const isMicroUsdCostCall: IsMicroUsdFunction = (
160
+ call: AggregateCall,
161
+ ): boolean => {
162
+ const query: JSONObject = call.aggregateBy["query"] as JSONObject;
163
+ const names: Includes = query["name"] as unknown as Includes;
164
+
165
+ return (names.values as Array<string>).includes(
166
+ LlmMicroUsdCostMetricNames[0]!,
167
+ );
168
+ };
169
+
170
+ type RowTextsFunction = () => Array<Array<string>>;
171
+
172
+ const renderedRows: RowTextsFunction = (): Array<Array<string>> => {
173
+ return screen
174
+ .queryAllByTestId("llm-usage-row")
175
+ .map((element: HTMLElement) => {
176
+ return Array.from(element.querySelectorAll("td")).map(
177
+ (cell: Element): string => {
178
+ return (cell.textContent || "").trim();
179
+ },
180
+ );
181
+ });
182
+ };
183
+
184
+ type RenderFunction = () => Promise<void>;
185
+
186
+ const renderBreakdown: RenderFunction = async (): Promise<void> => {
187
+ await act(async () => {
188
+ render(
189
+ <MemoryRouter>
190
+ <LlmUsageBreakdown />
191
+ </MemoryRouter>,
192
+ );
193
+ });
194
+ };
195
+
196
+ beforeEach(() => {
197
+ aggregateMock.mockReset();
198
+ getListMock.mockReset();
199
+ getCurrentProjectIdMock.mockReset();
200
+
201
+ getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
202
+ getListMock.mockResolvedValue({ data: [] } as never);
203
+ });
204
+
205
+ afterEach(() => {
206
+ cleanup();
207
+ });
208
+
209
+ describe("LlmUsageBreakdown - the ranked leaderboard", () => {
210
+ test("ranks employees by cost and shows their calls, tokens and share", async () => {
211
+ aggregateMock.mockImplementation((call: unknown) => {
212
+ const aggregateCall: AggregateCall = call as AggregateCall;
213
+
214
+ if (columnOf(aggregateCall) === "llmCost") {
215
+ return Promise.resolve(
216
+ result([
217
+ row({
218
+ value: 1,
219
+ columns: { llmUserEmail: "junior@example.com", llmUserId: "" },
220
+ }),
221
+ row({
222
+ value: 9,
223
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
224
+ }),
225
+ ]),
226
+ );
227
+ }
228
+
229
+ if (columnOf(aggregateCall) === "spanId") {
230
+ return Promise.resolve(
231
+ result([
232
+ row({
233
+ value: 4,
234
+ columns: { llmUserEmail: "junior@example.com", llmUserId: "" },
235
+ }),
236
+ row({
237
+ value: 40,
238
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
239
+ }),
240
+ ]),
241
+ );
242
+ }
243
+
244
+ if (columnOf(aggregateCall) === "llmInputTokens") {
245
+ return Promise.resolve(
246
+ result([
247
+ row({
248
+ value: 100,
249
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
250
+ }),
251
+ ]),
252
+ );
253
+ }
254
+
255
+ if (columnOf(aggregateCall) === "llmOutputTokens") {
256
+ return Promise.resolve(
257
+ result([
258
+ row({
259
+ value: 25,
260
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
261
+ }),
262
+ ]),
263
+ );
264
+ }
265
+
266
+ if (columnOf(aggregateCall) === "llmTotalTokens") {
267
+ return Promise.resolve(
268
+ result([
269
+ row({
270
+ value: 125,
271
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
272
+ }),
273
+ ]),
274
+ );
275
+ }
276
+
277
+ return Promise.resolve(result([]));
278
+ });
279
+
280
+ await renderBreakdown();
281
+
282
+ const rows: Array<Array<string>> = renderedRows();
283
+
284
+ expect(rows).toHaveLength(2);
285
+
286
+ // Ranked by spend, not by the order ClickHouse happened to return.
287
+ expect(rows[0]![0]).toBe("1");
288
+ expect(rows[0]![1]).toBe("senior@example.com");
289
+ expect(rows[0]![2]).toBe("40");
290
+ expect(rows[0]![3]).toBe("100");
291
+ expect(rows[0]![4]).toBe("25");
292
+ expect(rows[0]![5]).toBe("125");
293
+ expect(rows[0]![6]).toBe("$9.0000");
294
+ expect(rows[0]![7]).toContain("90.0%");
295
+
296
+ expect(rows[1]![0]).toBe("2");
297
+ expect(rows[1]![1]).toBe("junior@example.com");
298
+ expect(rows[1]![6]).toBe("$1.0000");
299
+ expect(rows[1]![7]).toContain("10.0%");
300
+ });
301
+
302
+ test("falls back from the email to the user id, merging both into one person", async () => {
303
+ aggregateMock.mockImplementation((call: unknown) => {
304
+ const aggregateCall: AggregateCall = call as AggregateCall;
305
+
306
+ if (columnOf(aggregateCall) === "llmCost") {
307
+ return Promise.resolve(
308
+ result([
309
+ /*
310
+ * Cursor emits an opaque id and no email. It must appear as its
311
+ * own person rather than collapsing into Unattributed.
312
+ */
313
+ row({
314
+ value: 2,
315
+ columns: { llmUserEmail: "", llmUserId: "cursor-4471" },
316
+ }),
317
+ ]),
318
+ );
319
+ }
320
+
321
+ return Promise.resolve(result([]));
322
+ });
323
+
324
+ await renderBreakdown();
325
+
326
+ const rows: Array<Array<string>> = renderedRows();
327
+
328
+ expect(rows).toHaveLength(1);
329
+ expect(rows[0]![1]).toBe("cursor-4471");
330
+ expect(
331
+ screen.queryByTestId("llm-usage-unattributed"),
332
+ ).not.toBeInTheDocument();
333
+ });
334
+ });
335
+
336
+ describe("LlmUsageBreakdown - unattributed spend", () => {
337
+ test("renders an explicit Unattributed row instead of dropping the spend", async () => {
338
+ aggregateMock.mockImplementation((call: unknown) => {
339
+ const aggregateCall: AggregateCall = call as AggregateCall;
340
+
341
+ if (columnOf(aggregateCall) === "llmCost") {
342
+ return Promise.resolve(
343
+ result([
344
+ row({
345
+ value: 3,
346
+ columns: { llmUserEmail: "", llmUserId: "" },
347
+ }),
348
+ row({
349
+ value: 1,
350
+ columns: { llmUserEmail: "named@example.com", llmUserId: "" },
351
+ }),
352
+ ]),
353
+ );
354
+ }
355
+
356
+ return Promise.resolve(result([]));
357
+ });
358
+
359
+ await renderBreakdown();
360
+
361
+ const rows: Array<Array<string>> = renderedRows();
362
+
363
+ expect(rows).toHaveLength(2);
364
+ expect(rows[0]![1]).toBe("Unattributed");
365
+ expect(rows[0]![6]).toBe("$3.0000");
366
+
367
+ /*
368
+ * The unattributed bucket is in the denominator too. If it were dropped,
369
+ * the named employee would read as 100% of spend and the page would
370
+ * disagree with the Overview KPIs.
371
+ */
372
+ expect(rows[0]![7]).toContain("75.0%");
373
+ expect(rows[1]![7]).toContain("25.0%");
374
+
375
+ expect(screen.getByTestId("llm-usage-unattributed")).toBeInTheDocument();
376
+ });
377
+
378
+ test("says why attribution can be missing and links to the setup docs", async () => {
379
+ aggregateMock.mockResolvedValue(result([]) as never);
380
+
381
+ await renderBreakdown();
382
+
383
+ expect(
384
+ screen.getByText(/did not send an identity attribute/i),
385
+ ).toBeInTheDocument();
386
+
387
+ const docsLink: HTMLElement = screen.getByRole("link", {
388
+ name: /attribute AI coding assistant usage/i,
389
+ });
390
+
391
+ expect(docsLink).toHaveAttribute(
392
+ "href",
393
+ "/docs/telemetry/ai-coding-assistants",
394
+ );
395
+ });
396
+ });
397
+
398
+ describe("LlmUsageBreakdown - the application dimension", () => {
399
+ test("resolves primaryEntityId to the service name", async () => {
400
+ const checkout: Service = new Service();
401
+ checkout._id = CHECKOUT_SERVICE_ID;
402
+ checkout.name = "checkout-api";
403
+
404
+ getListMock.mockResolvedValue({ data: [checkout] } as never);
405
+
406
+ aggregateMock.mockImplementation((call: unknown) => {
407
+ const aggregateCall: AggregateCall = call as AggregateCall;
408
+
409
+ if (columnOf(aggregateCall) === "llmCost") {
410
+ return Promise.resolve(
411
+ result([
412
+ row({
413
+ value: 5,
414
+ columns: { primaryEntityId: CHECKOUT_SERVICE_ID },
415
+ }),
416
+ row({
417
+ value: 1,
418
+ columns: {
419
+ primaryEntityId: "33333333-3333-4333-8333-333333333333",
420
+ },
421
+ }),
422
+ ]),
423
+ );
424
+ }
425
+
426
+ return Promise.resolve(result([]));
427
+ });
428
+
429
+ await renderBreakdown();
430
+
431
+ await act(async () => {
432
+ fireEvent.click(
433
+ screen.getByRole("button", { name: "Application / Service" }),
434
+ );
435
+ });
436
+
437
+ const rows: Array<HTMLElement> = screen.getAllByTestId("llm-usage-row");
438
+
439
+ expect(within(rows[0]!).getByText("checkout-api")).toBeInTheDocument();
440
+ expect(
441
+ within(rows[0]!).queryByText(CHECKOUT_SERVICE_ID),
442
+ ).not.toBeInTheDocument();
443
+
444
+ /*
445
+ * A service that is not in the list (deleted, or the list request
446
+ * failed) still gets a row — the id is a poor label but losing the spend
447
+ * would be worse.
448
+ */
449
+ expect(
450
+ within(rows[1]!).getByText("33333333-3333-4333-8333-333333333333"),
451
+ ).toBeInTheDocument();
452
+ });
453
+ });
454
+
455
+ describe("LlmUsageBreakdown - span-first, metric-fallback", () => {
456
+ test("consults GenAI metrics only when spans reported nothing, and labels them", async () => {
457
+ aggregateMock.mockImplementation((call: unknown) => {
458
+ const aggregateCall: AggregateCall = call as AggregateCall;
459
+
460
+ if (modelNameOf(aggregateCall) === "Span") {
461
+ // Spans reported nothing at all — this is the fallback's precondition.
462
+ return Promise.resolve(result([]));
463
+ }
464
+
465
+ if (isMicroUsdCostCall(aggregateCall)) {
466
+ // Codex reports MILLIONTHS of a dollar: 2,500,000 µUSD = $2.50.
467
+ return Promise.resolve(
468
+ result([
469
+ row({
470
+ value: 2500000,
471
+ attributes: { [METRIC_USER_ATTRIBUTE_KEY]: "codex@example.com" },
472
+ }),
473
+ ]),
474
+ );
475
+ }
476
+
477
+ return Promise.resolve(
478
+ result([
479
+ row({
480
+ value: 7,
481
+ attributes: { [METRIC_USER_ATTRIBUTE_KEY]: "claude@example.com" },
482
+ }),
483
+ ]),
484
+ );
485
+ });
486
+
487
+ await renderBreakdown();
488
+
489
+ const rows: Array<Array<string>> = renderedRows();
490
+
491
+ expect(rows).toHaveLength(2);
492
+
493
+ expect(rows[0]![1]).toBe("claude@example.com");
494
+ expect(rows[0]![6]).toBe("$7.0000");
495
+ // The metric stream carries spend, not per-call detail.
496
+ expect(rows[0]![2]).toBe("—");
497
+ expect(rows[0]![5]).toBe("—");
498
+
499
+ // The micro-USD scale is applied before the addition, not after.
500
+ expect(rows[1]![1]).toBe("codex@example.com");
501
+ expect(rows[1]![6]).toBe("$2.5000");
502
+
503
+ expect(screen.getByTestId("llm-usage-source-hint")).toHaveTextContent(
504
+ "from GenAI metrics",
505
+ );
506
+ });
507
+
508
+ test("never sums spans and metrics: a metric-emitting project with spans reads span figures only", async () => {
509
+ aggregateMock.mockImplementation((call: unknown) => {
510
+ const aggregateCall: AggregateCall = call as AggregateCall;
511
+
512
+ if (modelNameOf(aggregateCall) === "Metric") {
513
+ /*
514
+ * This project DOES publish a cost metric. If the component ever
515
+ * summed the two signals, $4 of span cost plus $100 of metric cost
516
+ * would surface as $104 — every dollar counted twice.
517
+ */
518
+ return Promise.resolve(
519
+ result([
520
+ row({
521
+ value: 100,
522
+ attributes: { [METRIC_USER_ATTRIBUTE_KEY]: "both@example.com" },
523
+ }),
524
+ ]),
525
+ );
526
+ }
527
+
528
+ if (columnOf(aggregateCall) === "llmCost") {
529
+ return Promise.resolve(
530
+ result([
531
+ row({
532
+ value: 4,
533
+ columns: { llmUserEmail: "both@example.com", llmUserId: "" },
534
+ }),
535
+ ]),
536
+ );
537
+ }
538
+
539
+ return Promise.resolve(result([]));
540
+ });
541
+
542
+ await renderBreakdown();
543
+
544
+ const rows: Array<Array<string>> = renderedRows();
545
+
546
+ expect(rows).toHaveLength(1);
547
+ expect(rows[0]![1]).toBe("both@example.com");
548
+ expect(rows[0]![6]).toBe("$4.0000");
549
+
550
+ // The metric stream is not even queried while spans have something to say.
551
+ const metricCalls: Array<unknown> = aggregateMock.mock.calls.filter(
552
+ (args: Array<unknown>): boolean => {
553
+ return modelNameOf(args[0] as AggregateCall) === "Metric";
554
+ },
555
+ );
556
+
557
+ expect(metricCalls).toHaveLength(0);
558
+ expect(
559
+ screen.queryByTestId("llm-usage-source-hint"),
560
+ ).not.toBeInTheDocument();
561
+ });
562
+
563
+ test("does not substitute metrics when the span aggregate FAILED rather than came back empty", async () => {
564
+ aggregateMock.mockImplementation((call: unknown) => {
565
+ const aggregateCall: AggregateCall = call as AggregateCall;
566
+
567
+ if (modelNameOf(aggregateCall) === "Span") {
568
+ return Promise.reject(new Error("clickhouse timeout"));
569
+ }
570
+
571
+ return Promise.resolve(
572
+ result([
573
+ row({
574
+ value: 42,
575
+ attributes: { [METRIC_USER_ATTRIBUTE_KEY]: "ghost@example.com" },
576
+ }),
577
+ ]),
578
+ );
579
+ });
580
+
581
+ await renderBreakdown();
582
+
583
+ // An error is an error. Dressing it up as metric data would be a lie.
584
+ expect(screen.queryByText("ghost@example.com")).not.toBeInTheDocument();
585
+ expect(screen.getByText(/Usage could not be loaded/i)).toBeInTheDocument();
586
+ });
587
+
588
+ test.each([
589
+ ["Provider", /do not report a provider/i],
590
+ ["Application / Service", /not attached to a OneUptime service/i],
591
+ ])(
592
+ "the %s dimension does not consult metrics, and says why instead of showing a bare empty state",
593
+ async (dimensionLabel: string, expectedNote: RegExp) => {
594
+ aggregateMock.mockImplementation((call: unknown) => {
595
+ const aggregateCall: AggregateCall = call as AggregateCall;
596
+
597
+ if (modelNameOf(aggregateCall) === "Span") {
598
+ return Promise.resolve(result([]));
599
+ }
600
+
601
+ /*
602
+ * The metric stream has plenty to say. It just cannot answer THIS
603
+ * question: a vendor cost counter carries no gen_ai.system and no
604
+ * OneUptime service id, so there is nothing to group on.
605
+ */
606
+ return Promise.resolve(
607
+ result([
608
+ row({
609
+ value: 12,
610
+ attributes: { [METRIC_USER_ATTRIBUTE_KEY]: "metric@example.com" },
611
+ }),
612
+ ]),
613
+ );
614
+ });
615
+
616
+ await renderBreakdown();
617
+
618
+ aggregateMock.mockClear();
619
+
620
+ await act(async () => {
621
+ fireEvent.click(screen.getByRole("button", { name: dimensionLabel }));
622
+ });
623
+
624
+ const metricCalls: Array<unknown> = aggregateMock.mock.calls.filter(
625
+ (args: Array<unknown>): boolean => {
626
+ return modelNameOf(args[0] as AggregateCall) === "Metric";
627
+ },
628
+ );
629
+
630
+ expect(metricCalls).toHaveLength(0);
631
+ expect(screen.getByText(/No LLM usage found/i)).toBeInTheDocument();
632
+
633
+ /*
634
+ * The whole point of this branch: an unexplained empty table reads as
635
+ * "OneUptime lost my data". The truth is that the signal does not
636
+ * exist, and the page has to say so.
637
+ */
638
+ expect(
639
+ screen.getByTestId("llm-usage-no-metric-signal"),
640
+ ).toHaveTextContent(expectedNote);
641
+ },
642
+ );
643
+
644
+ test.each([["Model"], ["Team"]])(
645
+ "the %s dimension has a metric fallback too, so a metrics-only coding agent is not four empty tabs",
646
+ async (dimensionLabel: string) => {
647
+ aggregateMock.mockImplementation((call: unknown) => {
648
+ const aggregateCall: AggregateCall = call as AggregateCall;
649
+
650
+ if (modelNameOf(aggregateCall) === "Span") {
651
+ return Promise.resolve(result([]));
652
+ }
653
+
654
+ if (isMicroUsdCostCall(aggregateCall)) {
655
+ return Promise.resolve(result([]));
656
+ }
657
+
658
+ /*
659
+ * One datapoint carrying every attribute a Cursor / Claude Code cost
660
+ * counter carries. Whichever dimension is selected must find its own
661
+ * key in there.
662
+ */
663
+ return Promise.resolve(
664
+ result([
665
+ row({
666
+ value: 8,
667
+ attributes: {
668
+ "cursor.model.name": "claude-4-sonnet",
669
+ "resource.team.id": "platform",
670
+ },
671
+ }),
672
+ ]),
673
+ );
674
+ });
675
+
676
+ await renderBreakdown();
677
+
678
+ await act(async () => {
679
+ fireEvent.click(screen.getByRole("button", { name: dimensionLabel }));
680
+ });
681
+
682
+ const rows: Array<Array<string>> = renderedRows();
683
+
684
+ expect(rows).toHaveLength(1);
685
+ expect(rows[0]![1]).toBe(
686
+ dimensionLabel === "Model" ? "claude-4-sonnet" : "platform",
687
+ );
688
+ expect(rows[0]![6]).toBe("$8.0000");
689
+
690
+ // Labelled, exactly as the Employee fallback is.
691
+ expect(screen.getByTestId("llm-usage-source-hint")).toHaveTextContent(
692
+ "from GenAI metrics",
693
+ );
694
+ },
695
+ );
696
+
697
+ test.each([["Model"], ["Team"]])(
698
+ "the %s metric fallback never sums spans and metrics either",
699
+ async (dimensionLabel: string) => {
700
+ aggregateMock.mockImplementation((call: unknown) => {
701
+ const aggregateCall: AggregateCall = call as AggregateCall;
702
+
703
+ if (modelNameOf(aggregateCall) === "Metric") {
704
+ /*
705
+ * $100 of metric spend sitting right there. If the dimension ever
706
+ * summed the two signals it would surface as $103.
707
+ */
708
+ return Promise.resolve(
709
+ result([
710
+ row({
711
+ value: 100,
712
+ attributes: {
713
+ "cursor.model.name": "claude-4-sonnet",
714
+ "resource.team.id": "platform",
715
+ },
716
+ }),
717
+ ]),
718
+ );
719
+ }
720
+
721
+ if (columnOf(aggregateCall) === "llmCost") {
722
+ return Promise.resolve(
723
+ result([
724
+ row({
725
+ value: 3,
726
+ columns: {
727
+ llmRequestModel: "claude-4-sonnet",
728
+ llmTeam: "platform",
729
+ },
730
+ }),
731
+ ]),
732
+ );
733
+ }
734
+
735
+ return Promise.resolve(result([]));
736
+ });
737
+
738
+ await renderBreakdown();
739
+
740
+ aggregateMock.mockClear();
741
+
742
+ await act(async () => {
743
+ fireEvent.click(screen.getByRole("button", { name: dimensionLabel }));
744
+ });
745
+
746
+ const rows: Array<Array<string>> = renderedRows();
747
+
748
+ expect(rows).toHaveLength(1);
749
+ expect(rows[0]![6]).toBe("$3.0000");
750
+
751
+ const metricCalls: Array<unknown> = aggregateMock.mock.calls.filter(
752
+ (args: Array<unknown>): boolean => {
753
+ return modelNameOf(args[0] as AggregateCall) === "Metric";
754
+ },
755
+ );
756
+
757
+ expect(metricCalls).toHaveLength(0);
758
+ expect(
759
+ screen.queryByTestId("llm-usage-source-hint"),
760
+ ).not.toBeInTheDocument();
761
+ },
762
+ );
763
+ });
764
+
765
+ /*
766
+ * ---------------------------------------------------------------------------
767
+ * The metric employee rollup's grouping keys
768
+ * ---------------------------------------------------------------------------
769
+ *
770
+ * Grouping the metric stream on user.email ALONE is the specific bug these
771
+ * cover: a Cursor-only project's identity is an opaque cursor.user.id and a
772
+ * Claude Code fleet on API-key auth sends user.account_uuid, so a
773
+ * single-key rollup silently collapses an entire company's spend into one
774
+ * Unattributed row — the exact failure the span-side groupBy is written to
775
+ * avoid.
776
+ */
777
+ describe("LlmUsageBreakdown - metric identity grouping", () => {
778
+ type MetricGroupKeysFunction = () => Array<string>;
779
+
780
+ const metricGroupKeys: MetricGroupKeysFunction = (): Array<string> => {
781
+ const call: AggregateCall | undefined = aggregateMock.mock.calls
782
+ .map((args: Array<unknown>): AggregateCall => {
783
+ return args[0] as AggregateCall;
784
+ })
785
+ .find((candidate: AggregateCall): boolean => {
786
+ return modelNameOf(candidate) === "Metric";
787
+ });
788
+
789
+ return (call?.aggregateBy["groupByAttributeKeys"] as Array<string>) || [];
790
+ };
791
+
792
+ test("groups on every recognized identity spelling, not just the email", async () => {
793
+ aggregateMock.mockImplementation((call: unknown) => {
794
+ const aggregateCall: AggregateCall = call as AggregateCall;
795
+
796
+ if (modelNameOf(aggregateCall) === "Span") {
797
+ return Promise.resolve(result([]));
798
+ }
799
+
800
+ return Promise.resolve(result([]));
801
+ });
802
+
803
+ await renderBreakdown();
804
+
805
+ expect(metricGroupKeys()).toEqual([...LlmMetricUserAttributeKeys]);
806
+
807
+ /*
808
+ * MetricService rejects more than MAX_GROUP_BY_ATTRIBUTE_KEYS = 10 keys
809
+ * with a BadDataException, and this list is exactly at the cap. Adding an
810
+ * eleventh spelling to LlmMetricUserAttributeKeys would turn the whole
811
+ * employee fallback into a 400 at runtime, which no other test here would
812
+ * catch — the query simply stops answering.
813
+ */
814
+ expect(LlmMetricUserAttributeKeys.length).toBeLessThanOrEqual(10);
815
+ });
816
+
817
+ test("an id-only Cursor datapoint ranks as a person, not as Unattributed", async () => {
818
+ aggregateMock.mockImplementation((call: unknown) => {
819
+ const aggregateCall: AggregateCall = call as AggregateCall;
820
+
821
+ if (modelNameOf(aggregateCall) === "Span") {
822
+ return Promise.resolve(result([]));
823
+ }
824
+
825
+ if (isMicroUsdCostCall(aggregateCall)) {
826
+ return Promise.resolve(result([]));
827
+ }
828
+
829
+ return Promise.resolve(
830
+ result([
831
+ /*
832
+ * Cursor: an opaque team-scoped integer and NO email anywhere. A
833
+ * user.email-only rollup reads this row's identity as absent.
834
+ */
835
+ row({
836
+ value: 11,
837
+ attributes: { "cursor.user.id": "4471" },
838
+ }),
839
+ // Claude Code on API-key auth: an account uuid, still no email.
840
+ row({
841
+ value: 5,
842
+ attributes: {
843
+ "user.account_uuid": "acct-9f2c",
844
+ },
845
+ }),
846
+ // An operator who stamped identity once via OTEL_RESOURCE_ATTRIBUTES.
847
+ row({
848
+ value: 2,
849
+ attributes: { "resource.user.email": "ops@example.com" },
850
+ }),
851
+ ]),
852
+ );
853
+ });
854
+
855
+ await renderBreakdown();
856
+
857
+ const rows: Array<Array<string>> = renderedRows();
858
+
859
+ expect(rows).toHaveLength(3);
860
+ expect(rows[0]![1]).toBe("4471");
861
+ expect(rows[0]![6]).toBe("$11.0000");
862
+ expect(rows[1]![1]).toBe("acct-9f2c");
863
+ expect(rows[2]![1]).toBe("ops@example.com");
864
+
865
+ // Three people, not one anonymous heap.
866
+ expect(
867
+ screen.queryByTestId("llm-usage-unattributed"),
868
+ ).not.toBeInTheDocument();
869
+ });
870
+
871
+ test("prefers the email when a datapoint carries both spellings", async () => {
872
+ aggregateMock.mockImplementation((call: unknown) => {
873
+ const aggregateCall: AggregateCall = call as AggregateCall;
874
+
875
+ if (modelNameOf(aggregateCall) === "Span") {
876
+ return Promise.resolve(result([]));
877
+ }
878
+
879
+ if (isMicroUsdCostCall(aggregateCall)) {
880
+ return Promise.resolve(result([]));
881
+ }
882
+
883
+ return Promise.resolve(
884
+ result([
885
+ row({
886
+ value: 4,
887
+ attributes: {
888
+ "user.email": "named@example.com",
889
+ "cursor.user.id": "4471",
890
+ // The span tier is more specific than the resource tier.
891
+ "resource.user.email": "fleet@example.com",
892
+ },
893
+ }),
894
+ ]),
895
+ );
896
+ });
897
+
898
+ await renderBreakdown();
899
+
900
+ const rows: Array<Array<string>> = renderedRows();
901
+
902
+ expect(rows).toHaveLength(1);
903
+ expect(rows[0]![1]).toBe("named@example.com");
904
+ });
905
+ });
906
+
907
+ describe("LlmUsageBreakdown - degradation", () => {
908
+ test("renders a message instead of throwing when every aggregate rejects", async () => {
909
+ aggregateMock.mockRejectedValue(new Error("boom") as never);
910
+
911
+ await renderBreakdown();
912
+
913
+ expect(screen.getByText(/Usage could not be loaded/i)).toBeInTheDocument();
914
+ expect(screen.queryAllByTestId("llm-usage-row")).toHaveLength(0);
915
+ });
916
+
917
+ test("keeps the cost ranking when only the token aggregates fail", async () => {
918
+ aggregateMock.mockImplementation((call: unknown) => {
919
+ const aggregateCall: AggregateCall = call as AggregateCall;
920
+
921
+ if (columnOf(aggregateCall) === "llmCost") {
922
+ return Promise.resolve(
923
+ result([
924
+ row({
925
+ value: 6,
926
+ columns: { llmUserEmail: "still@example.com", llmUserId: "" },
927
+ }),
928
+ ]),
929
+ );
930
+ }
931
+
932
+ return Promise.reject(new Error("token aggregate failed"));
933
+ });
934
+
935
+ await renderBreakdown();
936
+
937
+ const rows: Array<Array<string>> = renderedRows();
938
+
939
+ expect(rows).toHaveLength(1);
940
+ expect(rows[0]![1]).toBe("still@example.com");
941
+ expect(rows[0]![6]).toBe("$6.0000");
942
+ // Missing columns read as zero-valued sums, never as a thrown page.
943
+ expect(
944
+ screen.queryByText(/Usage could not be loaded/i),
945
+ ).not.toBeInTheDocument();
946
+ });
947
+
948
+ /*
949
+ * The inverse, and the one that was wrong: the COST aggregate fails while
950
+ * the token and call aggregates succeed. Every group still has a name, a
951
+ * call count and a token count, so a row is built for each — and its cost,
952
+ * never measured, sits at its initialized 0. Ranked by cost, that produced
953
+ * a full leaderboard of "$0.0000" underneath an error banner, on the page
954
+ * whose entire purpose is ranking people by spend. Those zeros were
955
+ * fabricated, and $0.0000 does not read as "unknown" to anyone.
956
+ */
957
+ test("does not present a cost-ranked leaderboard of $0.0000 when the cost aggregate failed", async () => {
958
+ aggregateMock.mockImplementation((call: unknown) => {
959
+ const aggregateCall: AggregateCall = call as AggregateCall;
960
+
961
+ if (columnOf(aggregateCall) === "llmCost") {
962
+ return Promise.reject(new Error("cost aggregate failed"));
963
+ }
964
+
965
+ // Everything else is healthy and has plenty of groups to offer.
966
+ return Promise.resolve(
967
+ result([
968
+ row({
969
+ value: 40,
970
+ columns: { llmUserEmail: "senior@example.com", llmUserId: "" },
971
+ }),
972
+ row({
973
+ value: 4,
974
+ columns: { llmUserEmail: "junior@example.com", llmUserId: "" },
975
+ }),
976
+ ]),
977
+ );
978
+ });
979
+
980
+ await renderBreakdown();
981
+
982
+ expect(screen.getByText(/Usage could not be loaded/i)).toBeInTheDocument();
983
+
984
+ // No rows at all, and in particular no fabricated zero costs.
985
+ expect(renderedRows()).toHaveLength(0);
986
+ expect(screen.queryByText("$0.0000")).not.toBeInTheDocument();
987
+ expect(screen.queryByText("senior@example.com")).not.toBeInTheDocument();
988
+ });
989
+
990
+ test("a cost aggregate that SUCCEEDS with no rows still renders the ordinary empty state", async () => {
991
+ /*
992
+ * The distinction the branch above turns on. "No LLM spend in this
993
+ * window" is a fact about the project; "the query fell over" is a fact
994
+ * about OneUptime. Conflating them either hides a real outage or invents
995
+ * one.
996
+ */
997
+ aggregateMock.mockResolvedValue(result([]) as never);
998
+
999
+ await renderBreakdown();
1000
+
1001
+ expect(screen.getByText(/No LLM usage found/i)).toBeInTheDocument();
1002
+ expect(
1003
+ screen.queryByText(/Usage could not be loaded/i),
1004
+ ).not.toBeInTheDocument();
1005
+ expect(renderedRows()).toHaveLength(0);
1006
+ });
1007
+ });