@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
@@ -151,6 +151,7 @@ describe("BaseAPI", () => {
151
151
  } as OneUptimeRequest;
152
152
 
153
153
  beforeAll(async () => {
154
+ mockRouter.routes.length = 0;
154
155
  mockRouter.post.mockClear();
155
156
  mockRouter.get.mockClear();
156
157
  mockRouter.put.mockClear();
@@ -208,11 +209,49 @@ describe("BaseAPI", () => {
208
209
  expect.any(Function),
209
210
  );
210
211
 
212
+ expect(Express.getRouter().post).toHaveBeenCalledWith(
213
+ "/mock/:id/update-item",
214
+ UserMiddleware.getUserMiddleware,
215
+ expect.any(Function),
216
+ );
217
+
211
218
  expect(Express.getRouter().delete).toHaveBeenCalledWith(
212
219
  "/mock/:id",
213
220
  UserMiddleware.getUserMiddleware,
214
221
  expect.any(Function),
215
222
  );
223
+
224
+ expect(Express.getRouter().post).toHaveBeenCalledWith(
225
+ "/mock/:id/delete-item",
226
+ UserMiddleware.getUserMiddleware,
227
+ expect.any(Function),
228
+ );
229
+ });
230
+
231
+ it("does not register state-changing GET aliases", () => {
232
+ const registeredGetPaths: Array<string> = mockRouter.routes
233
+ .filter((route: { method: string }) => {
234
+ return route.method === "GET";
235
+ })
236
+ .map((route: { uri: string }) => {
237
+ return route.uri;
238
+ });
239
+
240
+ expect(registeredGetPaths).not.toContain("/mock/:id/update-item");
241
+ expect(registeredGetPaths).not.toContain("/mock/:id/delete-item");
242
+ });
243
+
244
+ it("keeps read-only GET compatibility routes", () => {
245
+ const registeredGetPaths: Array<string> = mockRouter.routes
246
+ .filter((route: { method: string }) => {
247
+ return route.method === "GET";
248
+ })
249
+ .map((route: { uri: string }) => {
250
+ return route.uri;
251
+ });
252
+
253
+ expect(registeredGetPaths).toContain("/mock/get-list");
254
+ expect(registeredGetPaths).toContain("/mock/:id/get-item");
216
255
  });
217
256
  });
218
257
 
@@ -226,7 +265,9 @@ describe("BaseAPI", () => {
226
265
  ["POST", "/mock/:id/get-item", "getItem"],
227
266
  ["GET", "/mock/:id/get-item", "getItem"],
228
267
  ["PUT", "/mock/:id", "updateItem"],
268
+ ["POST", "/mock/:id/update-item", "updateItem"],
229
269
  ["DELETE", "/mock/:id", "deleteItem"],
270
+ ["POST", "/mock/:id/delete-item", "deleteItem"],
230
271
  ] as [string, string, string][];
231
272
 
232
273
  for (const [method, uri, shouldBeCalled] of checkRoutes) {
@@ -261,11 +261,7 @@ describe("BaseAPI.updateItem payload validation", () => {
261
261
  });
262
262
  });
263
263
 
