@desplega.ai/agent-swarm 1.104.0 → 1.105.0

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 (59) hide show
  1. package/README.md +1 -0
  2. package/dist/{actions-2expzt47.js → actions-45g8tvc8.js} +6 -6
  3. package/dist/{app-dr8hwx7z.js → app-18x5k1cj.js} +3 -3
  4. package/dist/{assistant-9rhgsw7v.js → assistant-ka281qe8.js} +9 -9
  5. package/dist/{boot-reembed-9kt97g1z.js → boot-reembed-f38vbxw8.js} +3 -3
  6. package/dist/{boot-reembed-3q1aywnz.js → boot-reembed-rp2gba79.js} +4 -4
  7. package/dist/{boot-scrub-logs-vskq108e.js → boot-scrub-logs-nsnc9f1a.js} +2 -2
  8. package/dist/{cli-v1syqkcb.js → cli-0e24r2b6.js} +2 -2
  9. package/dist/{cli-nzc0h54v.js → cli-0n4xxmy0.js} +1 -1
  10. package/dist/{cli-e7bwjntj.js → cli-0s37sw3k.js} +1 -1
  11. package/dist/{cli-23zk7xqy.js → cli-2hg6bj31.js} +4 -4
  12. package/dist/{cli-q5tshxd6.js → cli-4df7btj0.js} +1 -1
  13. package/dist/{cli-p4cj4vhc.js → cli-634jy7sa.js} +1 -1
  14. package/dist/{cli-7v4y3hyd.js → cli-7esh9fda.js} +1 -1
  15. package/dist/{cli-s42f54ja.js → cli-87fj1jk4.js} +1 -1
  16. package/dist/{cli-w1nfskkg.js → cli-8y7y1vmp.js} +2 -2
  17. package/dist/{cli-2hap19dv.js → cli-ba47wf2q.js} +3 -3
  18. package/dist/{cli-gj2n2ec1.js → cli-c4h8m0m3.js} +1 -1
  19. package/dist/{cli-5pf0b84v.js → cli-dd6tegmp.js} +2 -2
  20. package/dist/{cli-yjt8a36k.js → cli-ehcjr5vs.js} +3 -1
  21. package/dist/{cli-zghef0em.js → cli-gq7gvvrn.js} +1 -1
  22. package/dist/{cli-zaxksbgj.js → cli-gzmv1371.js} +5 -5
  23. package/dist/{cli-qvzgncd8.js → cli-sdf2qwkt.js} +26 -19
  24. package/dist/{cli-t9v14cnd.js → cli-sm9yseh1.js} +1 -1
  25. package/dist/{cli-54h5daha.js → cli-v2fb1fxf.js} +6 -6
  26. package/dist/{cli-d4z1csms.js → cli-vpwffend.js} +3 -3
  27. package/dist/{cli-0jw6j755.js → cli-wztagmpk.js} +1 -1
  28. package/dist/{cli-2g9jf9ek.js → cli-yd29d4n4.js} +1 -1
  29. package/dist/{cli-yvektv79.js → cli-yfyqsqzj.js} +1 -1
  30. package/dist/{cli-y9p3e94h.js → cli-yzr0qxx1.js} +3 -3
  31. package/dist/{cli-etvgz5x8.js → cli-zmta7t3y.js} +4 -4
  32. package/dist/cli.js +10 -8
  33. package/dist/{commands-wfrkef57.js → commands-tn8zj0an.js} +2 -2
  34. package/dist/{db-c42pxt7h.js → db-gtqymk7r.js} +2 -2
  35. package/dist/{handlers-n36zkfvx.js → handlers-5xdqk40d.js} +9 -9
  36. package/dist/{hook-gw3vhv6s.js → hook-gj6j6zwj.js} +1 -1
  37. package/dist/{http-59ygpn2h.js → http-4pceba9h.js} +144 -33
  38. package/dist/{index-xf6fp4y1.js → index-0rfp6wh2.js} +5 -5
  39. package/dist/{index-xz8vaa5c.js → index-as4evz9g.js} +10 -10
  40. package/dist/{index-881c1baf.js → index-paaw4xt5.js} +8 -8
  41. package/dist/{index-jca7jdpt.js → index-wf0q4k03.js} +6 -6
  42. package/dist/{keepalive-mj4302vw.js → keepalive-akbzjt41.js} +4 -4
  43. package/dist/{lead-qm0xjvkv.js → lead-g8v85nf4.js} +18 -18
  44. package/dist/{maintenance-xppxbe4t.js → maintenance-9yrzmrh8.js} +4 -4
  45. package/dist/{onboard-ejx6yxh8.js → onboard-v5bx0kpy.js} +2 -2
  46. package/dist/{otel-impl-zs5exkpk.js → otel-impl-530442da.js} +4510 -4465
  47. package/dist/{pricing-refresh-nqyf8bkb.js → pricing-refresh-dt289v11.js} +4 -4
  48. package/dist/{seed-pricing-w3x550cj.js → seed-pricing-183er9b3.js} +3 -3
  49. package/dist/{setup-7729ktfj.js → setup-tjgsvznx.js} +2 -2
  50. package/dist/{worker-4ttemwhw.js → worker-nxcxk5pb.js} +18 -18
  51. package/openapi.json +1 -1
  52. package/package.json +3 -1
  53. package/src/http/pages-public.ts +123 -2
  54. package/src/http/session-data.ts +16 -0
  55. package/src/otel-impl.ts +73 -0
  56. package/src/otel.ts +12 -0
  57. package/src/tests/otel-impl-secret-scrubbing.test.ts +101 -3
  58. package/src/tests/otel-session-cost-metrics.test.ts +281 -0
  59. package/src/tests/pages-authed-mode.test.ts +50 -0
