@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.
- package/Models/AnalyticsModels/Span.ts +101 -0
- package/Server/API/BaseAPI.ts +0 -24
- package/Server/API/SlackAPI.ts +0 -2
- package/Server/Middleware/SlackAuthorization.ts +96 -18
- package/Server/Utils/Telemetry/LlmMetricSpend.ts +56 -5
- package/Server/Utils/Telemetry/LlmSpan.ts +46 -0
- package/Server/Utils/Workspace/Slack/Actions/Auth.ts +0 -12
- package/Tests/App/Dashboard/LlmCallsTableIdentity.test.tsx +322 -0
- package/Tests/App/Dashboard/LlmOverview.test.tsx +335 -0
- package/Tests/App/Dashboard/LlmSpanDisplay.test.ts +391 -0
- package/Tests/App/Dashboard/LlmUsageBreakdown.test.tsx +1007 -0
- package/Tests/Server/API/BaseAPI.test.ts +41 -0
- package/Tests/Server/API/BaseAPIUpdatePayloadValidation.test.ts +9 -16
- package/Tests/Server/Middleware/SlackAuthorization.test.ts +262 -5
- package/Tests/Server/Utils/Telemetry/LlmCostBudgetEvaluator.test.ts +37 -18
- package/Tests/Server/Utils/Telemetry/LlmMetricSpend.test.ts +143 -2
- package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +804 -0
- package/Tests/Types/Telemetry/LlmMetricConventions.test.ts +391 -0
- package/Tests/Utils/Telemetry/LlmMetricQuery.test.ts +298 -0
- package/Types/Telemetry/LlmConventions.ts +255 -0
- package/Types/Telemetry/LlmMetricConventions.ts +212 -7
- package/Utils/Telemetry/LlmMetricQuery.ts +83 -0
- package/build/dist/Models/AnalyticsModels/Span.js +89 -0
- package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +3 -19
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +0 -2
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/Middleware/SlackAuthorization.js +58 -7
- package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js +52 -3
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js +24 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js +0 -10
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmConventions.js +236 -0
- package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmMetricConventions.js +197 -5
- package/build/dist/Types/Telemetry/LlmMetricConventions.js.map +1 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js +57 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js.map +1 -1
- 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
|
+
});
|