@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.
- package/README.md +1 -0
- package/dist/{actions-2expzt47.js → actions-45g8tvc8.js} +6 -6
- package/dist/{app-dr8hwx7z.js → app-18x5k1cj.js} +3 -3
- package/dist/{assistant-9rhgsw7v.js → assistant-ka281qe8.js} +9 -9
- package/dist/{boot-reembed-9kt97g1z.js → boot-reembed-f38vbxw8.js} +3 -3
- package/dist/{boot-reembed-3q1aywnz.js → boot-reembed-rp2gba79.js} +4 -4
- package/dist/{boot-scrub-logs-vskq108e.js → boot-scrub-logs-nsnc9f1a.js} +2 -2
- package/dist/{cli-v1syqkcb.js → cli-0e24r2b6.js} +2 -2
- package/dist/{cli-nzc0h54v.js → cli-0n4xxmy0.js} +1 -1
- package/dist/{cli-e7bwjntj.js → cli-0s37sw3k.js} +1 -1
- package/dist/{cli-23zk7xqy.js → cli-2hg6bj31.js} +4 -4
- package/dist/{cli-q5tshxd6.js → cli-4df7btj0.js} +1 -1
- package/dist/{cli-p4cj4vhc.js → cli-634jy7sa.js} +1 -1
- package/dist/{cli-7v4y3hyd.js → cli-7esh9fda.js} +1 -1
- package/dist/{cli-s42f54ja.js → cli-87fj1jk4.js} +1 -1
- package/dist/{cli-w1nfskkg.js → cli-8y7y1vmp.js} +2 -2
- package/dist/{cli-2hap19dv.js → cli-ba47wf2q.js} +3 -3
- package/dist/{cli-gj2n2ec1.js → cli-c4h8m0m3.js} +1 -1
- package/dist/{cli-5pf0b84v.js → cli-dd6tegmp.js} +2 -2
- package/dist/{cli-yjt8a36k.js → cli-ehcjr5vs.js} +3 -1
- package/dist/{cli-zghef0em.js → cli-gq7gvvrn.js} +1 -1
- package/dist/{cli-zaxksbgj.js → cli-gzmv1371.js} +5 -5
- package/dist/{cli-qvzgncd8.js → cli-sdf2qwkt.js} +26 -19
- package/dist/{cli-t9v14cnd.js → cli-sm9yseh1.js} +1 -1
- package/dist/{cli-54h5daha.js → cli-v2fb1fxf.js} +6 -6
- package/dist/{cli-d4z1csms.js → cli-vpwffend.js} +3 -3
- package/dist/{cli-0jw6j755.js → cli-wztagmpk.js} +1 -1
- package/dist/{cli-2g9jf9ek.js → cli-yd29d4n4.js} +1 -1
- package/dist/{cli-yvektv79.js → cli-yfyqsqzj.js} +1 -1
- package/dist/{cli-y9p3e94h.js → cli-yzr0qxx1.js} +3 -3
- package/dist/{cli-etvgz5x8.js → cli-zmta7t3y.js} +4 -4
- package/dist/cli.js +10 -8
- package/dist/{commands-wfrkef57.js → commands-tn8zj0an.js} +2 -2
- package/dist/{db-c42pxt7h.js → db-gtqymk7r.js} +2 -2
- package/dist/{handlers-n36zkfvx.js → handlers-5xdqk40d.js} +9 -9
- package/dist/{hook-gw3vhv6s.js → hook-gj6j6zwj.js} +1 -1
- package/dist/{http-59ygpn2h.js → http-4pceba9h.js} +144 -33
- package/dist/{index-xf6fp4y1.js → index-0rfp6wh2.js} +5 -5
- package/dist/{index-xz8vaa5c.js → index-as4evz9g.js} +10 -10
- package/dist/{index-881c1baf.js → index-paaw4xt5.js} +8 -8
- package/dist/{index-jca7jdpt.js → index-wf0q4k03.js} +6 -6
- package/dist/{keepalive-mj4302vw.js → keepalive-akbzjt41.js} +4 -4
- package/dist/{lead-qm0xjvkv.js → lead-g8v85nf4.js} +18 -18
- package/dist/{maintenance-xppxbe4t.js → maintenance-9yrzmrh8.js} +4 -4
- package/dist/{onboard-ejx6yxh8.js → onboard-v5bx0kpy.js} +2 -2
- package/dist/{otel-impl-zs5exkpk.js → otel-impl-530442da.js} +4510 -4465
- package/dist/{pricing-refresh-nqyf8bkb.js → pricing-refresh-dt289v11.js} +4 -4
- package/dist/{seed-pricing-w3x550cj.js → seed-pricing-183er9b3.js} +3 -3
- package/dist/{setup-7729ktfj.js → setup-tjgsvznx.js} +2 -2
- package/dist/{worker-4ttemwhw.js → worker-nxcxk5pb.js} +18 -18
- package/openapi.json +1 -1
- package/package.json +3 -1
- package/src/http/pages-public.ts +123 -2
- package/src/http/session-data.ts +16 -0
- package/src/otel-impl.ts +73 -0
- package/src/otel.ts +12 -0
- package/src/tests/otel-impl-secret-scrubbing.test.ts +101 -3
- package/src/tests/otel-session-cost-metrics.test.ts +281 -0
- 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-
|
|
6
|
-
import"./cli-
|
|
7
|
-
import"./cli-
|
|
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-
|
|
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-
|
|
5
|
-
import"./cli-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
28
|
-
import"./cli-
|
|
29
|
-
import"./cli-
|
|
30
|
-
import"./cli-
|
|
31
|
-
import"./cli-
|
|
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-
|
|
34
|
-
import"./cli-
|
|
35
|
-
import"./cli-
|
|
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-
|
|
39
|
-
import"./cli-
|
|
40
|
-
import"./cli-
|
|
41
|
-
import"./cli-
|
|
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-
|
|
44
|
+
import"./cli-yfyqsqzj.js";
|
|
45
45
|
import"./cli-z92pkwzn.js";
|
|
46
46
|
import"./cli-dh55d5fg.js";
|
|
47
|
-
import"./cli-
|
|
48
|
-
import"./cli-
|
|
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-
|
|
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.
|
|
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.
|
|
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",
|
package/src/http/pages-public.ts
CHANGED
|
@@ -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 "&";
|
|
179
|
+
case "<":
|
|
180
|
+
return "<";
|
|
181
|
+
case ">":
|
|
182
|
+
return ">";
|
|
183
|
+
case '"':
|
|
184
|
+
return """;
|
|
185
|
+
default:
|
|
186
|
+
return "'";
|
|
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
|
-
|
|
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",
|
package/src/http/session-data.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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`;
|