@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
|
@@ -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
|
}
|
package/Server/API/BaseAPI.ts
CHANGED
|
@@ -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
|
}
|
package/Server/API/SlackAPI.ts
CHANGED
|
@@ -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 =
|
|
55
|
-
"x-slack-signature"
|
|
56
|
-
|
|
57
|
-
const timestamp: string =
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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
|
|
113
|
-
|
|
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.
|
|
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
|
}
|