264
- /*
265
- * PUT /:id, POST /:id/update-item and GET /:id/update-item all land on this
266
- * one handler, so none of them can be left answering 200 to a payload that
267
- * writes nothing. GET carries no body at all.
268
- */
264
+ /* PUT /:id and POST /:id/update-item both land on this one handler. */
269
265
  describe("across every route that reaches updateItem", () => {
270
266
  it("refuses the flat body on the POST update-item route", async () => {
271
267
  const request: OneUptimeRequest = requestWithBody({
@@ -278,15 +274,7 @@ describe("BaseAPI.updateItem payload validation", () => {
278
274
  );
279
275
  });
280
276
 
281
- it("refuses the bodyless GET update-item route instead of quietly writing nothing", async () => {
282
- await expect(
283
- api.updateItem(requestWithBody({}), response),
284
- ).rejects.toThrow(BadDataException);
285
-
286
- expect(service.updateOneById).not.toHaveBeenCalled();
287
- });
288
-
289
- it("registers all three update routes", () => {
277
+ it("registers only non-GET update routes", () => {
290
278
  const crudPath: string = new Probe().getCrudApiPath()!.toString();
291
279
 
292
280
  expect(mockRouter.match("PUT", `${crudPath}/:id`)).toBeTruthy();
@@ -294,8 +282,13 @@ describe("BaseAPI.updateItem payload validation", () => {
294
282
  mockRouter.match("POST", `${crudPath}/:id/update-item`),
295
283
  ).toBeTruthy();
296
284
  expect(
297
- mockRouter.match("GET", `${crudPath}/:id/update-item`),
298
- ).toBeTruthy();
285
+ mockRouter.routes.some((route: { method: string; uri: string }) => {
286
+ return (
287
+ route.method === "GET" &&
288
+ route.uri === `${crudPath}/:id/update-item`
289
+ );
290
+ }),
291
+ ).toBe(false);
299
292
  });
300
293
  });
301
294
 
@@ -1,5 +1,6 @@
1
- import { describe, expect, test, beforeEach } from "@jest/globals";
1
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
2
2
  import crypto from "crypto";
3
+ import GlobalCache from "../../../Server/Infrastructure/GlobalCache";
3
4
  import SlackAuthorization from "../../../Server/Middleware/SlackAuthorization";
4
5
  import Response from "../../../Server/Utils/Response";
5
6
  import {
@@ -8,6 +9,7 @@ import {
8
9
  OneUptimeRequest,
9
10
  } from "../../../Server/Utils/Express";
10
11
  import BadDataException from "../../../Types/Exception/BadDataException";
12
+ import ServiceUnavailableException from "../../../Types/Exception/ServiceUnavailableException";
11
13
 
12
14
  const SIGNING_SECRET: string = "slack-app-signing-secret";
13
15
 
@@ -28,6 +30,16 @@ jest.mock("../../../Server/Utils/Response", () => {
28
30
  __esModule: true,
29
31
  default: {
30
32
  sendErrorResponse: jest.fn(),
33
+ sendTextResponse: jest.fn(),
34
+ },
35
+ };
36
+ });
37
+
38
+ jest.mock("../../../Server/Infrastructure/GlobalCache", () => {
39
+ return {
40
+ __esModule: true,
41
+ default: {
42
+ setStringIfNotExists: jest.fn(),
31
43
  },
32
44
  };
33
45
  });
@@ -41,11 +53,16 @@ jest.mock("../../../Server/Utils/Response", () => {
41
53
  * have to come back as the intended 400.
42
54
  */
43
55
  describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
44
- const TIMESTAMP: string = "1700000000";
56
+ const NOW_IN_SECONDS: number = 1_800_000_000;
57
+ const TIMESTAMP: string = NOW_IN_SECONDS.toString();
45
58
  const RAW_BODY: string = "token=abc&team_id=T123&command=%2Foneuptime";
46
59
 
47
60
  const sendErrorResponse: jest.Mock =
48
61
  Response.sendErrorResponse as unknown as jest.Mock;
62
+ const sendTextResponse: jest.Mock =
63
+ Response.sendTextResponse as unknown as jest.Mock;
64
+ const setStringIfNotExists: jest.Mock =
65
+ GlobalCache.setStringIfNotExists as unknown as jest.Mock;
49
66
 
50
67
  const res: ExpressResponse = {} as ExpressResponse;
51
68
  let next: NextFunction;
@@ -62,15 +79,19 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
62
79
  type BuildRequestFunction = (
63
80
  signature: string | undefined,
64
81
  body?: string,
82
+ timestamp?: string | null,
65
83
  ) => OneUptimeRequest;
66
84
 
67
85
  const buildRequest: BuildRequestFunction = (
68
86
  signature: string | undefined,
69
87
  body: string = RAW_BODY,
88
+ timestamp: string | null = TIMESTAMP,
70
89
  ): OneUptimeRequest => {
71
- const headers: Record<string, string> = {
72
- "x-slack-request-timestamp": TIMESTAMP,
73
- };
90
+ const headers: Record<string, string> = {};
91
+
92
+ if (timestamp !== null) {
93
+ headers["x-slack-request-timestamp"] = timestamp;
94
+ }
74
95
 
75
96
  if (signature !== undefined) {
76
97
  headers["x-slack-signature"] = signature;
@@ -81,9 +102,15 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
81
102
 
82
103
  beforeEach(() => {
83
104
  jest.clearAllMocks();
105
+ jest.spyOn(Date, "now").mockReturnValue(NOW_IN_SECONDS * 1000);
106
+ setStringIfNotExists.mockResolvedValue(true);
84
107
  next = jest.fn();
85
108
  });
86
109
 
110
+ afterEach(() => {
111
+ jest.restoreAllMocks();
112
+ });
113
+
87
114
  test("calls next when the signature matches the timestamp and body", async () => {
88
115
  const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
89
116
 
@@ -91,6 +118,12 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
91
118
 
92
119
  expect(next).toHaveBeenCalled();
93
120
  expect(sendErrorResponse).not.toHaveBeenCalled();
121
+ expect(setStringIfNotExists).toHaveBeenCalledWith(
122
+ "slack-request-replay",
123
+ sign(TIMESTAMP, RAW_BODY).slice("v0=".length),
124
+ TIMESTAMP,
125
+ { expiresInSeconds: 5 * 60 + 1 },
126
+ );
94
127
  });
95
128
 
96
129
  test("rejects a well-formed signature computed over a different body", async () => {
@@ -107,6 +140,212 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
107
140
  res,
108
141
  new BadDataException("Slack Signature Verification Failed."),
109
142
  );
143
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
144
+ });
145
+
146
+ test("accepts a request exactly five minutes old", async () => {
147
+ const timestamp: string = (NOW_IN_SECONDS - 5 * 60).toString();
148
+ const req: OneUptimeRequest = buildRequest(
149
+ sign(timestamp, RAW_BODY),
150
+ RAW_BODY,
151
+ timestamp,
152
+ );
153
+
154
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
155
+
156
+ expect(next).toHaveBeenCalled();
157
+ expect(sendErrorResponse).not.toHaveBeenCalled();
158
+ });
159
+
160
+ test("accepts a request exactly five minutes in the future and covers the full replay window", async () => {
161
+ const timestamp: string = (NOW_IN_SECONDS + 5 * 60).toString();
162
+ const req: OneUptimeRequest = buildRequest(
163
+ sign(timestamp, RAW_BODY),
164
+ RAW_BODY,
165
+ timestamp,
166
+ );
167
+
168
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
169
+
170
+ expect(next).toHaveBeenCalled();
171
+ expect(setStringIfNotExists).toHaveBeenCalledWith(
172
+ "slack-request-replay",
173
+ sign(timestamp, RAW_BODY).slice("v0=".length),
174
+ timestamp,
175
+ { expiresInSeconds: 10 * 60 + 1 },
176
+ );
177
+ });
178
+
179
+ test("rejects a correctly signed request older than five minutes", async () => {
180
+ const timestamp: string = (NOW_IN_SECONDS - 5 * 60 - 1).toString();
181
+ const req: OneUptimeRequest = buildRequest(
182
+ sign(timestamp, RAW_BODY),
183
+ RAW_BODY,
184
+ timestamp,
185
+ );
186
+
187
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
188
+
189
+ expect(next).not.toHaveBeenCalled();
190
+ expect(sendErrorResponse).toHaveBeenCalledWith(
191
+ req,
192
+ res,
193
+ new BadDataException("Slack Signature Verification Failed."),
194
+ );
195
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
196
+ });
197
+
198
+ test("rejects a correctly signed request more than five minutes in the future", async () => {
199
+ const timestamp: string = (NOW_IN_SECONDS + 5 * 60 + 1).toString();
200
+ const req: OneUptimeRequest = buildRequest(
201
+ sign(timestamp, RAW_BODY),
202
+ RAW_BODY,
203
+ timestamp,
204
+ );
205
+
206
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
207
+
208
+ expect(next).not.toHaveBeenCalled();
209
+ expect(sendErrorResponse).toHaveBeenCalledWith(
210
+ req,
211
+ res,
212
+ new BadDataException("Slack Signature Verification Failed."),
213
+ );
214
+ });
215
+
216
+ test("acknowledges a duplicate without invoking the Slack handler", async () => {
217
+ setStringIfNotExists.mockResolvedValue(false);
218
+ const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
219
+
220
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
221
+
222
+ expect(next).not.toHaveBeenCalled();
223
+ expect(sendErrorResponse).not.toHaveBeenCalled();
224
+ expect(sendTextResponse).toHaveBeenCalledWith(req, res, "");
225
+ });
226
+
227
+ test("uses the canonical digest so signature casing cannot bypass replay detection", async () => {
228
+ const lowercaseSignature: string = sign(TIMESTAMP, RAW_BODY);
229
+ const uppercaseSignature: string = `v0=${lowercaseSignature
230
+ .slice("v0=".length)
231
+ .toUpperCase()}`;
232
+ const firstRequest: OneUptimeRequest = buildRequest(lowercaseSignature);
233
+ const replayRequest: OneUptimeRequest = buildRequest(uppercaseSignature);
234
+
235
+ setStringIfNotExists
236
+ .mockResolvedValueOnce(true)
237
+ .mockResolvedValueOnce(false);
238
+
239
+ await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
240
+ await SlackAuthorization.isAuthorizedSlackRequest(replayRequest, res, next);
241
+
242
+ const canonicalDigest: string = lowercaseSignature.slice("v0=".length);
243
+ expect(setStringIfNotExists).toHaveBeenNthCalledWith(
244
+ 1,
245
+ "slack-request-replay",
246
+ canonicalDigest,
247
+ TIMESTAMP,
248
+ { expiresInSeconds: 5 * 60 + 1 },
249
+ );
250
+ expect(setStringIfNotExists).toHaveBeenNthCalledWith(
251
+ 2,
252
+ "slack-request-replay",
253
+ canonicalDigest,
254
+ TIMESTAMP,
255
+ { expiresInSeconds: 5 * 60 + 1 },
256
+ );
257
+ expect(next).toHaveBeenCalledTimes(1);
258
+ expect(sendTextResponse).toHaveBeenCalledWith(replayRequest, res, "");
259
+ });
260
+
261
+ test("allows repeated URL verification requests to reach their idempotent handler", async () => {
262
+ const signature: string = sign(TIMESTAMP, RAW_BODY);
263
+ const firstRequest: OneUptimeRequest = buildRequest(signature);
264
+ const secondRequest: OneUptimeRequest = buildRequest(signature);
265
+
266
+ Object.assign(firstRequest, {
267
+ route: { path: "/slack/events" },
268
+ body: { type: "url_verification" },
269
+ });
270
+ Object.assign(secondRequest, {
271
+ route: { path: "/slack/events" },
272
+ body: { type: "url_verification" },
273
+ });
274
+
275
+ await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
276
+ await SlackAuthorization.isAuthorizedSlackRequest(secondRequest, res, next);
277
+
278
+ expect(next).toHaveBeenCalledTimes(2);
279
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
280
+ });
281
+
282
+ test("allows repeated options loads to reach their idempotent handler", async () => {
283
+ const signature: string = sign(TIMESTAMP, RAW_BODY);
284
+ const firstRequest: OneUptimeRequest = buildRequest(signature);
285
+ const secondRequest: OneUptimeRequest = buildRequest(signature);
286
+
287
+ Object.assign(firstRequest, {
288
+ route: { path: "/slack/options-load" },
289
+ });
290
+ Object.assign(secondRequest, {
291
+ route: { path: "/slack/options-load" },
292
+ });
293
+
294
+ await SlackAuthorization.isAuthorizedSlackRequest(firstRequest, res, next);
295
+ await SlackAuthorization.isAuthorizedSlackRequest(secondRequest, res, next);
296
+
297
+ expect(next).toHaveBeenCalledTimes(2);
298
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
299
+ });
300
+
301
+ test("fails closed when distributed replay protection is unavailable", async () => {
302
+ setStringIfNotExists.mockRejectedValue(new Error("cache unavailable"));
303
+ const req: OneUptimeRequest = buildRequest(sign(TIMESTAMP, RAW_BODY));
304
+
305
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
306
+
307
+ expect(next).not.toHaveBeenCalled();
308
+ expect(sendErrorResponse).toHaveBeenCalledWith(
309
+ req,
310
+ res,
311
+ new ServiceUnavailableException(
312
+ "Slack request verification is temporarily unavailable.",
313
+ ),
314
+ );
315
+ });
316
+
317
+ const malformedTimestamps: Array<[string, string | null]> = [
318
+ ["absent", null],
319
+ ["empty", ""],
320
+ ["non-numeric", "not-a-timestamp"],
321
+ ["partially numeric", `${TIMESTAMP}abc`],
322
+ ["fractional", `${TIMESTAMP}.5`],
323
+ ["negative", "-1"],
324
+ ["outside the safe integer range", "999999999999999999999"],
325
+ ];
326
+
327
+ describe("rejects a malformed timestamp", () => {
328
+ test.each(malformedTimestamps)(
329
+ "%s",
330
+ async (_label: string, timestamp: string | null) => {
331
+ const signedTimestamp: string = timestamp || TIMESTAMP;
332
+ const req: OneUptimeRequest = buildRequest(
333
+ sign(signedTimestamp, RAW_BODY),
334
+ RAW_BODY,
335
+ timestamp,
336
+ );
337
+
338
+ await SlackAuthorization.isAuthorizedSlackRequest(req, res, next);
339
+
340
+ expect(next).not.toHaveBeenCalled();
341
+ expect(sendErrorResponse).toHaveBeenCalledWith(
342
+ req,
343
+ res,
344
+ new BadDataException("Slack Signature Verification Failed."),
345
+ );
346
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
347
+ },
348
+ );
110
349
  });
111
350
 
112
351
  /*
@@ -143,7 +382,25 @@ describe("SlackAuthorization.isAuthorizedSlackRequest", () => {
143
382
  res,
144
383
  new BadDataException("Slack Signature Verification Failed."),
145
384
  );
385
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
146
386
  },
147
387
  );
148
388
  });
389
+
390
+ test("normalizes an array signature header without throwing", async () => {
391
+ const req: OneUptimeRequest = buildRequest(undefined);
392
+ req.headers["x-slack-signature"] = ["not-a-signature"];
393
+
394
+ await expect(
395
+ SlackAuthorization.isAuthorizedSlackRequest(req, res, next),
396
+ ).resolves.toBeUndefined();
397
+
398
+ expect(next).not.toHaveBeenCalled();
399
+ expect(sendErrorResponse).toHaveBeenCalledWith(
400
+ req,
401
+ res,
402
+ new BadDataException("Slack Signature Verification Failed."),
403
+ );
404
+ expect(setStringIfNotExists).not.toHaveBeenCalled();
405
+ });
149
406
  });
@@ -19,6 +19,7 @@ import LlmCostBudgetService from "../../../../Server/Services/LlmCostBudgetServi
19
19
  import MetricService from "../../../../Server/Services/MetricService";
20
20
  import SpanService from "../../../../Server/Services/SpanService";
21
21
  import TelemetryUtil from "../../../../Server/Utils/Telemetry/Telemetry";
22
+ import { LlmMicroUsdCostMetricNames } from "../../../../Types/Telemetry/LlmMetricConventions";
22
23
  import { LlmMetricScope } from "../../../../Utils/Telemetry/LlmMetricQuery";
23
24
  /*
24
25
  * `jest` deliberately comes from the global scope, not @jest/globals — the
@@ -84,6 +85,34 @@ const mockedMetricService: {
84
85
  aggregateBy: MockedFn;
85
86
  };
86
87
 
88
+ /*
89
+ * Metric-sourced cost is TWO aggregates, not one: the USD-denominated metric
90
+ * names and the micro-USD ones (the Codex CLI reports millionths of a dollar)
91
+ * are queried separately and scaled before being added, because a single Sum
92
+ * over both units cannot be un-mixed afterwards. A plain mockResolvedValue
93
+ * answers BOTH queries with the same payload, which would feed the same
94
+ * figure in twice — once at 1e-6 — and quietly skew every expectation here.
95
+ *
96
+ * So the mock dispatches on the metric names the query actually selects: the
97
+ * rows are the project's USD spend, and the micro-USD query correctly finds
98
+ * nothing.
99
+ */
100
+ function mockUsdMetricRows(rows: Array<JSONObject>): void {
101
+ mockedMetricService.aggregateBy.mockImplementation(
102
+ (aggregate: { query: Record<string, unknown> }): Promise<JSONObject> => {
103
+ const names: Array<string> = ((
104
+ aggregate?.query?.["name"] as { values?: Array<string> } | undefined
105
+ )?.values || []) as Array<string>;
106
+
107
+ const isMicroUsd: boolean = names.some((name: string) => {
108
+ return LlmMicroUsdCostMetricNames.includes(name);
109
+ });
110
+
111
+ return Promise.resolve({ data: isMicroUsd ? [] : rows });
112
+ },
113
+ );
114
+ }
115
+
87
116
  const mockedBudgetService: {
88
117
  findBy: MockedFn;
89
118
  updateOneById: MockedFn;
@@ -596,9 +625,7 @@ describe("LlmCostBudgetEvaluator.resolveSpend — source selection", () => {
596
625
  }
597
626
 
598
627
  function mockMetricSpend(value: number): void {
599
- mockedMetricService.aggregateBy.mockResolvedValue({
600
- data: [{ timestamp: NOW, value: value }],
601
- } as never);
628
+ mockUsdMetricRows([{ timestamp: NOW, value: value }]);
602
629
  }
603
630
 
604
631
  test("uses spans when the span stream reported spend", async () => {
@@ -730,12 +757,10 @@ describe("LlmCostBudgetEvaluator.resolveSpend — source selection", () => {
730
757
  });
731
758
 
732
759
  test("sums multiple metric buckets", async () => {
733
- mockedMetricService.aggregateBy.mockResolvedValue({
734
- data: [
735
- { timestamp: NOW, value: 1.5 },
736
- { timestamp: NOW, value: 2.5 },
737
- ],
738
- } as never);
760
+ mockUsdMetricRows([
761
+ { timestamp: NOW, value: 1.5 },
762
+ { timestamp: NOW, value: 2.5 },
763
+ ]);
739
764
 
740
765
  await expect(
741
766
  LlmCostBudgetEvaluator.resolveSpend({
@@ -781,9 +806,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
781
806
  });
782
807
 
783
808
  test("a metrics-only project gets its spend stamped on the budget row", async () => {
784
- mockedMetricService.aggregateBy.mockResolvedValue({
785
- data: [{ timestamp: NOW, value: 60 }],
786
- } as never);
809
+ mockUsdMetricRows([{ timestamp: NOW, value: 60 }]);
787
810
 
788
811
  const budget: LlmCostBudget = makeBudget({ dailyBudgetInUSD: 100 });
789
812
 
@@ -798,9 +821,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
798
821
  });
799
822
 
800
823
  test("a metrics-only project publishes the same budget series as a span-backed one", async () => {
801
- mockedMetricService.aggregateBy.mockResolvedValue({
802
- data: [{ timestamp: NOW, value: 80 }],
803
- } as never);
824
+ mockUsdMetricRows([{ timestamp: NOW, value: 80 }]);
804
825
 
805
826
  await LlmCostBudgetEvaluator.evaluateBudget({
806
827
  budget: makeBudget({ dailyBudgetInUSD: 100 }),
@@ -823,9 +844,7 @@ describe("LlmCostBudgetEvaluator.evaluateBudget — metric-sourced spend", () =>
823
844
  * have to learn about the source.
824
845
  */
825
846
  test("the published series carries no source-specific attribute", async () => {
826
- mockedMetricService.aggregateBy.mockResolvedValue({
827
- data: [{ timestamp: NOW, value: 5 }],
828
- } as never);
847
+ mockUsdMetricRows([{ timestamp: NOW, value: 5 }]);
829
848
 
830
849
  const budget: LlmCostBudget = makeBudget();
831
850