@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
@@ -1150,6 +1150,80 @@ export default class Span extends AnalyticsBaseModel {
1150
1150
  accessControl: llmColumnAccessControl,
1151
1151
  });
1152
1152
 
1153
+ /*
1154
+ * Employee identity columns. Denormalized at ingest from the OTel general
1155
+ * semantic-convention identity attributes (user.id / user.email / team.id)
1156
+ * and the gateway + coding-agent fallbacks — see
1157
+ * LlmUserIdAttributeKeys in Common/Types/Telemetry/LlmConventions.ts.
1158
+ *
1159
+ * These answer the question the trace-only LLM feature could not: which
1160
+ * PERSON, and which cost centre, does this spend belong to. They are
1161
+ * populated only on LLM spans (the extractor gates them on isLlmSpan), so
1162
+ * the RUM and HTTP fleet — which also carries user.id — pays nothing for
1163
+ * them.
1164
+ *
1165
+ * They hold real identifying data, so TraceScrubRuleService.scrubSpan
1166
+ * applies the project's Attributes-scoped scrub rules to them just as it
1167
+ * does to the attribute they were derived from.
1168
+ */
1169
+ const llmUserIdColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
1170
+ key: "llmUserId",
1171
+ isLowCardinality: true,
1172
+ title: "LLM User ID",
1173
+ description:
1174
+ "Id of the employee / internal actor who made this LLM call (user.id, enduser.id, or the gateway / coding-agent equivalent). NOT the caller's own downstream customer — that identity is deliberately never mapped here. '' when the instrumentation reports none.",
1175
+ required: false,
1176
+ type: TableColumnType.Text,
1177
+ /*
1178
+ * Set index sized like idx_llm_request_model: a project's employee
1179
+ * roster is in the hundreds-to-low-thousands, so 1000 distinct values
1180
+ * per granule keeps the index useful for the "one person's spend"
1181
+ * query without degenerating into a per-granule scan.
1182
+ */
1183
+ skipIndex: {
1184
+ name: "idx_llm_user_id",
1185
+ type: SkipIndexType.Set,
1186
+ params: [1000],
1187
+ granularity: 4,
1188
+ },
1189
+ accessControl: llmColumnAccessControl,
1190
+ });
1191
+
1192
+ const llmUserEmailColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
1193
+ key: "llmUserEmail",
1194
+ isLowCardinality: true,
1195
+ title: "LLM User Email",
1196
+ description:
1197
+ "Email of the employee / internal actor who made this LLM call (user.email, emitted natively by Claude Code, Gemini CLI and Codex). Subject to the project's Attributes-scoped trace scrub rules. '' when the instrumentation reports none.",
1198
+ required: false,
1199
+ type: TableColumnType.Text,
1200
+ // Same sizing rationale as idx_llm_user_id — one row per employee.
1201
+ skipIndex: {
1202
+ name: "idx_llm_user_email",
1203
+ type: SkipIndexType.Set,
1204
+ params: [1000],
1205
+ granularity: 4,
1206
+ },
1207
+ accessControl: llmColumnAccessControl,
1208
+ });
1209
+
1210
+ const llmTeamColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
1211
+ key: "llmTeam",
1212
+ isLowCardinality: true,
1213
+ title: "LLM Team",
1214
+ description:
1215
+ "Team / cost centre the LLM spend charges to (team.id, cost_center, department — conventionally set via OTEL_RESOURCE_ATTRIBUTES). '' when none is reported.",
1216
+ required: false,
1217
+ type: TableColumnType.Text,
1218
+ /*
1219
+ * No skip index, mirroring llmResponseModel. Teams number in the tens:
1220
+ * a Set index would match nearly every granule and cost more to read
1221
+ * than the filter it saves, and the LowCardinality dictionary already
1222
+ * makes the column cheap to scan.
1223
+ */
1224
+ accessControl: llmColumnAccessControl,
1225
+ });
1226
+
1153
1227
  const retentionDateColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