@@ -2,13 +2,13 @@ import {
2
2
  PRICING_REFRESH_INTERVAL_MS,
3
3
  refreshPricingFromModelsDev,
4
4
  startPricingRefreshLoop
5
- } from "./cli-w1nfskkg.js";
6
- import"./cli-7v4y3hyd.js";
7
- import"./cli-0jw6j755.js";
5
+ } from "./cli-8y7y1vmp.js";
6
+ import"./cli-7esh9fda.js";
7
+ import"./cli-wztagmpk.js";
8
8
  import"./cli-z2zcxes1.js";
9
9
  import"./cli-q21d49ac.js";
10
10
  import"./cli-anrj584m.js";
11
- import"./cli-yjt8a36k.js";
11
+ import"./cli-ehcjr5vs.js";
12
12
  import"./cli-3pp1362w.js";
13
13
  import"./cli-p9swy5t3.js";
14
14
  export {
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  buildModelsDevSeedRows,
3
3
  seedPricingFromModelsDev
4
- } from "./cli-7v4y3hyd.js";
5
- import"./cli-0jw6j755.js";
4
+ } from "./cli-7esh9fda.js";
5
+ import"./cli-wztagmpk.js";
6
6
  import"./cli-z2zcxes1.js";
7
7
  import"./cli-q21d49ac.js";
8
8
  import"./cli-anrj584m.js";
9
- import"./cli-yjt8a36k.js";
9
+ import"./cli-ehcjr5vs.js";
10
10
  import"./cli-3pp1362w.js";
11
11
  import"./cli-p9swy5t3.js";
12
12
  export {
@@ -4,14 +4,14 @@ import {
4
4
  createDefaultMcpJson,
5
5
  createDefaultSettingsLocal,
6
6
  createHooksConfig
7
- } from "./cli-q5tshxd6.js";
7
+ } from "./cli-4df7btj0.js";
8
8
  import {
9
9
  require_jsx_dev_runtime
10
10
  } from "./cli-6n1nnzm7.js";
