@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
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
LlmCostMetricNames,
|
|
3
3
|
LlmInputTokenTypeValues,
|
|
4
|
+
LlmMetricTeamAttributeKeys,
|
|
5
|
+
LlmMetricUserAttributeKeys,
|
|
6
|
+
LlmMicroUsdCostMetricNames,
|
|
4
7
|
LlmOutputTokenTypeValues,
|
|
5
8
|
LlmTokenDirection,
|
|
6
9
|
LlmTokenTypeAttributeKeys,
|
|
7
10
|
LlmTokenUsageMetricNames,
|
|
11
|
+
MICRO_USD_TO_USD,
|
|
8
12
|
getLlmTokenDirection,
|
|
9
13
|
getLlmTokenTypeValues,
|
|
10
14
|
} from "../../../Types/Telemetry/LlmMetricConventions";
|
|
@@ -191,3 +195,390 @@ describe("LlmMetricConventions", () => {
|
|
|
191
195
|
});
|
|
192
196
|
});
|
|
193
197
|
});
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* Coding-agent vendor metric names.
|
|
201
|
+
*
|
|
202
|
+
* The bug class: every major AI coding agent now exports OpenTelemetry
|
|
203
|
+
* natively, and every one of them namespaces its metrics under its own vendor
|
|
204
|
+
* prefix rather than gen_ai.*. A name this module does not know is a name the
|
|
205
|
+
* query never selects, so a whole fleet of agents can be exporting into
|
|
206
|
+
* OneUptime and report exactly $0 of spend — a silent zero, which is the
|
|
207
|
+
* worst kind: no error, no gap in a chart, just a number that looks fine and
|
|
208
|
+
* is wrong.
|
|
209
|
+
*/
|
|
210
|
+
describe("LlmMetricConventions — coding-agent vendor metric names", () => {
|
|
211
|
+
test.each([
|
|
212
|
+
["claude_code.token.usage", "Anthropic Claude Code CLI"],
|
|
213
|
+
["cursor.token.usage", "Cursor Enterprise OTel export"],
|
|
214
|
+
["codex.turn.token_usage", "OpenAI Codex CLI"],
|
|
215
|
+
])("token metric %s (%s) is recognized", (name: string) => {
|
|
216
|
+
expect(LlmTokenUsageMetricNames).toContain(name);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* ---------------------------------------------------------------------
|
|
221
|
+
* The DOUBLE-COUNT hazard, pinned.
|
|
222
|
+
* ---------------------------------------------------------------------
|
|
223
|
+
*
|
|
224
|
+
* LlmMetricQuery.buildTokenQuery issues ONE query with
|
|
225
|
+
* `name: new Includes(LlmTokenUsageMetricNames)` and groups only by the
|
|
226
|
+
* token-TYPE attribute keys — never by metric name. So if a single emitter
|
|
227
|
+
* contributes TWO names to this list, both of its emissions come back as
|
|
228
|
+
* separate rows and reduceTokenRows adds them together. The result is a
|
|
229
|
+
* project reporting exactly 2x its real tokens: no error, no gap in the
|
|
230
|
+
* chart, just a number that looks plausible and is wrong.
|
|
231
|
+
*
|
|
232
|
+
* Google Gemini CLI is the concrete case. It emits `gemini_cli.token.usage`
|
|
233
|
+
* AND the semantic-convention `gen_ai.client.token.usage` for the same
|
|
234
|
+
* tokens (both are listed in its metric table in
|
|
235
|
+
* Docs/Content/en/telemetry/gemini-cli-and-copilot.md), so the vendor name
|
|
236
|
+
* must stay OUT. Nothing is lost — the semconv name it also emits is in the
|
|
237
|
+
* list, so its tokens are counted, once.
|
|
238
|
+
*
|
|
239
|
+
* The other three vendor names were audited against the same docs and do
|
|
240
|
+
* NOT overlap a semconv metric: Claude Code and Cursor publish only their
|
|
241
|
+
* own vendor-namespaced metrics, and Codex's gen_ai.* usage attributes live
|
|
242
|
+
* on SPANS rather than metrics.
|
|
243
|
+
*
|
|
244
|
+
* The list is pinned exactly rather than by `toContain` so that ADDING a
|
|
245
|
+
* name — the way this bug arrives — fails here and makes whoever adds it
|
|
246
|
+
* check for an overlapping semconv emission first.
|
|
247
|
+
*/
|
|
248
|
+
test("Gemini CLI's vendor token metric is deliberately NOT recognized", () => {
|
|
249
|
+
expect(LlmTokenUsageMetricNames).not.toContain("gemini_cli.token.usage");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the token metric list is exactly this set, in this order", () => {
|
|
253
|
+
expect(LlmTokenUsageMetricNames).toEqual([
|
|
254
|
+
"gen_ai.client.token.usage",
|
|
255
|
+
"gen_ai.client.token.count",
|
|
256
|
+
"llm.token.usage",
|
|
257
|
+
"llm.usage.tokens",
|
|
258
|
+
"claude_code.token.usage",
|
|
259
|
+
"cursor.token.usage",
|
|
260
|
+
"codex.turn.token_usage",
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("no emitter contributes two names to the token list", () => {
|
|
265
|
+
/*
|
|
266
|
+
* The property the pinned list above protects, stated directly. Each
|
|
267
|
+
* entry names one emitter; two names mapping to the same emitter is the
|
|
268
|
+
* double count. Keep this map in step with the list when a genuinely
|
|
269
|
+
* non-overlapping vendor name is added.
|
|
270
|
+
*/
|
|
271
|
+
const emitterByMetricName: Record<string, string> = {
|
|
272
|
+
"gen_ai.client.token.usage": "otel-semconv",
|
|
273
|
+
"gen_ai.client.token.count": "otel-semconv-pre-1.27",
|
|
274
|
+
"llm.token.usage": "openinference",
|
|
275
|
+
"llm.usage.tokens": "openllmetry",
|
|
276
|
+
"claude_code.token.usage": "claude-code",
|
|
277
|
+
"cursor.token.usage": "cursor",
|
|
278
|
+
"codex.turn.token_usage": "codex",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Every recognized name is accounted for — no unattributed additions.
|
|
282
|
+
expect(Object.keys(emitterByMetricName).sort()).toEqual(
|
|
283
|
+
[...LlmTokenUsageMetricNames].sort(),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const emitters: Array<string> = LlmTokenUsageMetricNames.map(
|
|
287
|
+
(name: string) => {
|
|
288
|
+
return emitterByMetricName[name]!;
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
expect(new Set(emitters).size).toBe(emitters.length);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test.each([
|
|
296
|
+
["claude_code.cost.usage", "Anthropic Claude Code CLI"],
|
|
297
|
+
["cursor.cost.usage", "Cursor Enterprise OTel export"],
|
|
298
|
+
])("USD cost metric %s (%s) is recognized", (name: string) => {
|
|
299
|
+
expect(LlmCostMetricNames).toContain(name);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("the semantic convention still leads the token list", () => {
|
|
303
|
+
// Vendor names are appended, never prepended — semconv stays preferred.
|
|
304
|
+
expect(LlmTokenUsageMetricNames[0]).toBe("gen_ai.client.token.usage");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("Codex cost is NOT in the USD list", () => {
|
|
308
|
+
/*
|
|
309
|
+
* The million-fold error. codex.turn.cost_microusd reports MILLIONTHS of
|
|
310
|
+
* a dollar; summed alongside genuinely-USD metrics a $3 turn becomes
|
|
311
|
+
* $3,000,000 and trips every configured cost budget at once.
|
|
312
|
+
*/
|
|
313
|
+
expect(LlmCostMetricNames).not.toContain("codex.turn.cost_microusd");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("Codex cost is in the micro-USD list", () => {
|
|
317
|
+
expect(LlmMicroUsdCostMetricNames).toContain("codex.turn.cost_microusd");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the USD and micro-USD cost lists are disjoint", () => {
|
|
321
|
+
const overlap: Array<string> = LlmCostMetricNames.filter((name: string) => {
|
|
322
|
+
return LlmMicroUsdCostMetricNames.includes(name);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(overlap).toEqual([]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("the micro-USD list never overlaps the token list either", () => {
|
|
329
|
+
const overlap: Array<string> = LlmMicroUsdCostMetricNames.filter(
|
|
330
|
+
(name: string) => {
|
|
331
|
+
return LlmTokenUsageMetricNames.includes(name);
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
expect(overlap).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("no cost metric name is duplicated across the three lists", () => {
|
|
339
|
+
const all: Array<string> = [
|
|
340
|
+
...LlmTokenUsageMetricNames,
|
|
341
|
+
...LlmCostMetricNames,
|
|
342
|
+
...LlmMicroUsdCostMetricNames,
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
expect(new Set(all).size).toBe(all.length);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("every micro-USD name is a non-empty trimmed string", () => {
|
|
349
|
+
for (const name of LlmMicroUsdCostMetricNames) {
|
|
350
|
+
expect(typeof name).toBe("string");
|
|
351
|
+
expect(name.length).toBeGreaterThan(0);
|
|
352
|
+
expect(name).toBe(name.trim());
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("MICRO_USD_TO_USD is exactly one millionth", () => {
|
|
357
|
+
/*
|
|
358
|
+
* Pinned as a literal rather than as 1/1_000_000 so a dropped or added
|
|
359
|
+
* zero fails here loudly. Everything downstream multiplies by this.
|
|
360
|
+
*/
|
|
361
|
+
expect(MICRO_USD_TO_USD).toBe(0.000001);
|
|
362
|
+
expect(MICRO_USD_TO_USD).toBe(1e-6);
|
|
363
|
+
expect(1_000_000 * MICRO_USD_TO_USD).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/*
|
|
368
|
+
* Vendor token-type spellings.
|
|
369
|
+
*
|
|
370
|
+
* Two failure modes are pinned here. First, a token-type KEY the fold does
|
|
371
|
+
* not recognize means every datapoint from that emitter is dropped as
|
|
372
|
+
* "direction unknown" — tokens silently vanish. Second, a token-type VALUE
|
|
373
|
+
* that is wrongly recognized double-counts: Codex's `total` is a superset of
|
|
374
|
+
* its siblings and `reasoning_output` is a subset of `output`, and both
|
|
375
|
+
* arrive ALONGSIDE the datapoints they overlap.
|
|
376
|
+
*/
|
|
377
|
+
describe("LlmMetricConventions — coding-agent token type spellings", () => {
|
|
378
|
+
test.each([
|
|
379
|
+
["type", "Claude Code (bare)"],
|
|
380
|
+
["cursor.token.type", "Cursor"],
|
|
381
|
+
["token_type", "Codex"],
|
|
382
|
+
])("token type key %s (%s) is recognized", (key: string) => {
|
|
383
|
+
expect(LlmTokenTypeAttributeKeys).toContain(key);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("the bare 'type' key never outranks a namespaced one", () => {
|
|
387
|
+
/*
|
|
388
|
+
* The bounded-risk argument for accepting a key as generic as `type`:
|
|
389
|
+
* it is only ever consulted for rows that already matched an LLM metric
|
|
390
|
+
* NAME, and it sorts after every namespaced spelling, so an emitter that
|
|
391
|
+
* carries both is read from the namespaced one.
|
|
392
|
+
*/
|
|
393
|
+
const bare: number = LlmTokenTypeAttributeKeys.indexOf("type");
|
|
394
|
+
const semconv: number =
|
|
395
|
+
LlmTokenTypeAttributeKeys.indexOf("gen_ai.token.type");
|
|
396
|
+
|
|
397
|
+
expect(bare).toBeGreaterThan(semconv);
|
|
398
|
+
expect(bare).toBeGreaterThan(
|
|
399
|
+
LlmTokenTypeAttributeKeys.indexOf("llm.token.type"),
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("the token type key list still contains no duplicates", () => {
|
|
404
|
+
expect(new Set(LlmTokenTypeAttributeKeys).size).toBe(
|
|
405
|
+
LlmTokenTypeAttributeKeys.length,
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test.each(["input", "output"])(
|
|
410
|
+
"the plain %s value is already covered — no vendor value needed",
|
|
411
|
+
(value: string) => {
|
|
412
|
+
expect(getLlmTokenDirection(value)).not.toBeNull();
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
/*
|
|
417
|
+
* Every excluded literal, spelled exactly as its vendor emits it. These
|
|
418
|
+
* MUST map to null; a change that starts counting any of them inflates
|
|
419
|
+
* metric-sourced totals against the span-sourced ones they stand in for,
|
|
420
|
+
* or double-counts outright.
|
|
421
|
+
*/
|
|
422
|
+
test.each([
|
|
423
|
+
// Claude Code — camelCase, which is easy to miss when eyeballing a list.
|
|
424
|
+
"cacheRead",
|
|
425
|
+
"cacheCreation",
|
|
426
|
+
// Cursor.
|
|
427
|
+
"cache_read",
|
|
428
|
+
"cache_creation",
|
|
429
|
+
// Codex.
|
|
430
|
+
"cached_input",
|
|
431
|
+
"cache_write_input",
|
|
432
|
+
// Codex: a SUPERSET of its siblings, emitted alongside them.
|
|
433
|
+
"total",
|
|
434
|
+
// Codex: a SUBSET of `output`, emitted alongside it.
|
|
435
|
+
"reasoning_output",
|
|
436
|
+
])("the excluded token kind %s maps to null", (value: string) => {
|
|
437
|
+
expect(getLlmTokenDirection(value)).toBeNull();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test.each(["CacheRead", " cacheCreation ", "TOTAL", "Reasoning_Output"])(
|
|
441
|
+
"the excluded kind %s stays excluded after case/whitespace normalization",
|
|
442
|
+
(value: string) => {
|
|
443
|
+
expect(getLlmTokenDirection(value)).toBeNull();
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
test("no excluded kind leaked into either value list", () => {
|
|
448
|
+
for (const excluded of [
|
|
449
|
+
"cacheread",
|
|
450
|
+
"cachecreation",
|
|
451
|
+
"cache_read",
|
|
452
|
+
"cache_creation",
|
|
453
|
+
"cached_input",
|
|
454
|
+
"cache_write_input",
|
|
455
|
+
"total",
|
|
456
|
+
"reasoning_output",
|
|
457
|
+
]) {
|
|
458
|
+
expect(LlmInputTokenTypeValues).not.toContain(excluded);
|
|
459
|
+
expect(LlmOutputTokenTypeValues).not.toContain(excluded);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/*
|
|
465
|
+
* Metric-side identity keys. These exist so metric-sourced spend can be
|
|
466
|
+
* grouped by employee at all; the risk they carry is the same one the span
|
|
467
|
+
* side documents — grouping the wrong human's spend under an engineer's name.
|
|
468
|
+
*/
|
|
469
|
+
describe("LlmMetricConventions — metric identity attribute keys", () => {
|
|
470
|
+
test("the user list leads with the key the coding agents emit natively", () => {
|
|
471
|
+
expect(LlmMetricUserAttributeKeys[0]).toBe("user.email");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("the user list covers the coding-agent and Cursor spellings", () => {
|
|
475
|
+
/*
|
|
476
|
+
* Two tiers, bare first then resource-prefixed, written out as literals.
|
|
477
|
+
*
|
|
478
|
+
* The resource tier is not decoration. OtelMetricsIngestService flattens
|
|
479
|
+
* resource attributes through
|
|
480
|
+
* `TelemetryUtil.getAttributes({ items, prefixKeysWithString: "resource" })`
|
|
481
|
+
* exactly as the traces service does, so a fleet that stamps identity on
|
|
482
|
+
* the resource — the only thing OTEL_RESOURCE_ATTRIBUTES can do, and what
|
|
483
|
+
* Cursor documents for cursor.user.id — arrives here as
|
|
484
|
+
* `resource.user.email`. A bare-key-only list matches none of it and
|
|
485
|
+
* every per-employee metric rollup is silently empty.
|
|
486
|
+
*/
|
|
487
|
+
expect(LlmMetricUserAttributeKeys).toEqual([
|
|
488
|
+
"user.email",
|
|
489
|
+
"user.id",
|
|
490
|
+
"user.account_uuid",
|
|
491
|
+
"user.account_id",
|
|
492
|
+
"cursor.user.id",
|
|
493
|
+
"resource.user.email",
|
|
494
|
+
"resource.user.id",
|
|
495
|
+
"resource.user.account_uuid",
|
|
496
|
+
"resource.user.account_id",
|
|
497
|
+
"resource.cursor.user.id",
|
|
498
|
+
]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("the team list leads with team.id", () => {
|
|
502
|
+
expect(LlmMetricTeamAttributeKeys[0]).toBe("team.id");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("the team list covers the OTEL_RESOURCE_ATTRIBUTES spellings", () => {
|
|
506
|
+
/*
|
|
507
|
+
* Both spellings of each, and the resource one is the spelling that
|
|
508
|
+
* actually arrives: OTEL_RESOURCE_ATTRIBUTES=team.id=platform reaches the
|
|
509
|
+
* query layer as `resource.team.id`, never `team.id`.
|
|
510
|
+
*/
|
|
511
|
+
expect(LlmMetricTeamAttributeKeys).toEqual([
|
|
512
|
+
"team.id",
|
|
513
|
+
"team",
|
|
514
|
+
"cost_center",
|
|
515
|
+
"department",
|
|
516
|
+
"cursor.team.id",
|
|
517
|
+
"resource.team.id",
|
|
518
|
+
"resource.team",
|
|
519
|
+
"resource.cost_center",
|
|
520
|
+
"resource.department",
|
|
521
|
+
"resource.cursor.team.id",
|
|
522
|
+
]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("the bare tier still leads, so the scoped filter keys are unchanged", () => {
|
|
526
|
+
/*
|
|
527
|
+
* LlmMetricQuery exports METRIC_USER_ATTRIBUTE_KEY /
|
|
528
|
+
* METRIC_TEAM_ATTRIBUTE_KEY as element zero of these lists and uses them
|
|
529
|
+
* as the single map-filter key for scoped queries. If the resource tier
|
|
530
|
+
* ever led, every scoped query would silently filter on the wrong key.
|
|
531
|
+
*/
|
|
532
|
+
expect(LlmMetricUserAttributeKeys[0]).toBe("user.email");
|
|
533
|
+
expect(LlmMetricTeamAttributeKeys[0]).toBe("team.id");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("neither identity list carries a downstream-customer key", () => {
|
|
537
|
+
/*
|
|
538
|
+
* The same exclusion the span side enforces: gen_ai.user and llm.user
|
|
539
|
+
* carry the CALLER'S customer, not the employee. Grouping metric spend by
|
|
540
|
+
* one of those would manufacture a phantom employee per customer.
|
|
541
|
+
*/
|
|
542
|
+
for (const customerKey of [
|
|
543
|
+
"gen_ai.user",
|
|
544
|
+
"llm.user",
|
|
545
|
+
"litellm.metadata.user_api_key_end_user_id",
|
|
546
|
+
]) {
|
|
547
|
+
expect(LlmMetricUserAttributeKeys).not.toContain(customerKey);
|
|
548
|
+
expect(LlmMetricTeamAttributeKeys).not.toContain(customerKey);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("the two identity lists are disjoint", () => {
|
|
553
|
+
const overlap: Array<string> = LlmMetricUserAttributeKeys.filter(
|
|
554
|
+
(key: string) => {
|
|
555
|
+
return LlmMetricTeamAttributeKeys.includes(key);
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
expect(overlap).toEqual([]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("neither identity list collides with a token type key", () => {
|
|
563
|
+
/*
|
|
564
|
+
* They are both used as groupBy attribute keys against the same rows; a
|
|
565
|
+
* key that means "which employee" in one list and "which side of the
|
|
566
|
+
* exchange" in the other would classify tokens by person.
|
|
567
|
+
*/
|
|
568
|
+
for (const key of [
|
|
569
|
+
...LlmMetricUserAttributeKeys,
|
|
570
|
+
...LlmMetricTeamAttributeKeys,
|
|
571
|
+
]) {
|
|
572
|
+
expect(LlmTokenTypeAttributeKeys).not.toContain(key);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("neither identity list contains duplicates", () => {
|
|
577
|
+
expect(new Set(LlmMetricUserAttributeKeys).size).toBe(
|
|
578
|
+
LlmMetricUserAttributeKeys.length,
|
|
579
|
+
);
|
|
580
|
+
expect(new Set(LlmMetricTeamAttributeKeys).size).toBe(
|
|
581
|
+
LlmMetricTeamAttributeKeys.length,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
});
|