1154
1228
  key: "retentionDate",
1155
1229
  codec: [{ codec: "DoubleDelta" }, { codec: "ZSTD", level: 1 }],
@@ -1245,6 +1319,9 @@ export default class Span extends AnalyticsBaseModel {
1245
1319
  llmTotalTokensColumn,
1246
1320
  llmCostColumn,
1247
1321
  llmConversationIdColumn,
1322
+ llmUserIdColumn,
1323
+ llmUserEmailColumn,
1324
+ llmTeamColumn,
1248
1325
  retentionDateColumn,
1249
1326
  ],
1250
1327
  projections: [
@@ -1574,4 +1651,28 @@ export default class Span extends AnalyticsBaseModel {
1574
1651
  public set llmConversationId(v: string | undefined) {
1575
1652
  this.setColumnValue("llmConversationId", v);
1576
1653
  }
1654
+
1655
+ public get llmUserId(): string | undefined {
1656
+ return this.getColumnValue("llmUserId") as string | undefined;
1657
+ }
1658
+
1659
+ public set llmUserId(v: string | undefined) {
1660
+ this.setColumnValue("llmUserId", v);
1661
+ }
1662
+
1663
+ public get llmUserEmail(): string | undefined {
1664
+ return this.getColumnValue("llmUserEmail") as string | undefined;
1665
+ }
1666
+
1667
+ public set llmUserEmail(v: string | undefined) {
1668
+ this.setColumnValue("llmUserEmail", v);
1669
+ }
1670
+
1671
+ public get llmTeam(): string | undefined {
1672
+ return this.getColumnValue("llmTeam") as string | undefined;
1673
+ }
1674
+
1675
+ public set llmTeam(v: string | undefined) {
1676
+ this.setColumnValue("llmTeam", v);
1677
+ }
1577
1678
  }
@@ -147,18 +147,6 @@ export default class BaseAPI<
147
147
  },
148
148
  );
149
149
 
150
- router.get(
151
- `${new this.entityType().getCrudApiPath()?.toString()}/:id/update-item`,
152
- UserMiddleware.getUserMiddleware,
153
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
154
- try {
155
- await this.updateItem(req, res);
156
- } catch (err) {
157
- next(err);
158
- }
159
- },
160
- );
161
-
162
150
  // Delete
163
151
  router.delete(
164
152
  `${new this.entityType().getCrudApiPath()?.toString()}/:id`,
@@ -184,18 +172,6 @@ export default class BaseAPI<
184
172
  },
185
173
  );
186
174
 
187
- router.get(
188
- `${new this.entityType().getCrudApiPath()?.toString()}/:id/delete-item`,
189
- UserMiddleware.getUserMiddleware,
190
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
191
- try {
192
- await this.deleteItem(req, res);
193
- } catch (err) {
194
- next(err);
195
- }
196
- },
197
- );
198
-
199
175
  this.router = router;
200
176
  this.service = service;
201
177
  }
@@ -592,7 +592,6 @@ export default class SlackAPI {
592
592
  "Slack Interactive Auth Result: ",
593
593
  getLogAttributesFromRequest(req as any),
594
594
  );
595
- logger.debug(authResult, getLogAttributesFromRequest(req as any));
596
595
 
597
596
  // if slack uninstall app then,
598
597
  if (authResult.payloadType === "app_uninstall") {
@@ -886,7 +885,6 @@ export default class SlackAPI {
886
885
  "Slack Events API Request received",
887
886
  getLogAttributesFromRequest(req as any),
888
887
  );
889
- logger.debug(req.body, getLogAttributesFromRequest(req as any));
890
888
 
891
889
  const payload: JSONObject = req.body;
892
890
 
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  ExpressResponse,
3
+ headerValueToString,
3
4
  NextFunction,
4
5
  OneUptimeRequest,
5
6
  } from "../Utils/Express";
7
+ import GlobalCache from "../Infrastructure/GlobalCache";
6
8
  import Response from "../Utils/Response";