11
11
  import {
12
12
  getApiKey
13
13
  } from "./cli-f14fvzag.js";
14
- import"./cli-yjt8a36k.js";
14
+ import"./cli-ehcjr5vs.js";
15
15
  import {
16
16
  Box_default,
17
17
  Spinner,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runAgent
3
- } from "./cli-23zk7xqy.js";
3
+ } from "./cli-2hg6bj31.js";
4
4
  import"./cli-khe4x17a.js";
5
5
  import"./cli-p03mawph.js";
6
6
  import"./cli-4wmnp8q3.js";
@@ -15,7 +15,7 @@ import"./cli-d6n0thxw.js";
15
15
  import"./cli-hx1dcevq.js";
16
16
  import {
17
17
  getEnabledCapabilities
18
- } from "./cli-qvzgncd8.js";
18
+ } from "./cli-sdf2qwkt.js";
19
19
  import"./cli-09rxrad0.js";
20
20
  import"./cli-dcfyyh3t.js";
21
21
  import"./cli-gz9sbyk3.js";
@@ -24,32 +24,32 @@ import"./cli-psrqtw2m.js";
24
24
  import"./cli-tng3131n.js";
25
25
  import"./cli-zm7njwzd.js";
26
26
  import"./cli-0k9dc6ks.js";
27
- import"./cli-2g9jf9ek.js";
28
- import"./cli-w1nfskkg.js";
29
- import"./cli-7v4y3hyd.js";
30
- import"./cli-s42f54ja.js";
31
- import"./cli-54h5daha.js";
27
+ import"./cli-yd29d4n4.js";
28
+ import"./cli-8y7y1vmp.js";
29
+ import"./cli-7esh9fda.js";
30
+ import"./cli-87fj1jk4.js";
31
+ import"./cli-v2fb1fxf.js";
32
32
  import"./cli-f14fvzag.js";
33
- import"./cli-v1syqkcb.js";
34
- import"./cli-5pf0b84v.js";
35
- import"./cli-p4cj4vhc.js";
33
+ import"./cli-0e24r2b6.js";
34
+ import"./cli-dd6tegmp.js";
35
+ import"./cli-634jy7sa.js";
36
36
  import"./cli-3j01d33e.js";
37
37
  import"./cli-fad8m16k.js";
38
- import"./cli-y9p3e94h.js";
39
- import"./cli-zghef0em.js";
40
- import"./cli-nzc0h54v.js";
41
- import"./cli-zaxksbgj.js";
38
+ import"./cli-yzr0qxx1.js";
39
+ import"./cli-gq7gvvrn.js";
40
+ import"./cli-0n4xxmy0.js";
41
+ import"./cli-gzmv1371.js";
42
42
  import"./cli-fete3y1e.js";
43
43
  import"./cli-b0p7rfnd.js";
44
- import"./cli-yvektv79.js";
44
+ import"./cli-yfyqsqzj.js";
45
45
  import"./cli-z92pkwzn.js";
46
46
  import"./cli-dh55d5fg.js";
47
- import"./cli-e7bwjntj.js";
48
- import"./cli-0jw6j755.js";
47
+ import"./cli-0s37sw3k.js";
48
+ import"./cli-wztagmpk.js";
49
49
  import"./cli-z2zcxes1.js";
50
50
  import"./cli-q21d49ac.js";
51
51
  import"./cli-anrj584m.js";
52
- import"./cli-yjt8a36k.js";
52
+ import"./cli-ehcjr5vs.js";
53
53
  import"./cli-3pp1362w.js";
54
54
  import"./cli-e15jhafb.js";
55
55
  import"./cli-9aryf9tv.js";
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.104.0",
5
+ "version": "1.105.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.104.0",
3
+ "version": "1.105.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -124,8 +124,10 @@
124
124
  "@opencode-ai/sdk": "^1.17.11",
