@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
@@ -6,6 +6,7 @@ import Query from "../../../Types/BaseDatabase/Query";
6
6
  import ObjectID from "../../../Types/ObjectID";
7
7
  import {
8
8
  LlmCostMetricNames,
9
+ LlmMicroUsdCostMetricNames,
9
10
  LlmTokenUsageMetricNames,
10
11
  } from "../../../Types/Telemetry/LlmMetricConventions";
11
12
  import LlmMetricQuery, {
@@ -13,6 +14,8 @@ import LlmMetricQuery, {
13
14
  LlmMetricTokenTotals,
14
15
  METRIC_MODEL_ATTRIBUTE_KEY,
15
16
  METRIC_SYSTEM_ATTRIBUTE_KEY,
17
+ METRIC_TEAM_ATTRIBUTE_KEY,
18
+ METRIC_USER_ATTRIBUTE_KEY,
16
19
  } from "../../../Utils/Telemetry/LlmMetricQuery";
17
20
  import { describe, expect, test } from "@jest/globals";
18
21
 
@@ -470,3 +473,298 @@ describe("LlmMetricQuery", () => {
470
473
  });
471
474
  });
472
475
  });
476
+
477
+ /*
478
+ * Employee / team scoping of metric-sourced spend.
479
+ *
480
+ * The bug class: a scoping dimension that silently does nothing. If a filter
481
+ * is dropped, mis-keyed, or written to the wrong attribute name, the query
482
+ * quietly returns the WHOLE project's spend under one person's name — a
483
+ * number that renders perfectly and attributes an org-wide bill to one
484
+ * engineer. Nothing about that failure looks like a failure.
485
+ */
486
+ describe("LlmMetricQuery — employee and team scoping", () => {
487
+ test("narrows by employee email using the primary identity key", () => {
488
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
489
+ scope: scope({ llmUserEmail: "ada@example.com" }),
490
+ metricNames: ["a"],
491
+ }) as unknown as Record<string, unknown>;
492
+
493
+ expect(query["attributes"]).toEqual({
494
+ [METRIC_USER_ATTRIBUTE_KEY]: "ada@example.com",
495
+ });
496
+ expect(METRIC_USER_ATTRIBUTE_KEY).toBe("user.email");
497
+ });
498
+
499
+ test("narrows by employee id on the same primary identity key", () => {
500
+ /*
501
+ * The metric stream has ONE identity attribute, unlike the span side
502
+ * where id and email are separate columns — so an id-scoped query filters
503
+ * the same key.
504
+ */
505
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
506
+ scope: scope({ llmUserId: "acct-9f2" }),
507
+ metricNames: ["a"],
508
+ }) as unknown as Record<string, unknown>;
509
+
510
+ expect(query["attributes"]).toEqual({
511
+ [METRIC_USER_ATTRIBUTE_KEY]: "acct-9f2",
512
+ });
513
+ });
514
+
515
+ test("email wins over id when a caller supplies both", () => {
516
+ /*
517
+ * Both map to one key, so one has to win deterministically rather than by
518
+ * object-literal order. Email is the preferred spelling of the list.
519
+ */
520
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
521
+ scope: scope({ llmUserEmail: "ada@example.com", llmUserId: "acct-9f2" }),
522
+ metricNames: ["a"],
523
+ }) as unknown as Record<string, unknown>;
524
+
525
+ expect(query["attributes"]).toEqual({
526
+ [METRIC_USER_ATTRIBUTE_KEY]: "ada@example.com",
527
+ });
528
+ });
529
+
530
+ test("narrows by team using the primary team key", () => {
531
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
532
+ scope: scope({ llmTeam: "platform" }),
533
+ metricNames: ["a"],
534
+ }) as unknown as Record<string, unknown>;
535
+
536
+ expect(query["attributes"]).toEqual({
537
+ [METRIC_TEAM_ATTRIBUTE_KEY]: "platform",
538
+ });
539
+ expect(METRIC_TEAM_ATTRIBUTE_KEY).toBe("team.id");
540
+ });
541
+
542
+ test("identity filters compose with provider, model and service", () => {
543
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
544
+ scope: scope({
545
+ serviceId: SERVICE_ID,
546
+ llmSystem: "anthropic",
547
+ llmModel: "claude-opus-4-8",
548
+ llmUserEmail: "ada@example.com",
549
+ llmTeam: "platform",
550
+ }),
551
+ metricNames: ["a"],
552
+ }) as unknown as Record<string, unknown>;
553
+
554
+ expect(query["primaryEntityId"]).toBe(SERVICE_ID);
555
+ expect(query["attributes"]).toEqual({
556
+ [METRIC_SYSTEM_ATTRIBUTE_KEY]: "anthropic",
557
+ [METRIC_MODEL_ATTRIBUTE_KEY]: "claude-opus-4-8",
558
+ [METRIC_USER_ATTRIBUTE_KEY]: "ada@example.com",
559
+ [METRIC_TEAM_ATTRIBUTE_KEY]: "platform",
560
+ });
561
+ });
562
+
563
+ test("the user and team filters never collide on one key", () => {
564
+ // A shared key would make a team filter silently overwrite the person.
565
+ expect(METRIC_USER_ATTRIBUTE_KEY).not.toBe(METRIC_TEAM_ATTRIBUTE_KEY);
566
+ });
567
+
568
+ test("empty-string identity values leave the query unscoped", () => {
569
+ /*
570
+ * The dangerous direction is the opposite of under-counting: an empty
571
+ * filter that still emits an attribute predicate would match NOTHING and
572
+ * report $0 for a person who spent thousands.
573
+ */
574
+ const query: Record<string, unknown> = LlmMetricQuery.buildBaseQuery({
575
+ scope: scope({ llmUserEmail: "", llmUserId: "", llmTeam: "" }),
576
+ metricNames: ["a"],
577
+ }) as unknown as Record<string, unknown>;
578
+
579
+ expect(query).not.toHaveProperty("attributes");
580
+ });
581
+
582
+ test("every cost and token query carries the identity scoping", () => {
583
+ const scoped: LlmMetricScope = scope({
584
+ llmUserEmail: "ada@example.com",
585
+ llmTeam: "platform",
586
+ });
587
+
588
+ for (const query of [
589
+ LlmMetricQuery.buildCostQuery(scoped),
590
+ LlmMetricQuery.buildMicroUsdCostQuery(scoped),
591
+ LlmMetricQuery.buildTokenQuery(scoped),
592
+ ] as Array<Query<Metric>>) {
593
+ const record: Record<string, unknown> = query as unknown as Record<
594
+ string,
595
+ unknown
596
+ >;
597
+
598
+ expect(record["attributes"]).toEqual({
599
+ [METRIC_USER_ATTRIBUTE_KEY]: "ada@example.com",
600
+ [METRIC_TEAM_ATTRIBUTE_KEY]: "platform",
601
+ });
602
+ }
603
+ });
604
+ });
605
+
606
+ /*
607
+ * Micro-USD cost, and the single place its scale factor is applied.
608
+ *
609
+ * The bug class this block exists for is a MILLION-FOLD unit error. The
610
+ * OpenAI Codex CLI reports spend in millionths of a dollar. If its metric
611
+ * name reaches the USD query, or the 1e-6 factor is dropped or typo'd, a $3
612
+ * turn lands in a cost budget as $3,000,000 and instantly breaches every
613
+ * threshold a customer has configured. Nothing throws; the alerts just fire.
614
+ */
615
+ describe("LlmMetricQuery — micro-USD cost", () => {
616
+ test("the micro-USD query selects exactly the micro-USD metric names", () => {
617
+ const query: Record<string, unknown> =
618
+ LlmMetricQuery.buildMicroUsdCostQuery(scope()) as unknown as Record<
619
+ string,
620
+ unknown
621
+ >;
622
+
623
+ expect((query["name"] as Includes).values).toEqual(
624
+ LlmMicroUsdCostMetricNames,
625
+ );
626
+ });
627
+
628
+ test("the USD and micro-USD queries never select the same metric name", () => {
629
+ const usd: Includes = (
630
+ LlmMetricQuery.buildCostQuery(scope()) as unknown as Record<
631
+ string,
632
+ unknown
633
+ >
634
+ )["name"] as Includes;
635
+
636
+ const micro: Includes = (
637
+ LlmMetricQuery.buildMicroUsdCostQuery(scope()) as unknown as Record<
638
+ string,
639
+ unknown
640
+ >
641
+ )["name"] as Includes;
642
+
643
+ const overlap: Array<string> = (usd.values as Array<string>).filter(
644
+ (name: string) => {
645
+ return (micro.values as Array<string>).includes(name);
646
+ },
647
+ );
648
+
649
+ expect(overlap).toEqual([]);
650
+ });
651
+
652
+ test("the micro-USD query bounds rows with the same time predicate", () => {
653
+ const query: Record<string, unknown> =
654
+ LlmMetricQuery.buildMicroUsdCostQuery(scope()) as unknown as Record<
655
+ string,
656
+ unknown
657
+ >;
658
+
659
+ const time: InBetween<Date> = query["time"] as InBetween<Date>;
660
+
661
+ expect(time).toBeInstanceOf(InBetween);
662
+ expect(time.startValue).toBe(START);
663
+ expect(time.endValue).toBe(END);
664
+ });
665
+
666
+ describe("combineCostTotals", () => {
667
+ test("a 1,500,000 micro-USD datapoint is $1.50", () => {
668
+ // The canonical Codex figure. This is the whole point of the function.
669
+ expect(
670
+ LlmMetricQuery.combineCostTotals({ usd: 0, microUsd: 1_500_000 }),
671
+ ).toBeCloseTo(1.5, 10);
672
+ });
673
+
674
+ test("one million micro-USD is exactly one dollar", () => {
675
+ expect(
676
+ LlmMetricQuery.combineCostTotals({ usd: 0, microUsd: 1_000_000 }),
677
+ ).toBe(1);
678
+ });
679
+
680
+ test("adds a USD total and a micro-USD total in the same unit", () => {
681
+ expect(
682
+ LlmMetricQuery.combineCostTotals({ usd: 4.25, microUsd: 1_500_000 }),
683
+ ).toBeCloseTo(5.75, 10);
684
+ });
685
+
686
+ test("a micro-USD total is NOT added raw", () => {
687
+ /*
688
+ * The regression guard, stated as the failure it prevents: without the
689
+ * scale factor this returns 3,000,000 instead of 3.
690
+ */
691
+ const combined: number = LlmMetricQuery.combineCostTotals({
692
+ usd: 0,
693
+ microUsd: 3_000_000,
694
+ });
695
+
696
+ expect(combined).toBeCloseTo(3, 10);
697
+ expect(combined).toBeLessThan(10);
698
+ });
699
+
700
+ test("sub-cent micro-USD figures survive as fractions", () => {
701
+ // A single cheap turn: 1250 micro-USD = $0.00125.
702
+ expect(
703
+ LlmMetricQuery.combineCostTotals({ usd: 0, microUsd: 1250 }),
704
+ ).toBeCloseTo(0.00125, 10);
705
+ });
706
+
707
+ test("returns 0 when both totals are 0", () => {
708
+ expect(LlmMetricQuery.combineCostTotals({ usd: 0, microUsd: 0 })).toBe(0);
709
+ });
710
+
711
+ test("preserves a negative micro-USD total, which a delta counter can emit", () => {
712
+ expect(
713
+ LlmMetricQuery.combineCostTotals({ usd: 2, microUsd: -1_000_000 }),
714
+ ).toBeCloseTo(1, 10);
715
+ });
716
+
717
+ /*
718
+ * Non-finite inputs contribute 0 rather than poisoning the result, exactly
719
+ * as sumAggregatedRows does. A NaN spend compares false against every
720
+ * threshold, which silently DISABLES a budget monitor instead of tripping
721
+ * it — the failure mode a cost alert must never have.
722
+ */
723
+ test.each([
724
+ ["NaN usd", NaN, 1_000_000, 1],
725
+ ["NaN microUsd", 2, NaN, 2],
726
+ ["Infinity usd", Infinity, 1_000_000, 1],
727
+ ["-Infinity usd", -Infinity, 2_000_000, 2],
728
+ ["Infinity microUsd", 3, Infinity, 3],
729
+ ["-Infinity microUsd", 3, -Infinity, 3],
730
+ ["both non-finite", NaN, Infinity, 0],
731
+ ])(
732
+ "%s contributes 0 instead of poisoning the total",
733
+ (
734
+ _label: string,
735
+ usd: number,
736
+ microUsd: number,
737
+ expected: number,
738
+ ): void => {
739
+ const combined: number = LlmMetricQuery.combineCostTotals({
740
+ usd: usd,
741
+ microUsd: microUsd,
742
+ });
743
+
744
+ expect(isFinite(combined)).toBe(true);
745
+ expect(combined).toBeCloseTo(expected, 10);
746
+ },
747
+ );
748
+
749
+ test("composes with sumAggregatedRows end to end", () => {
750
+ /*
751
+ * The realistic shape: a Claude Code cost metric in dollars and three
752
+ * Codex turns in micro-USD, folded the way getCostInUSD folds them.
753
+ */
754
+ const usd: number = LlmMetricQuery.sumAggregatedRows([
755
+ row(0.5),
756
+ row(0.25),
757
+ ] as Array<AggregatedModel>);
758
+
759
+ const microUsd: number = LlmMetricQuery.sumAggregatedRows([
760
+ row(1_500_000),
761
+ row(500_000),
762
+ row("not-a-number"),
763
+ ] as Array<AggregatedModel>);
764
+
765
+ expect(
766
+ LlmMetricQuery.combineCostTotals({ usd: usd, microUsd: microUsd }),
767
+ ).toBeCloseTo(2.75, 10);
768
+ });
769
+ });
770
+ });
@@ -16,6 +16,65 @@
16
16
  * lookup helpers return the first key that is present.