7
9
  import BadDataException from "../../Types/Exception/BadDataException";
10
+ import ServiceUnavailableException from "../../Types/Exception/ServiceUnavailableException";
8
11
  import { SlackAppSigningSecret } from "../EnvironmentConfig";
9
12
  import crypto from "crypto";
10
13
  import logger, { getLogAttributesFromRequest } from "../Utils/Logger";
@@ -21,6 +24,9 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
21
24
  */
22
25
  const SIGNATURE_PREFIX: string = "v0=";
23
26
  const HEX_DIGEST_REGEX: RegExp = /^[a-f0-9]{64}$/i;
27
+ const UNIX_TIMESTAMP_REGEX: RegExp = /^\d+$/;
28
+ const MAX_REQUEST_AGE_IN_SECONDS: number = 5 * 60;
29
+ const REPLAY_CACHE_NAMESPACE: string = "slack-request-replay";
24
30
 
25
31
  export default class SlackAuthorization {
26
32
  @CaptureSpan()
@@ -51,25 +57,41 @@ export default class SlackAuthorization {
51
57
  // validate slack signing secret
52
58
  const slackSigningSecret: string = SlackAppSigningSecret.toString();
53
59
 
54
- const slackSignature: string | undefined = req.headers[
55
- "x-slack-signature"
56
- ] as string | undefined;
57
- const timestamp: string = req.headers[
58
- "x-slack-request-timestamp"
59
- ] as string;
60
+ const slackSignature: string | undefined = headerValueToString(
61
+ req.headers["x-slack-signature"],
62
+ );
63
+ const timestamp: string =
64
+ headerValueToString(req.headers["x-slack-request-timestamp"]) || "";
60
65
  // Use rawBody for both JSON and URL-encoded requests, fallback to rawFormUrlEncodedBody for backward compatibility
61
66
  const requestBody: string =
62
67
  (req as OneUptimeRequest).rawBody ||
63
68
  (req as OneUptimeRequest).rawFormUrlEncodedBody ||
64
69
  "";
65
70
 
66
- logger.debug(
67
- `slackSignature: ${slackSignature}`,
68
- getLogAttributesFromRequest(req),
69
- );
70
- logger.debug(`timestamp: ${timestamp}`, getLogAttributesFromRequest(req));
71
- logger.debug(`requestBody: `, getLogAttributesFromRequest(req));
72
- logger.debug(requestBody, getLogAttributesFromRequest(req));
71
+ /*
72
+ * Slack signs the timestamp specifically so a captured request cannot be
73
+ * replayed indefinitely. Verify the documented five-minute freshness
74
+ * window before doing any signature work. The strict decimal check keeps
75
+ * Number parsing from accepting partial values such as "123abc".
76
+ */
77
+ const timestampInSeconds: number = Number(timestamp);
78
+ const nowInSeconds: number = Math.floor(Date.now() / 1000);
79
+
80
+ if (
81
+ !UNIX_TIMESTAMP_REGEX.test(timestamp) ||
82
+ !Number.isSafeInteger(timestampInSeconds) ||
83
+ Math.abs(nowInSeconds - timestampInSeconds) > MAX_REQUEST_AGE_IN_SECONDS
84
+ ) {
85
+ logger.error(
86
+ "Slack request has a missing, malformed, or stale X-Slack-Request-Timestamp header.",
87
+ getLogAttributesFromRequest(req),
88
+ );
89
+ return Response.sendErrorResponse(
90
+ req,
91
+ res,
92
+ new BadDataException("Slack Signature Verification Failed."),
93
+ );
94
+ }
73
95
 
74
96
  const providedDigest: string =
75
97
  slackSignature && slackSignature.startsWith(SIGNATURE_PREFIX)
@@ -94,11 +116,6 @@ export default class SlackAuthorization {
94
116
  .update(baseString)
95
117
  .digest("hex");
96
118
 
97
- logger.debug(
98
- `Generated signature: ${SIGNATURE_PREFIX}${expectedDigest}`,
99
- getLogAttributesFromRequest(req),
100
- );
101
-
102
119
  /*
103
120
  * Both sides are decoded from 64 validated hex characters, so both
104
121
  * buffers are 32 bytes and timingSafeEqual cannot throw here.
@@ -120,6 +137,67 @@ export default class SlackAuthorization {
120
137
  );
121
138
  }
122
139
 
140
+ /*
141
+ * URL verification and options loading have no side effects and need their
142
+ * normal response on every valid delivery. All other signed Slack routes
143
+ * are processed at most once.
144
+ */
145
+ const isNaturallyIdempotentRequest: boolean =
146
+ req.route?.path === "/slack/options-load" ||
147
+ req.body?.["type"] === "url_verification";
148
+
149
+ if (isNaturallyIdempotentRequest) {
150
+ next();
151
+ return;
152
+ }
153
+
154
+ /*
155
+ * Freshness bounds replay to a small window; this atomic cache claim closes
156
+ * that remaining window across every application replica. The expiry lasts
157
+ * until this timestamp can no longer pass the freshness check. That can be
158
+ * almost ten minutes when a sender clock is five minutes ahead.
159
+ */
160
+ const replayCacheTtlInSeconds: number = Math.max(
161
+ timestampInSeconds + MAX_REQUEST_AGE_IN_SECONDS - nowInSeconds + 1,
162
+ 1,
163
+ );
164
+
165
+ let isFirstDelivery: boolean;
166
+
167
+ try {
168
+ isFirstDelivery = await GlobalCache.setStringIfNotExists(
169
+ REPLAY_CACHE_NAMESPACE,
170
+ expectedDigest,
171
+ timestamp,
172
+ { expiresInSeconds: replayCacheTtlInSeconds },
173
+ );
174
+ } catch {
175
+ /*
176
+ * Replay protection is part of authentication, so cache failure must
177
+ * fail closed. Letting the request through would silently restore the
178
+ * vulnerability whenever Redis is unavailable.
179
+ */
180
+ logger.error(
181
+ "Slack replay protection is unavailable.",
182
+ getLogAttributesFromRequest(req),
183
+ );
184
+ return Response.sendErrorResponse(
185
+ req,
186
+ res,
187
+ new ServiceUnavailableException(
188
+ "Slack request verification is temporarily unavailable.",
189
+ ),
190
+ );
191
+ }
192
+
193
+ if (!isFirstDelivery) {
194
+ logger.debug(
195
+ "Duplicate Slack request acknowledged without reprocessing.",
196
+ getLogAttributesFromRequest(req),
197
+ );
198
+ return Response.sendTextResponse(req, res, "");
199
+ }
200
+
123
201
  logger.debug(
124
202
  "Slack request authorized successfully",
125
203
  getLogAttributesFromRequest(req),
@@ -75,6 +75,38 @@ export default class LlmMetricSpend {
75
75
  } as AggregateBy<Metric>;
76
76
  }
77
77
 
78
+ /**
79
+ * Aggregate descriptor for the micro-USD cost sum.
80
+ *
81
+ * Identical in shape to buildCostAggregateBy, deliberately a SECOND query
82
+ * rather than a wider name list on the first: the two name lists carry
83
+ * different units, and a single Sum over both would add micro-USD figures
84
+ * to USD ones with no way to recover the unit afterwards. Codex spend would
85
+ * arrive a million times too large and trip every cost budget in the
86
+ * project. getCostInUSD scales this total once, via
87
+ * LlmMetricQuery.combineCostTotals.
88
+ */
89
+ public static buildMicroUsdCostAggregateBy(
90
+ scope: LlmMetricScope,
91
+ ): AggregateBy<Metric> {
92
+ return {
93
+ query: LlmMetricQuery.buildMicroUsdCostQuery(scope),
94
+ aggregationType: AggregationType.Sum,
95
+ aggregateColumnName: "value",
96
+ aggregationTimestampColumnName: "time",
97
+ startTimestamp: scope.startTime,
98
+ endTimestamp: scope.endTime,
99
+ aggregationInterval: AggregationInterval.Total,
100
+ // Same fail-loud reasoning as the USD query above.
101
+ timeoutOverflowMode: "throw",
102
+ limit: LIMIT_PER_PROJECT,
103
+ skip: 0,
104
+ props: {
105
+ isRoot: true,
106
+ },
107
+ } as AggregateBy<Metric>;
108
+ }
109
+
78
110
  /**
79
111
  * Aggregate descriptor for the token sum.
80
112
  *
@@ -106,14 +138,33 @@ export default class LlmMetricSpend {
106
138
  } as AggregateBy<Metric>;
107
139
  }
108
140
 
109
- /** Metric-sourced spend in USD for the scope, or 0 when nothing matched. */
141
+ /**
142
+ * Metric-sourced spend in USD for the scope, or 0 when nothing matched.
143
+ *
144
+ * Two aggregates, not one, because the recognized cost metrics come in two
145
+ * UNITS: dollars (the gateways, Claude Code, Cursor) and millionths of a
146
+ * dollar (the Codex CLI). They are queried apart and scaled before being
147
+ * added, in LlmMetricQuery.combineCostTotals — the one place the 1e-6
148
+ * factor lives. Folding the micro-USD names into the USD query would report
149
+ * Codex spend a million times too high, which for a figure that gates
150
+ * budget alerting is far worse than reporting nothing.
151
+ *
152
+ * The two run in parallel: they hit the same table with disjoint name
153
+ * filters, and serializing them would double the latency of every budget
154
+ * evaluation for no benefit.
155
+ */
110
156
  @CaptureSpan()
111
157
  public static async getCostInUSD(scope: LlmMetricScope): Promise<number> {
112
- const result: AggregatedResult = await MetricService.aggregateBy(
113
- this.buildCostAggregateBy(scope),
114
- );
158
+ const [usdResult, microUsdResult]: [AggregatedResult, AggregatedResult] =
159
+ await Promise.all([
160
+ MetricService.aggregateBy(this.buildCostAggregateBy(scope)),
161
+ MetricService.aggregateBy(this.buildMicroUsdCostAggregateBy(scope)),
162
+ ]);
115
163
 
116
- return LlmMetricQuery.sumAggregatedRows(result?.data);
164
+ return LlmMetricQuery.combineCostTotals({
165
+ usd: LlmMetricQuery.sumAggregatedRows(usdResult?.data),
166
+ microUsd: LlmMetricQuery.sumAggregatedRows(microUsdResult?.data),
167
+ });
117
168
  }
118
169
 
119
170
  /** Metric-sourced input/output token totals for the scope. */
@@ -14,8 +14,11 @@ import {
14
14
  LlmRequestModelAttributeKeys,
15
15
  LlmResponseModelAttributeKeys,
16
16
  LlmSystemAttributeKeys,
17
+ LlmTeamAttributeKeys,
17
18
  LlmToolNameAttributeKeys,
18
19
  LlmTotalTokenAttributeKeys,
20
+ LlmUserEmailAttributeKeys,
21
+ LlmUserIdAttributeKeys,
19
22
  } from "../../../Types/Telemetry/LlmConventions";
20
23
  import { AttributeType } from "./Telemetry";
21
24
 
@@ -61,6 +64,20 @@ export interface LlmSpanFields {
61
64
  llmToolName: string;
62
65
  // Conversation / session id grouping calls of one interaction (gen_ai.conversation.id).
63
66
  llmConversationId: string;
67
+ /*
68
+ * WHO ran the call — the EMPLOYEE / internal actor, never the caller's own
69
+ * downstream customer. See the block comment above
70
+ * LlmUserIdAttributeKeys in Common/Types/Telemetry/LlmConventions.ts for
71
+ * why that distinction is load-bearing rather than pedantic.
72
+ *
73
+ * "" when the instrumentation reports no identity, which is still the
74
+ * common case for library-instrumented server code — only the coding-agent
75
+ * CLIs and the gateways stamp identity by default.
76
+ */
77
+ llmUserId: string;
78
+ llmUserEmail: string;
79
+ // Team / cost centre the spend charges to (team.id, cost_center, ...).
80
+ llmTeam: string;
64
81
  }
65
82
 
66
83
  type SpanAttributes = Dictionary<AttributeType | Array<AttributeType>>;
@@ -83,6 +100,9 @@ export default class LlmSpanUtil {
83
100
  llmAgentName: "",
84
101
  llmToolName: "",
85
102
  llmConversationId: "",
103
+ llmUserId: "",
104
+ llmUserEmail: "",
105
+ llmTeam: "",
86
106
  };
87
107
  }
88
108
 
@@ -184,6 +204,32 @@ export default class LlmSpanUtil {
184
204
  attributes,
185
205
  LlmConversationIdAttributeKeys,
186
206
  );
207
+
208
+ /*
209
+ * Identity is gated on isLlmSpan for exactly the reason the
210
+ * conversation id above is. "user.id", "user.email" and "team.id" are
211
+ * GENERIC OTel general-semconv keys — RUM browser spans and ordinary
212
+ * backend HTTP spans routinely carry them, and those are the
213
+ * highest-volume span classes there are. Stamping them onto every such
214
+ * span would copy the value (and pay for its skip index) across the
215
+ * whole fleet to serve a reader that only ever asks "which employee
216
+ * spent what on LLM calls". The LLM spans are the only rows that
217
+ * question reads, so they are the only rows that carry the columns.
218
+ *
219
+ * The keys that carry the caller's DOWNSTREAM CUSTOMER rather than the
220
+ * employee (gen_ai.user, llm.user,
221
+ * litellm.metadata.user_api_key_end_user_id) are deliberately absent
222
+ * from these lists — see LlmEndUserAttributeKeys. Reading one of them
223
+ * here would silently misattribute internal chargeback.
224
+ */
225
+ fields.llmUserId = this.getString(attributes, LlmUserIdAttributeKeys);
226
+
227
+ fields.llmUserEmail = this.getString(
228
+ attributes,
229
+ LlmUserEmailAttributeKeys,
230
+ );
231
+
232
+ fields.llmTeam = this.getString(attributes, LlmTeamAttributeKeys);
187
233
  }
188
234
 
189
235
  /*
@@ -65,8 +65,6 @@ export default class SlackAuthAction {
65
65
  const { req } = data;
66
66
 
67
67
  logger.debug("Starting Slack request authorization");
68
- logger.debug(`Request body: `);
69
- logger.debug(req.body);
70
68
 
71
69
  let payload: JSONObject = req.body;
72
70
 
@@ -74,9 +72,6 @@ export default class SlackAuthAction {
74
72
  payload = JSON.parse(payload["payload"]);
75
73
  }
76
74
 
77
- logger.debug(`Payload: `);
78
- logger.debug(payload);
79
-
80
75
  let slackUserId: string | undefined = (
81
76
  (payload as JSONObject)["user"] as JSONObject
82
77
  )?.["id"] as string;
@@ -216,11 +211,6 @@ export default class SlackAuthAction {
216
211
  };
217
212
 
218
213
  actions.push(action);
219
- logger.debug("View values: ");
220
- logger.debug(viewValues);
221
-
222
- logger.debug("Actions: ");
223
- logger.debug(actions);
224
214
  }
225
215
 
226
216
  if (payload["callback_id"]) {
@@ -296,8 +286,6 @@ export default class SlackAuthAction {
296
286
  logger.debug("Slack request authorized successfully", {
297
287
  projectId: projectId.toString(),
298
288
  });
299
- logger.debug("Slack request: ");
300
- logger.debug(slackRequest);
301
289
 
302
290
  return slackRequest;
303
291
  }