125
125
  "@openfort/openfort-node": "^0.9.1",
126
126
  "@opentelemetry/api": "^1.9.1",
127
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
127
128
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
128
129
  "@opentelemetry/resources": "^2.7.1",
130
+ "@opentelemetry/sdk-metrics": "^2.7.1",
129
131
  "@opentelemetry/sdk-node": "^0.218.0",
130
132
  "@opentelemetry/semantic-conventions": "^1.41.1",
131
133
  "@slack/bolt": "^4.6.0",
@@ -150,6 +150,106 @@ function injectBrowserSdk(html: string): string {
150
150
  return injection + html;
151
151
  }
152
152
 
153
+ /**
154
+ * Self-print snippet appended to the served document when the export button
155
+ * opens `/p/:id?print=1`. The page prints ITSELF in the user's own browser —
156
+ * no server-side headless browser. We wait for `load` + webfonts + a short
157
+ * tick so the Tailwind Play CDN JIT pass and font swap settle before the
158
+ * print dialog opens, otherwise the PDF can capture an unstyled flash.
159
+ */
160
+ const PRINT_AUTOTRIGGER_SCRIPT = `<script>
161
+ (function () {
162
+ function print() { setTimeout(function () { window.print(); }, 400); }
163
+ window.addEventListener("load", function () {
164
+ var fonts = document.fonts;
165
+ if (fonts && fonts.ready && typeof fonts.ready.then === "function") {
166
+ fonts.ready.then(print, print);
167
+ } else {
168
+ print();
169
+ }
170
+ });
171
+ })();
172
+ </script>`;
173
+
174
+ function escapeHtml(value: string): string {
175
+ return value.replace(/[&<>"']/g, (char) => {
176
+ switch (char) {
177
+ case "&":
178
+ return "&amp;";
179
+ case "<":
180
+ return "&lt;";
181
+ case ">":
182
+ return "&gt;";
183
+ case '"':
184
+ return "&quot;";
185
+ default:
186
+ return "&#39;";
187
+ }
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Standalone, self-contained HTML for printing a JSON page. JSON pages have no
193
+ * agent-authored HTML body (the SPA renders the tree), so `/p/:id` normally
194
+ * 302s to the SPA. For `?print=1` we serve this minimal light-themed document
195
+ * — pretty-printed JSON in a wrapping `<pre>` — and let the browser print it.
196
+ */
197
+ function printableJsonPageHtml(page: Page): string {
198
+ let body = page.body;
199
+ try {
200
+ body = JSON.stringify(JSON.parse(page.body), null, 2);
201
+ } catch {
202
+ // Preserve the original body when the stored payload is not valid JSON.
203
+ }
204
+ const description = page.description
205
+ ? `<p class="description">${escapeHtml(page.description)}</p>`
206
+ : "";
207
+ return `<!doctype html>
208
+ <html>
209
+ <head>
210
+ <meta charset="utf-8">
211
+ <title>${escapeHtml(page.title)}</title>
212
+ <style>
213
+ body {
214
+ color: #111827;
215
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
216
+ line-height: 1.5;
217
+ margin: 0;
218
+ padding: 24px;
219
+ }
220
+ h1 {
221
+ font-size: 24px;
222
+ line-height: 1.2;
223
+ margin: 0 0 8px;
224
+ }
225
+ .description {
226
+ color: #4b5563;
227
+ margin: 0 0 20px;
228
+ }
229
+ pre {
230
+ background: #f8fafc;
231
+ border: 1px solid #e5e7eb;
232
+ border-radius: 6px;
233
+ color: #111827;
234
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
235
+ font-size: 11px;
236
+ line-height: 1.45;
237
+ margin: 0;
238
+ overflow-wrap: anywhere;
239
+ padding: 16px;
240
+ white-space: pre-wrap;
241
+ }
242
+ </style>
243
+ </head>
244
+ <body>
245
+ <h1>${escapeHtml(page.title)}</h1>
246
+ ${description}
247
+ <pre>${escapeHtml(body)}</pre>
248
+ ${PRINT_AUTOTRIGGER_SCRIPT}
249
+ </body>
250
+ </html>`;
251
+ }
252
+
153
253
  /**
154
254
  * Trim `.json` off the last path segment, returning the bare id. Returns
155
255
  * `null` if the segment doesn't end in `.json` (caller should fall through
@@ -465,8 +565,28 @@ export async function handlePagesPublic(
465
565
  return true;
466
566
  }
467
567
 
568
+ // `?print=1` — the SPA's "Export PDF" button opens the page in a new tab
569
+ // with this flag so the page self-prints in the user's own browser (no
570
+ // server-side headless browser). HTML pages get the auto-print snippet
571
+ // appended; JSON pages — which normally 302 to the SPA — are served as a
572
+ // standalone printable document instead.
573
+ const wantsPrint = queryParams.get("print") === "1";
574
+
468
575
  // `/p/:id` — render either HTML directly or 302→SPA for JSON.
469
576
  if (page.contentType === "application/json") {
577
+ if (wantsPrint) {
578
+ const headers: Record<string, string> = {
579
+ "Content-Type": "text/html; charset=utf-8",
580
+ "Cache-Control": "no-store",
581
+ "Content-Security-Policy": buildCsp(),
582
+ "X-Content-Type-Options": "nosniff",
583
+ };
584
+ if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
585
+ res.writeHead(200, headers);
586
+ res.end(printableJsonPageHtml(page));
587
+ bumpViewCount(page.id);
588
+ return true;
589
+ }
470
590
  const headers: Record<string, string> = { Location: `${getAppBaseUrl()}/pages/${page.id}` };
471
591
  if (inlineSetCookie) headers["Set-Cookie"] = inlineSetCookie;
472
592
  res.writeHead(302, headers);
@@ -477,8 +597,9 @@ export async function handlePagesPublic(
477
597
  return true;
478
598
  }
479
599
 
480
- // text/html — inject SDK + serve.
481
- const html = injectBrowserSdk(page.body);
600
+ // text/html — inject SDK + serve. Append the self-print snippet when the
601
+ // export button requested it.
602
+ const html = injectBrowserSdk(page.body) + (wantsPrint ? PRINT_AUTOTRIGGER_SCRIPT : "");
482
603
  const headers: Record<string, string> = {
483
604
  "Content-Type": "text/html; charset=utf-8",
484
605
  "Cache-Control": "no-store",
@@ -14,6 +14,7 @@ import {
14
14
  getTaskById,
15
15
  } from "../be/db";
16
16
  import { normalizeModelKey } from "../be/pricing-normalize";
17
+ import { recordSessionCost } from "../otel";
17
18
  import { incrementServerSessionsProcessed } from "../server-runtime-counters";
18
19
  import type { SessionCost, SessionCostSource } from "../types";
19
20
  import { route } from "./route-def";
@@ -291,6 +292,21 @@ export async function handleSessionData(
291
292
  isError: parsed.body.isError ?? false,
292
293
  costSource,
293
294
  });
295
+ recordSessionCost({
296
+ totalCostUsd,
297
+ harness: parsed.body.provider ?? "unknown",
298
+ model,
299
+ costSource,
300
+ isError: parsed.body.isError ?? false,
301
+ tokens: {
302
+ input: inputTokens,
303
+ output: outputTokens,
304
+ cacheRead: cachedInputTokens,
305
+ cacheWrite: parsed.body.cacheWriteTokens ?? 0,
306
+ reasoning: parsed.body.reasoningOutputTokens ?? 0,
307
+ thinking: parsed.body.thinkingTokens ?? 0,
308
+ },
309
+ });
294
310
  incrementServerSessionsProcessed();
295
311
  json(res, { success: true, cost }, 201);
296
312
  } catch (error) {
package/src/otel-impl.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import {
2
+ type Counter,
2
3
  context,
4
+ metrics,
3
5
  propagation,
4
6
  ROOT_CONTEXT,
5
7
  type Span,
6
8
  SpanStatusCode,
7
9
  trace,
8
10
  } from "@opentelemetry/api";
11
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
9
12
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
10
13
  import {
11
14
  hostDetector,
@@ -13,6 +16,7 @@ import {
13
16
  processDetector,
14
17
  resourceFromAttributes,
15
18
  } from "@opentelemetry/resources";
19
+ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
16
20
  import { NodeSDK } from "@opentelemetry/sdk-node";
17
21
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
18
22
  import pkg from "../package.json";
@@ -23,9 +27,12 @@ type AttributeValue = string | number | boolean | string[] | number[] | boolean[
23
27
  type Attributes = Record<string, AttributeValue | undefined>;
24
28
 
25
29
  const TRACER_NAME = "agent-swarm";
30
+ const METER_NAME = "agent-swarm";
26
31
  const RAW_SPAN = Symbol("agent-swarm.raw-span");
27
32
 
28
33
  let sdk: NodeSDK | undefined;
34
+ let costCounter: Counter | undefined;
35
+ let tokenCounter: Counter | undefined;
29
36
 
30
37
  function decodeResourceAttributeValue(value: string): string {
31
38
  try {
@@ -158,6 +165,14 @@ export async function boot(serviceRole: string): Promise<void> {
158
165
  // parsed `OTEL_RESOURCE_ATTRIBUTES`) then stays authoritative.
159
166
  resourceDetectors: [hostDetector, osDetector, processDetector],
160
167
  traceExporter: new OTLPTraceExporter(),
168
+ // Metrics: export on the same OTLP pipeline as traces so both signals share
169
+ // the same resource (service.name, agentswarm.service.role, etc.). Temporality
170
+ // is intentionally NOT hardcoded — operators should set
171
+ // OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta for Datadog.
172
+ metricReader: new PeriodicExportingMetricReader({
173
+ exporter: new OTLPMetricExporter(),
174
+ exportIntervalMillis: 60_000,
175
+ }),
161
176
  });
162
177
 
163
178
  sdk.start();
@@ -228,3 +243,61 @@ export function injectTraceContext(headers: Record<string, string>): Record<stri
228
243
  propagation.inject(context.active(), headers);
229
244
  return headers;
230
245
  }
246
+
247
+ export interface SessionCostMetric {
248
+ totalCostUsd: number;
249
+ harness: string;
250
+ model: string;
251
+ costSource: string;
252
+ isError: boolean;
253
+ tokens: {
254
+ input: number;
255
+ output: number;
256
+ cacheRead: number;
257
+ cacheWrite: number;
258
+ reasoning: number;
259
+ thinking: number;
260
+ };
261
+ }
262
+
263
+ function ensureInstruments(): void {
264
+ if (costCounter) return;
265
+ const meter = metrics.getMeter(METER_NAME);
266
+ costCounter = meter.createCounter("agentswarm.cost.usd", {
267
+ description: "USD cost per finalized cost record",
268
+ unit: "{usd}",
269
+ });
270
+ tokenCounter = meter.createCounter("agentswarm.tokens", {
271
+ description: "Tokens per finalized cost record",
272
+ unit: "{token}",
273
+ });
274
+ }
275
+
276
+ export function recordSessionCost(m: SessionCostMetric): void {
277
+ ensureInstruments();
278
+ // Scrub all free-form string attributes before they reach the OTLP exporter.
279
+ // `model` comes from the /api/session-costs request body and may contain
280
+ // arbitrary operator-supplied text; scrubbing prevents accidental secret egress.
281
+ const attrs = {
282
+ harness: scrubSecrets(m.harness || "unknown"),
283
+ model: scrubSecrets(m.model || "unknown"),
284
+ cost_source: scrubSecrets(m.costSource || "unknown"),
285
+ is_error: m.isError,
286
+ };
287
+ if (Number.isFinite(m.totalCostUsd) && m.totalCostUsd > 0) {
288
+ costCounter!.add(m.totalCostUsd, attrs);
289
+ }
290
+ for (const [token_type, n] of Object.entries(m.tokens)) {
291
+ if (Number.isFinite(n) && n > 0) {
292
+ tokenCounter!.add(n, { ...attrs, token_type });
293
+ }
294
+ }
295
+ }
296
+
297
+ export function _injectCountersForTests(
298
+ cost: Counter | undefined,
299
+ token: Counter | undefined,
300
+ ): void {
301
+ costCounter = cost;
302
+ tokenCounter = token;
303
+ }
package/src/otel.ts CHANGED
@@ -15,6 +15,10 @@ export type SwarmSpan = {
15
15
  end: () => void;
16
16
  };
17
17
 
18
+ // eslint-disable-next-line import/no-duplicates -- type-only import, no side-effects
19
+ import type { SessionCostMetric } from "./otel-impl";
20
+ export type { SessionCostMetric };
21
+
18
22
  const enabled = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
19
23
 
20
24
  const NOOP_SPAN: SwarmSpan = {
@@ -43,6 +47,7 @@ let realInjectTraceContext:
43
47
  | ((headers: Record<string, string>) => Record<string, string>)
44
48
  | undefined;
45
49
  let realShutdown: (() => Promise<void>) | undefined;
50
+ let realRecordSessionCost: ((m: SessionCostMetric) => void) | undefined;
46
51
 
47
52
  export function isOtelEnabled(): boolean {
48
53
  return enabled;
@@ -66,6 +71,7 @@ export async function initOtel(serviceRole = process.env.AGENT_ROLE || "api"): P
66
71
  realWithSpanContext = impl.withSpanContext;
67
72
  realInjectTraceContext = impl.injectTraceContext;
68
73
  realShutdown = impl.shutdown;
74
+ realRecordSessionCost = impl.recordSessionCost;
69
75
  console.log(`[OTel] enabled for ${impl.resolveServiceName(serviceRole)} (${serviceRole})`);
70
76
  } catch (error) {
71
77
  console.warn(`[OTel] disabled after initialization failure: ${error}`);
@@ -119,6 +125,11 @@ export async function shutdownOtel(): Promise<void> {
119
125
  await realShutdown();
120
126
  }
121
127
 
128
+ export function recordSessionCost(m: SessionCostMetric): void {
129
+ if (!enabled || !realRecordSessionCost) return;
130
+ realRecordSessionCost(m);
131
+ }
132
+
122
133
  export function _resetOtelForTests() {
123
134
  initialized = false;
124
135
  realWithSpan = undefined;
@@ -127,4 +138,5 @@ export function _resetOtelForTests() {
127
138
  realWithSpanContext = undefined;
128
139
  realInjectTraceContext = undefined;
129
140
  realShutdown = undefined;
141
+ realRecordSessionCost = undefined;
130
142
  }
@@ -1,9 +1,107 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { scrubOtelException, scrubOtelStatus } from "../otel-impl";
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { Counter } from "@opentelemetry/api";
3
+ import {
4
+ _injectCountersForTests,
5
+ recordSessionCost,
6
+ scrubOtelException,
7
+ scrubOtelStatus,
8
+ } from "../otel-impl";
3
9
 
4
10
  const SECRET = "ghp_1234567890abcdefghijklmnopqrstuv";
5
11
 
6
- describe("otel-impl secret scrubbing", () => {
12
+ // Fake counter that records every add() call so tests can assert on the args.
13
+ const addSpy = mock((..._args: unknown[]) => {});
14
+ const fakeCounter = { add: addSpy } as unknown as Counter;
15
+
16
+ describe("otel-impl metric attribute scrubbing", () => {
17
+ beforeEach(() => {
18
+ addSpy.mockClear();
19
+ _injectCountersForTests(fakeCounter, fakeCounter);
20
+ });
21
+
22
+ test("scrubs a token-like model value before Counter.add()", () => {
23
+ // Simulate a model field that accidentally contains a GitHub PAT.
24
+ // The token must not be preceded by a word char for the regex to match.
25
+ const secretModel = `model/${SECRET}`; // '/' is non-word → regex fires
26
+ recordSessionCost({
27
+ totalCostUsd: 0.01,
28
+ harness: "claude",
29
+ model: secretModel,
30
+ costSource: "harness",
31
+ isError: false,
32
+ tokens: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, thinking: 0 },
33
+ });
34
+
35
+ expect(addSpy).toHaveBeenCalled();
36
+ for (const call of addSpy.mock.calls) {
37
+ const attrs = call[1] as Record<string, unknown>;
38
+ expect(String(attrs.model)).not.toContain(SECRET);
39
+ expect(String(attrs.model)).toContain("[REDACTED:");
40
+ }
41
+ });
42
+
43
+ test("scrubs a token-like harness value before Counter.add()", () => {
44
+ recordSessionCost({
45
+ totalCostUsd: 0.01,
46
+ harness: `Bearer ${SECRET}`,
47
+ model: "claude-opus-4",
48
+ costSource: "pricing-table",
49
+ isError: false,
50
+ tokens: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, thinking: 0 },
51
+ });
52
+
53
+ expect(addSpy).toHaveBeenCalled();
54
+ for (const call of addSpy.mock.calls) {
55
+ const attrs = call[1] as Record<string, unknown>;
56
+ expect(String(attrs.harness)).not.toContain(SECRET);
57
+ }
58
+ });
59
+
60
+ test("zero totalCostUsd skips cost counter but still records tokens", () => {
61
+ recordSessionCost({
62
+ totalCostUsd: 0,
63
+ harness: "codex",
64
+ model: "gpt-4o",
65
+ costSource: "unpriced",
66
+ isError: false,
67
+ tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, reasoning: 0, thinking: 0 },
68
+ });
69
+
70
+ // Two tokenCounter.add() calls (input + output), zero costCounter.add()
71
+ expect(addSpy.mock.calls.length).toBe(2);
72
+ const tokenTypes = addSpy.mock.calls.map((c) => (c[1] as Record<string, unknown>).token_type);
73
+ expect(tokenTypes).toContain("input");
74
+ expect(tokenTypes).toContain("output");
75
+ });
76
+
77
+ test("all six token_type values are emitted when non-zero", () => {
78
+ recordSessionCost({
79
+ totalCostUsd: 0.1,
80
+ harness: "claude",
81
+ model: "claude-sonnet-4-6",
82
+ costSource: "pricing-table",
83
+ isError: false,
84
+ tokens: {
85
+ input: 100,
86
+ output: 50,
87
+ cacheRead: 10,
88
+ cacheWrite: 5,
89
+ reasoning: 20,
90
+ thinking: 15,
91
+ },
92
+ });
93
+
94
+ const tokenCalls = addSpy.mock.calls.filter(
95
+ (c) => (c[1] as Record<string, unknown>).token_type !== undefined,
96
+ );
97
+ const emittedTypes = tokenCalls.map((c) => (c[1] as Record<string, unknown>).token_type);
98
+ expect(emittedTypes.sort()).toEqual(
99
+ ["cacheRead", "cacheWrite", "input", "output", "reasoning", "thinking"].sort(),
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("otel-impl exception / status scrubbing", () => {
7
105
  test("scrubs Error messages and stacks before recording exceptions", () => {
8
106
  const error = new Error(`request failed with token ${SECRET}`);
9
107
  error.stack = `Error: request failed with token ${SECRET}\n at fake`;