17
17
  */
18
18
 
19
+ /**
20
+ * The prefix OTLP ingest stamps onto every RESOURCE attribute before the
21
+ * attributes map reaches any extractor.
22
+ *
23
+ * This is not cosmetic and it is the difference between a feature that works
24
+ * and one that silently reports nothing. All three ingest services flatten
25
+ * resource attributes through
26
+ * `TelemetryUtil.getAttributes({ items, prefixKeysWithString: "resource" })`
27
+ * — OtelTracesIngestService, OtelMetricsIngestService and
28
+ * OtelLogsIngestService alike. So an operator who sets
29
+ * `OTEL_RESOURCE_ATTRIBUTES=team.id=platform` does NOT produce an attribute
30
+ * called `team.id`; they produce one called `resource.team.id`.
31
+ *
32
+ * OtelTracesIngestService already learned this the hard way for session
33
+ * replay (see its `sessionIdAttributeKeys`, which lists both spellings and
34
+ * whose comment notes that reading only the bare key "would look correct in a
35
+ * unit test and fail in production"). Identity is the same trap, one tier
36
+ * worse: identity is precisely the kind of value a fleet stamps ONCE on the
37
+ * resource rather than on every span, and Cursor documents `cursor.user.id` /
38
+ * `cursor.team.id` as resource attributes outright.
39
+ */
40
+ export const RESOURCE_ATTRIBUTE_KEY_PREFIX: string = "resource.";
41
+
42
+ /**
43
+ * Widen a key list so it matches both the span-attribute spelling and the
44
+ * resource-attribute spelling of every key in it.
45
+ *
46
+ * The bare block comes FIRST in its entirety, then the resource-prefixed
47
+ * block, because a span attribute is strictly more specific than a resource
48
+ * attribute: the resource describes the whole process, the span describes one
49
+ * call. Preserving each block's internal order keeps the documented
50
+ * preference ordering intact within each tier.
51
+ *
52
+ * Pure and exported so the widening is one tested function rather than a
53
+ * hand-maintained second copy of every list that drifts the first time
54
+ * somebody adds a key.
55
+ */
56
+ export function withResourcePrefixedKeys(keys: Array<string>): Array<string> {
57
+ return [
58
+ ...keys,
59
+ ...keys.map((key: string) => {
60
+ return `${RESOURCE_ATTRIBUTE_KEY_PREFIX}${key}`;
61
+ }),
62
+ ];
63
+ }
64
+
65
+ /*
66
+ * Note what is deliberately NOT widened: every list below that carries a
67
+ * per-CALL value — system, operation, models, tokens, cost, conversation id,
68
+ * tool name, request parameters, prompts and completions. Those describe one
69
+ * LLM request and have no business being set on a resource, which describes
70
+ * the whole process; a resource-scoped `gen_ai.request.model` would be a
71
+ * misconfiguration, not a convention to support. Widening them would double
72
+ * the number of map lookups on the hottest ingest path to chase a spelling
73
+ * nobody emits. Only the IDENTITY lists — user id, user email, team, and the
74
+ * end-user EXCLUSION list — are widened, because identity is exactly the
75
+ * value an operator sets once via OTEL_RESOURCE_ATTRIBUTES.
76
+ */
77
+
19
78
  // Provider / system, e.g. "openai", "anthropic", "aws.bedrock".
