@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,391 @@
1
+ import LlmSpanDisplayUtil, {
2
+ LlmSpanDisplay,
3
+ } from "../../../../App/FeatureSet/Dashboard/src/Utils/LlmSpanDisplay";
4
+ import LlmSpanUtil, {
5
+ LlmSpanFields,
6
+ } from "../../../Server/Utils/Telemetry/LlmSpan";
7
+ import { AttributeType } from "../../../Server/Utils/Telemetry/Telemetry";
8
+ import Dictionary from "../../../Types/Dictionary";
9
+ import { JSONObject } from "../../../Types/JSON";
10
+ import {
11
+ LlmEndUserAttributeKeys,
12
+ LlmTeamAttributeKeys,
13
+ LlmUserEmailAttributeKeys,
14
+ LlmUserIdAttributeKeys,
15
+ } from "../../../Types/Telemetry/LlmConventions";
16
+ import { describe, expect, test } from "@jest/globals";
17
+
18
+ /*
19
+ * ---------------------------------------------------------------------------
20
+ * The client half of the LLM convention pair
21
+ * ---------------------------------------------------------------------------
22
+ *
23
+ * LlmSpanDisplay.ts and Common/Server/Utils/Telemetry/LlmSpan.ts read the SAME
24
+ * key lists out of Common/Types/Telemetry/LlmConventions.ts, and that shared
25
+ * import is the only thing keeping the span panel and the denormalized
26
+ * columns naming the same person for the same span. Nothing about that
27
+ * arrangement is enforced by the type system: dropping a list from the
28
+ * client's import, or re-declaring a key inline, compiles perfectly and shows
29
+ * a blank "User" on exactly the emitters whose rows the table can filter.
30
+ *
31
+ * So the highest-value test in this file is the drift guard at the bottom,
32
+ * which walks every recognized key and asserts the two parsers agree on it.
33
+ * The rest pin the behaviours a reader of the panel depends on: preference
34
+ * order, the employee / downstream-customer separation, and the empty cases.
35
+ */
36
+
37
+ // A span whose attributes reach the client as a plain JSON map.
38
+ type Attributes = JSONObject;
39
+
40
+ // The server extractor's flattened attribute dictionary.
41
+ type ServerAttributes = Dictionary<AttributeType | Array<AttributeType>>;
42
+
43
+ /*
44
+ * Every case below carries an LLM marker. Both parsers gate identity on the
45
+ * span being an LLM span at all, so identity keys on their own would prove
46
+ * nothing about the key lists — see the gating test further down, which pins
47
+ * that gate on purpose.
48
+ */
49
+ const LLM_MARKER: Attributes = { "gen_ai.system": "openai" };
50
+
51
+ type ParseFunction = (attributes: Attributes) => LlmSpanDisplay;
52
+
53
+ const parse: ParseFunction = (attributes: Attributes): LlmSpanDisplay => {
54
+ return LlmSpanDisplayUtil.parse({ attributes: attributes });
55
+ };
56
+
57
+ type WithMarkerFunction = (attributes: Attributes) => Attributes;
58
+
59
+ const withMarker: WithMarkerFunction = (attributes: Attributes): Attributes => {
60
+ return { ...LLM_MARKER, ...attributes };
61
+ };
62
+
63
+ describe("LlmSpanDisplayUtil.parse — employee identity keys", () => {
64
+ /*
65
+ * Each list is ordered preferred-first and the parser returns the first key
66
+ * present. A key that is listed but never read is worse than one that is
67
+ * missing: the list is the documentation, and both the ingest extractor and
68
+ * this parser are read as the answer to "does OneUptime understand my
69
+ * instrumentation?".
70
+ */
71
+ test.each(LlmUserIdAttributeKeys)(
72
+ "user id key %s is recognized on its own",
73
+ (key: string) => {
74
+ expect(parse(withMarker({ [key]: "employee-42" })).userId).toBe(
75
+ "employee-42",
76
+ );
77
+ },
78
+ );
79
+
80
+ test.each(LlmUserEmailAttributeKeys)(
81
+ "user email key %s is recognized on its own",
82
+ (key: string) => {
83
+ expect(parse(withMarker({ [key]: "ada@example.com" })).userEmail).toBe(
84
+ "ada@example.com",
85
+ );
86
+ },
87
+ );
88
+
89
+ test.each(LlmTeamAttributeKeys)(
90
+ "team key %s is recognized on its own",
91
+ (key: string) => {
92
+ expect(parse(withMarker({ [key]: "platform" })).team).toBe("platform");
93
+ },
94
+ );
95
+
96
+ test("the whole preference order is honoured, most-preferred first", () => {
97
+ /*
98
+ * A single span carrying EVERY recognized spelling: a gateway that stamps
99
+ * its own metadata onto a span an SDK already annotated produces exactly
100
+ * this. Walking the list from the back and removing one key at a time
101
+ * asserts the full ordering rather than only its first element.
102
+ */
103
+ const attributes: Attributes = withMarker({});
104
+
105
+ for (const key of LlmUserIdAttributeKeys) {
106
+ attributes[key] = `value-of-${key}`;
107
+ }
108
+
109
+ for (
110
+ let index: number = 0;
111
+ index < LlmUserIdAttributeKeys.length;
112
+ index++
113
+ ) {
114
+ const expectedKey: string = LlmUserIdAttributeKeys[index]!;
115
+
116
+ expect(parse(attributes).userId).toBe(`value-of-${expectedKey}`);
117
+
118
+ delete attributes[expectedKey];
119
+ }
120
+
121
+ // Every key removed: nothing is left to resolve.
122
+ expect(parse(attributes).userId).toBe("");
123
+ });
124
+
125
+ test("user.id and user.email are read from nested attribute objects", () => {
126
+ /*
127
+ * The API returns attributes as they were stored, and OTLP producers that
128
+ * send `user: { id, email }` as a nested object arrive that way. The
129
+ * server extractor sees an already-flattened dictionary; the client has
130
+ * to flatten first, so this path is client-only and would otherwise be
131
+ * untested.
132
+ */
133
+ const display: LlmSpanDisplay = parse(
134
+ withMarker({
135
+ user: { id: "acct-9f2", email: "grace@example.com" },
136
+ }),
137
+ );
138
+
139
+ expect(display.userId).toBe("acct-9f2");
140
+ expect(display.userEmail).toBe("grace@example.com");
141
+ });
142
+ });
143
+
144
+ describe("LlmSpanDisplayUtil.parse — employee vs downstream customer", () => {
145
+ /*
146
+ * The load-bearing separation. gen_ai.user / llm.user carry OpenAI's `user`
147
+ * REQUEST parameter and litellm.metadata.user_api_key_end_user_id is
148
+ * LiteLLM's end-user id: on a SaaS product's spans all three name the
149
+ * product's OWN CUSTOMER. Reading one of them as the employee would
150
+ * manufacture a phantom employee per customer and leave the engineer who
151
+ * actually owns the spend looking like they spent nothing.
152
+ */
153
+ test.each(LlmEndUserAttributeKeys)(
154
+ "end-user key %s yields no employee",
155
+ (key: string) => {
156
+ const display: LlmSpanDisplay = parse(
157
+ withMarker({ [key]: "customer-1234" }),
158
+ );
159
+
160
+ expect(display.userId).toBe("");
161
+ expect(display.userEmail).toBe("");
162
+ expect(display.endUser).toBe("customer-1234");
163
+ },
164
+ );
165
+
166
+ test("a span carrying ONLY end-user keys has no employee at all", () => {
167
+ const attributes: Attributes = withMarker({});
168
+
169
+ for (const key of LlmEndUserAttributeKeys) {
170
+ attributes[key] = "customer-1234";
171
+ }
172
+
173
+ const display: LlmSpanDisplay = parse(attributes);
174
+
175
+ expect(display.userId).toBe("");
176
+ expect(display.userEmail).toBe("");
177
+ expect(display.team).toBe("");
178
+ expect(display.endUser).toBe("customer-1234");
179
+ });
180
+
181
+ test("employee and end user are both surfaced, separately, on one span", () => {
182
+ // The realistic LiteLLM shape: key owner is staff, end user is a customer.
183
+ const display: LlmSpanDisplay = parse(
184
+ withMarker({
185
+ "litellm.metadata.user_api_key_user_id": "employee-7",
186
+ "litellm.metadata.user_api_key_end_user_id": "customer-1234",
187
+ }),
188
+ );
189
+
190
+ expect(display.userId).toBe("employee-7");
191
+ expect(display.endUser).toBe("customer-1234");
192
+ });
193
+
194
+ test("no end-user key is also an employee key", () => {
195
+ /*
196
+ * The exclusion pinned as a set relation rather than through parse(), so
197
+ * appending an end-user spelling to an employee list fails here even if
198
+ * the parser's own behaviour is unchanged.
199
+ */
200
+ for (const key of LlmEndUserAttributeKeys) {
201
+ expect(LlmUserIdAttributeKeys).not.toContain(key);
202
+ expect(LlmUserEmailAttributeKeys).not.toContain(key);
203
+ expect(LlmTeamAttributeKeys).not.toContain(key);
204
+ }
205
+ });
206
+ });
207
+
208
+ describe("LlmSpanDisplayUtil.parse — absent and empty identity", () => {
209
+ test("an LLM span with no identity attributes reports none", () => {
210
+ const display: LlmSpanDisplay = parse(
211
+ withMarker({ "gen_ai.request.model": "gpt-4o" }),
212
+ );
213
+
214
+ expect(display.isLlmSpan).toBe(true);
215
+ expect(display.userId).toBe("");
216
+ expect(display.userEmail).toBe("");
217
+ expect(display.team).toBe("");
218
+ expect(display.endUser).toBe("");
219
+ });
220
+
221
+ test("whitespace-only values are treated as absent, not as a person", () => {
222
+ /*
223
+ * An emitter that sets the attribute from an unset environment variable
224
+ * sends "" or " ". Rendering that as the employee would produce a blank
225
+ * chip and a blank table cell that read as a real, nameless person.
226
+ */
227
+ const display: LlmSpanDisplay = parse(
228
+ withMarker({
229
+ "user.id": " ",
230
+ "user.email": "\t\n",
231
+ "team.id": " ",
232
+ }),
233
+ );
234
+
235
+ expect(display.userId).toBe("");
236
+ expect(display.userEmail).toBe("");
237
+ expect(display.team).toBe("");
238
+ });
239
+
240
+ test("a whitespace-only preferred key falls through to the next one", () => {
241
+ const display: LlmSpanDisplay = parse(
242
+ withMarker({ "user.id": " ", "enduser.id": "employee-42" }),
243
+ );
244
+
245
+ expect(display.userId).toBe("employee-42");
246
+ });
247
+
248
+ test("values are trimmed before display", () => {
249
+ expect(
250
+ parse(withMarker({ "user.email": " ada@example.com " })).userEmail,
251
+ ).toBe("ada@example.com");
252
+ });
253
+
254
+ test("identity alone does not make a span an LLM span", () => {
255
+ /*
256
+ * user.id / user.email / team.id are GENERIC OTel general-semconv keys
257
+ * that RUM browser spans and ordinary HTTP spans carry. If they counted
258
+ * as LLM evidence, the AI / LLM panel would open on a plain page-load
259
+ * span; and because the ingest extractor gates the llmUser* columns the
260
+ * same way, the panel would be claiming an employee the stored row does
261
+ * not have.
262
+ */
263
+ const display: LlmSpanDisplay = parse({
264
+ "http.method": "GET",
265
+ "user.id": "acct-9f2",
266
+ "user.email": "ada@example.com",
267
+ "team.id": "platform",
268
+ });
269
+
270
+ expect(display.isLlmSpan).toBe(false);
271
+ expect(display.userId).toBe("");
272
+ expect(display.userEmail).toBe("");
273
+ expect(display.team).toBe("");
274
+ });
275
+ });
276
+
277
+ describe("LlmSpanDisplay and the ingest extractor cannot drift", () => {
278
+ /*
279
+ * The guard this file exists for.
280
+ *
281
+ * The span panel parses attributes in the browser; the LLM calls table
282
+ * filters and sorts the columns the ingest extractor wrote. Both are
283
+ * supposed to be reading the identical key lists. Every way of breaking
284
+ * that — dropping a list from one side's import, re-declaring the strings
285
+ * inline, adding a key to only one parser — type-checks and renders, and
286
+ * shows up in production as a panel and a table row that disagree about who
287
+ * made a call.
288
+ *
289
+ * So each case below feeds ONE recognized key to BOTH parsers and requires
290
+ * the same answer. Adding a key to LlmConventions.ts extends this suite for
291
+ * free; wiring it into only one parser fails it.
292
+ */
293
+ type ExtractFunction = (attributes: Attributes) => LlmSpanFields;
294
+
295
+ const extract: ExtractFunction = (attributes: Attributes): LlmSpanFields => {
296
+ return LlmSpanUtil.extract(attributes as ServerAttributes);
297
+ };
298
+
299
+ test.each(LlmUserIdAttributeKeys)(
300
+ "client and server resolve the same employee id from %s",
301
+ (key: string) => {
302
+ const attributes: Attributes = withMarker({ [key]: "employee-42" });
303
+
304
+ expect(parse(attributes).userId).toBe(extract(attributes).llmUserId);
305
+ expect(parse(attributes).userId).toBe("employee-42");
306
+ },
307
+ );
308
+
309
+ test.each(LlmUserEmailAttributeKeys)(
310
+ "client and server resolve the same employee email from %s",
311
+ (key: string) => {
312
+ const attributes: Attributes = withMarker({ [key]: "ada@example.com" });
313
+
314
+ expect(parse(attributes).userEmail).toBe(
315
+ extract(attributes).llmUserEmail,
316
+ );
317
+ expect(parse(attributes).userEmail).toBe("ada@example.com");
318
+ },
319
+ );
320
+
321
+ test.each(LlmTeamAttributeKeys)(
322
+ "client and server resolve the same team from %s",
323
+ (key: string) => {
324
+ const attributes: Attributes = withMarker({ [key]: "platform" });
325
+
326
+ expect(parse(attributes).team).toBe(extract(attributes).llmTeam);
327
+ expect(parse(attributes).team).toBe("platform");
328
+ },
329
+ );
330
+
331
+ test.each(LlmEndUserAttributeKeys)(
332
+ "neither client nor server reads %s as the employee",
333
+ (key: string) => {
334
+ const attributes: Attributes = withMarker({ [key]: "customer-1234" });
335
+
336
+ const display: LlmSpanDisplay = parse(attributes);
337
+ const fields: LlmSpanFields = extract(attributes);
338
+
339
+ expect(display.userId).toBe("");
340
+ expect(display.userEmail).toBe("");
341
+ expect(fields.llmUserId).toBe("");
342
+ expect(fields.llmUserEmail).toBe("");
343
+
344
+ /*
345
+ * The client alone surfaces the end user, under its own label. That
346
+ * asymmetry is deliberate — the value has no column — so it is asserted
347
+ * rather than left to look like an oversight.
348
+ */
349
+ expect(display.endUser).toBe("customer-1234");
350
+ },
351
+ );
352
+
353
+ test("both parsers agree on a fully-populated coding-agent span", () => {
354
+ // The shape Claude Code / Codex / Gemini CLI actually export.
355
+ const attributes: Attributes = withMarker({
356
+ "gen_ai.request.model": "claude-opus-4",
357
+ "user.id": "acct-9f2",
358
+ "user.email": "ada@example.com",
359
+ "team.id": "platform",
360
+ cost_center: "RD-114",
361
+ });
362
+
363
+ const display: LlmSpanDisplay = parse(attributes);
364
+ const fields: LlmSpanFields = extract(attributes);
365
+
366
+ expect(display.userId).toBe(fields.llmUserId);
367
+ expect(display.userEmail).toBe(fields.llmUserEmail);
368
+ expect(display.team).toBe(fields.llmTeam);
369
+
370
+ // team.id outranks cost_center in the shared list, on both sides.
371
+ expect(display.team).toBe("platform");
372
+ });
373
+
374
+ test("both parsers gate identity on the span being an LLM span", () => {
375
+ const attributes: Attributes = {
376
+ "http.method": "GET",
377
+ "user.id": "acct-9f2",
378
+ "user.email": "ada@example.com",
379
+ "team.id": "platform",
380
+ };
381
+
382
+ const display: LlmSpanDisplay = parse(attributes);
383
+ const fields: LlmSpanFields = extract(attributes);
384
+
385
+ expect(display.isLlmSpan).toBe(fields.isLlmSpan);
386
+ expect(display.isLlmSpan).toBe(false);
387
+ expect(display.userId).toBe(fields.llmUserId);
388
+ expect(display.userEmail).toBe(fields.llmUserEmail);
389
+ expect(display.team).toBe(fields.llmTeam);
390
+ });
391
+ });