20
79
  export const LlmSystemAttributeKeys: Array<string> = [
21
80
  "gen_ai.system",
@@ -96,6 +155,202 @@ export const LlmAgentNameAttributeKeys: Array<string> = [
96
155
  "agent.name",
97
156
  ];
98
157
 
158
+ /*
159
+ * ---------------------------------------------------------------------------
160
+ * WHO ran this call — the EMPLOYEE, never the employee's own customer.
161
+ * ---------------------------------------------------------------------------
162
+ *
163
+ * The LLM feature was born trace-only and had no concept of a human being, so
164
+ * "which of our engineers burned $4k on Opus last month" was unanswerable. The
165
+ * three lists below make the human actor a first-class span dimension, so
166
+ * spend can be grouped by person and by cost centre.
167
+ *
168
+ * THE CORRECTNESS RULE, and it is not a stylistic one: an LLM span can carry
169
+ * TWO different humans. The employee who made the call, and the DOWNSTREAM
170
+ * CUSTOMER on whose behalf the call was made (OpenAI's `user` request
171
+ * parameter, which instrumentations echo back as gen_ai.user / llm.user, and
172
+ * LiteLLM's end-user id). Those two are not interchangeable. Mapping a
173
+ * downstream customer into the employee columns produces internal chargeback
174
+ * that is not merely imprecise but WRONG — a support bot serving 40k
175
+ * customers would manufacture 40k phantom "employees", and the engineer who
176
+ * actually owns that spend would appear to have spent nothing.
177
+ *
178
+ * So the customer-identity keys are enumerated separately in
179
+ * LlmEndUserAttributeKeys below and are deliberately NOT read into any
180
+ * column. The exclusion is pinned by a test; if someone later wants
181
+ * downstream-customer analytics it must land in its OWN column, never by
182
+ * appending a key to the lists below.
183
+ */
184
+
185
+ /*
186
+ * The employee / internal actor id.
187
+ *
188
+ * Provenance, preferred first:
189
+ * - user.id OTel general semantic conventions; THE canonical key for
190
+ * the human actor and the one to standardize on.
191
+ * - enduser.id Also still an ACTIVE semconv attribute — the 1.25
192
+ * deprecation removed enduser.role and enduser.scope only,
193
+ * so this remains a legitimate, widely-emitted spelling.
194
+ * - litellm.metadata.user_api_key_user_id / metadata.user_api_key_user_id
195
+ * LiteLLM proxy: the internal user who OWNS the virtual key
196
+ * the request authenticated with. Note the sibling key
197
+ * user_api_key_end_user_id is the CUSTOMER and is excluded.
198
+ *
199
+ * BOTH spellings are required, and the bare one is the one
200
+ * that actually fires for most deployments. LiteLLM's
201
+ * default `otel` callback (v1) stamps its metadata with a
202
+ * bare "metadata." prefix — see METRIC_METADATA_KEYS and the
203
+ * `common_attrs[f"metadata.{key}"]` assignment in
204
+ * litellm/integrations/opentelemetry.py. The "litellm."
205
+ * namespace only appears under OpenTelemetry v2, which is
206
+ * OFF by default (LITELLM_OTEL_V2, `enabled: default=False`).
207
+ * Recognizing only the v2 spelling meant a stock LiteLLM
208
+ * proxy — the exact "one virtual key per employee" setup our
209
+ * docs recommend as the cleanest chargeback architecture —
210
+ * produced ZERO attribution, silently, with every gateway
211
+ * dollar landing in Unattributed and no error to explain it.
212
+ * - traceloop.association.properties.user_id OpenLLMetry.
213
+ * - langfuse.user.id Langfuse.
214
+ * - user.account_uuid / user.account_id Claude Code (and Codex for
215
+ * the latter) stamp the signed-in account onto every span.
216
+ * - cursor.user.id Cursor's OTel export; an opaque team-scoped integer,
217
+ * which is why it sorts last — it needs the Cursor admin API
218
+ * to resolve to a person, unlike the keys above.
219
+ *
220
+ * The `Base` list is the SPAN-attribute spelling only. Consumers must import
221
+ * LlmUserIdAttributeKeys, which adds the resource-attribute tier — see
222
+ * withResourcePrefixedKeys. The base list is exported solely so tests and
223
+ * docs can cross-check the two tiers against each other.
224
+ */
225
+ export const LlmUserIdBaseAttributeKeys: Array<string> = [
226
+ "user.id",
227
+ "enduser.id",
228
+ // LiteLLM OTel v2 spelling, then the v1 default. Both are needed — see above.
229
+ "litellm.metadata.user_api_key_user_id",
230
+ "metadata.user_api_key_user_id",
231
+ "traceloop.association.properties.user_id",
232
+ "langfuse.user.id",
233
+ "user.account_uuid",
234
+ "user.account_id",
235
+ "cursor.user.id",
236
+ ];
237
+
238
+ export const LlmUserIdAttributeKeys: Array<string> = withResourcePrefixedKeys(
239
+ LlmUserIdBaseAttributeKeys,
240
+ );
241
+
242
+ /*
243
+ * The employee's email address — in practice the only identity value a
244
+ * manager can read without a lookup table, which is why it gets its own
245
+ * column rather than being folded into the id.
246
+ *
247
+ * user.email is the OTel general-semconv key and is emitted NATIVELY by
248
+ * Claude Code, Gemini CLI and OpenAI Codex, so for the coding-agent fleet
249
+ * this list is usually the one that hits.
250
+ *
251
+ * The two LiteLLM spellings matter more than their position suggests: the
252
+ * proxy carries the key owner's email in its metadata (user_api_key_user_email
253
+ * is in METRIC_METADATA_KEYS), so a gateway issuing one virtual key per
254
+ * employee yields a real, human-readable email with no lookup table and no
255
+ * per-application instrumentation. That is the cleanest chargeback setup
256
+ * available today, and it is what our AI-gateway guide recommends. As with
257
+ * the id list, the bare "metadata." spelling is the DEFAULT one and the
258
+ * "litellm." namespace only appears under the opt-in OpenTelemetry v2 mode.
259
+ *
260
+ * Because this column holds real PII, TraceScrubRuleService scrubs it under
261
+ * the Attributes scope exactly as it scrubs the attribute it was derived from
262
+ * — see the identity-column pass in scrubSpan. A denormalized column that
263
+ * skipped the scrub pass would make a customer's email-redaction rule
264
+ * silently ineffective on the very column most likely to hold an email.
265
+ */
266
+ export const LlmUserEmailBaseAttributeKeys: Array<string> = [
267
+ "user.email",
268
+ // LiteLLM OTel v2 spelling, then the v1 default. Both are needed — see above.
269
+ "litellm.metadata.user_api_key_user_email",
270
+ "metadata.user_api_key_user_email",
271
+ "traceloop.association.properties.user_email",
272
+ "enduser.email",
273
+ ];
274
+
275
+ export const LlmUserEmailAttributeKeys: Array<string> =
276
+ withResourcePrefixedKeys(LlmUserEmailBaseAttributeKeys);
277
+
278
+ /*
279
+ * The team / cost centre the spend charges to.
280
+ *
281
+ * The first four are not emitted by any instrumentation on their own: they
282
+ * are what an organization sets via OTEL_RESOURCE_ATTRIBUTES on the agent
283
+ * process (OTEL_RESOURCE_ATTRIBUTES=team.id=platform,cost_center=RD-114), and
284
+ * are conventionally the ONLY way a coding-agent CLI learns which budget it
285
+ * belongs to. The remaining entries are the gateway/vendor spellings that
286
+ * arrive without any operator configuration.
287
+ *
288
+ * That first sentence is exactly why the resource tier is not optional here:
289
+ * OTEL_RESOURCE_ATTRIBUTES sets a RESOURCE attribute, which ingest delivers
290
+ * as `resource.team.id`. A list of bare keys alone would mean the one
291
+ * mechanism the docs present as THE way to attribute spend to a cost centre
292
+ * never matches a single row.
293
+ */
294
+ export const LlmTeamBaseAttributeKeys: Array<string> = [
295
+ "team.id",
296
+ "team",
297
+ "cost_center",
298
+ "department",
299
+ // LiteLLM OTel v2 spellings, then the v1 default. See the id list above.
300
+ "litellm.metadata.user_api_key_team_id",
301
+ "litellm.team.id",
302
+ "metadata.user_api_key_team_id",
303
+ "cursor.team.id",
304
+ ];
305
+
306
+ export const LlmTeamAttributeKeys: Array<string> = withResourcePrefixedKeys(
307
+ LlmTeamBaseAttributeKeys,
308
+ );
309
+
310
+ /*
311
+ * DELIBERATELY EXCLUDED — the DOWNSTREAM CUSTOMER, not the employee.
312
+ *
313
+ * Exported so the exclusion is a documented, testable decision rather than an
314
+ * omission somebody "fixes" later:
315
+ *
316
+ * - gen_ai.user / llm.user
317
+ * Both carry the OpenAI `user` REQUEST PARAMETER, which the API
318
+ * documents as the caller's own end user, sent for abuse monitoring. On
319
+ * a SaaS product's spans this is the SaaS product's customer.
320
+ * - litellm.metadata.user_api_key_end_user_id /
321
+ * metadata.user_api_key_end_user_id / litellm.end_user.id
322
+ * LiteLLM's explicit end-user id, distinct from the key-owner id above
323
+ * (which IS the employee and IS recognized). All three spellings are
324
+ * listed for the same reason the employee lists carry two: the bare
325
+ * "metadata." form is what the DEFAULT v1 otel callback emits, while
326
+ * "litellm.end_user.id" is the dedicated attribute the opt-in v2 mode
327
+ * defines in litellm/integrations/otel/model/semconv.py. Excluding only
328
+ * the v2 metadata spelling would leave the two spellings a real
329
+ * deployment actually produces free to be mistaken for an employee.
330
+ *
331
+ * None of these is denormalized into a column by this change. They remain
332
+ * available in the raw attributes map for anyone who queries them directly.
333
+ *
334
+ * This list is resource-widened too, and for the OPPOSITE reason to the
335
+ * lists above. Here the widening does not make anything match — it makes the
336
+ * EXCLUSION hold. The exclusion is enforced as a set relation ("no end-user
337
+ * key appears in an employee list"), so if this list stopped at the bare
338
+ * spellings while the employee lists carried both tiers, a future
339
+ * `resource.gen_ai.user` could be appended to an employee list and every
340
+ * exclusion test would still pass. Both tiers here, both tiers there.
341
+ */
342
+ export const LlmEndUserBaseAttributeKeys: Array<string> = [
343
+ "gen_ai.user",
344
+ "llm.user",
345
+ "litellm.metadata.user_api_key_end_user_id",
346
+ "metadata.user_api_key_end_user_id",
347
+ "litellm.end_user.id",
348
+ ];
349
+
350
+ export const LlmEndUserAttributeKeys: Array<string> = withResourcePrefixedKeys(
351
+ LlmEndUserBaseAttributeKeys,
352
+ );
353
+
99
354
  export const LlmToolNameAttributeKeys: Array<string> = [
100
355
  "gen_ai.tool.name",
101
356
  "tool.name",