@warlock.js/ai-panoptic 4.3.0 → 4.5.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/CHANGELOG.md +46 -30
- package/cjs/index.cjs +2636 -67
- package/cjs/index.cjs.map +1 -1
- package/esm/collector/collector.d.mts +20 -2
- package/esm/collector/collector.d.mts.map +1 -1
- package/esm/collector/collector.mjs +47 -7
- package/esm/collector/collector.mjs.map +1 -1
- package/esm/collector/content-capture.type.d.mts +52 -0
- package/esm/collector/content-capture.type.d.mts.map +1 -0
- package/esm/collector/extract-span-attributes.d.mts.map +1 -1
- package/esm/collector/extract-span-attributes.mjs +3 -0
- package/esm/collector/extract-span-attributes.mjs.map +1 -1
- package/esm/collector/index.d.mts +2 -1
- package/esm/collector/index.mjs +7 -0
- package/esm/collector/normalize-error.d.mts.map +1 -1
- package/esm/collector/normalize-error.mjs +6 -3
- package/esm/collector/normalize-error.mjs.map +1 -1
- package/esm/collector/report-to-span.d.mts +8 -1
- package/esm/collector/report-to-span.d.mts.map +1 -1
- package/esm/collector/report-to-span.mjs +87 -2
- package/esm/collector/report-to-span.mjs.map +1 -1
- package/esm/collector/report-to-trace.d.mts +10 -6
- package/esm/collector/report-to-trace.d.mts.map +1 -1
- package/esm/collector/report-to-trace.mjs +10 -7
- package/esm/collector/report-to-trace.mjs.map +1 -1
- package/esm/config/apply-panoptic-config.d.mts +27 -0
- package/esm/config/apply-panoptic-config.d.mts.map +1 -0
- package/esm/config/apply-panoptic-config.mjs +89 -0
- package/esm/config/apply-panoptic-config.mjs.map +1 -0
- package/esm/config/index.d.mts +2 -0
- package/esm/config/index.mjs +3 -0
- package/esm/config/panoptic-config.type.d.mts +84 -0
- package/esm/config/panoptic-config.type.d.mts.map +1 -0
- package/esm/contracts/trace.type.d.mts +31 -5
- package/esm/contracts/trace.type.d.mts.map +1 -1
- package/esm/dashboard/dashboard.d.mts +32 -0
- package/esm/dashboard/dashboard.d.mts.map +1 -0
- package/esm/dashboard/dashboard.mjs +132 -0
- package/esm/dashboard/dashboard.mjs.map +1 -0
- package/esm/dashboard/dashboard.type.d.mts +63 -0
- package/esm/dashboard/dashboard.type.d.mts.map +1 -0
- package/esm/dashboard/index.d.mts +3 -0
- package/esm/dashboard/parse-query.d.mts +1 -0
- package/esm/dashboard/parse-query.mjs +54 -0
- package/esm/dashboard/parse-query.mjs.map +1 -0
- package/esm/dashboard/serve.d.mts +1 -0
- package/esm/dashboard/serve.mjs +111 -0
- package/esm/dashboard/serve.mjs.map +1 -0
- package/esm/dashboard/trace-filter.d.mts +187 -0
- package/esm/dashboard/trace-filter.d.mts.map +1 -0
- package/esm/dashboard/trace-filter.mjs +270 -0
- package/esm/dashboard/trace-filter.mjs.map +1 -0
- package/esm/dashboard/ui.html.mjs +1330 -0
- package/esm/dashboard/ui.html.mjs.map +1 -0
- package/esm/dashboard/warlock-logo.mjs +13 -0
- package/esm/dashboard/warlock-logo.mjs.map +1 -0
- package/esm/exporters/console/console-exporter.d.mts +11 -3
- package/esm/exporters/console/console-exporter.d.mts.map +1 -1
- package/esm/exporters/console/console-exporter.mjs +31 -13
- package/esm/exporters/console/console-exporter.mjs.map +1 -1
- package/esm/exporters/console/console-exporter.type.d.mts +15 -0
- package/esm/exporters/console/console-exporter.type.d.mts.map +1 -1
- package/esm/exporters/console/format-span-io.d.mts +27 -0
- package/esm/exporters/console/format-span-io.d.mts.map +1 -0
- package/esm/exporters/console/format-span-io.mjs +52 -0
- package/esm/exporters/console/format-span-io.mjs.map +1 -0
- package/esm/exporters/console/index.d.mts +2 -1
- package/esm/exporters/file/file-exporter.mjs +5 -2
- package/esm/exporters/file/file-exporter.mjs.map +1 -1
- package/esm/exporters/index.d.mts +1 -0
- package/esm/exporters/index.mjs +1 -0
- package/esm/exporters/langfuse/langfuse-exporter.d.mts +10 -6
- package/esm/exporters/langfuse/langfuse-exporter.d.mts.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.mjs +80 -30
- package/esm/exporters/langfuse/langfuse-exporter.mjs.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.type.d.mts +6 -2
- package/esm/exporters/langfuse/langfuse-exporter.type.d.mts.map +1 -1
- package/esm/exporters/otel/otel-api.shim.type.d.mts +60 -0
- package/esm/exporters/otel/otel-api.shim.type.d.mts.map +1 -0
- package/esm/exporters/otel/otel-exporter.d.mts.map +1 -1
- package/esm/exporters/otel/otel-exporter.mjs +15 -0
- package/esm/exporters/otel/otel-exporter.mjs.map +1 -1
- package/esm/exporters/otel/otel-exporter.type.d.mts +7 -3
- package/esm/exporters/otel/otel-exporter.type.d.mts.map +1 -1
- package/esm/exporters/utils/gen-ai-attributes.d.mts +3 -1
- package/esm/exporters/utils/gen-ai-attributes.d.mts.map +1 -1
- package/esm/exporters/utils/gen-ai-attributes.mjs +5 -1
- package/esm/exporters/utils/gen-ai-attributes.mjs.map +1 -1
- package/esm/index.d.mts +11 -2
- package/esm/index.mjs +10 -1
- package/esm/panoptic/index.mjs +4 -0
- package/esm/panoptic/panoptic.d.mts.map +1 -1
- package/esm/panoptic/panoptic.mjs +9 -1
- package/esm/panoptic/panoptic.mjs.map +1 -1
- package/esm/panoptic/panoptic.type.d.mts +32 -0
- package/esm/panoptic/panoptic.type.d.mts.map +1 -1
- package/esm/register.mjs +12 -0
- package/esm/register.mjs.map +1 -0
- package/esm/store/cache-trace-store.d.mts +81 -0
- package/esm/store/cache-trace-store.d.mts.map +1 -0
- package/esm/store/cache-trace-store.mjs +290 -0
- package/esm/store/cache-trace-store.mjs.map +1 -0
- package/esm/store/in-memory-trace-store.mjs +9 -2
- package/esm/store/in-memory-trace-store.mjs.map +1 -1
- package/esm/store/index.d.mts +1 -0
- package/esm/store/index.mjs +1 -0
- package/llms-full.txt +351 -4
- package/llms.txt +4 -3
- package/package.json +6 -2
- package/skills/README.md +7 -3
- package/skills/export-traces/SKILL.md +33 -1
- package/skills/observe-with-panoptic/SKILL.md +49 -2
- package/skills/query-traces/SKILL.md +52 -1
- package/skills/use-local-dashboard/SKILL.md +213 -0
package/cjs/index.cjs
CHANGED
|
@@ -2,6 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
|
2
2
|
let _warlock_js_ai = require("@warlock.js/ai");
|
|
3
3
|
let node_fs_promises = require("node:fs/promises");
|
|
4
4
|
let node_path = require("node:path");
|
|
5
|
+
let node_http = require("node:http");
|
|
5
6
|
|
|
6
7
|
//#region ../@warlock.js/ai-panoptic/src/exporters/utils/walk-spans.ts
|
|
7
8
|
/**
|
|
@@ -47,7 +48,7 @@ function normalizeError(error) {
|
|
|
47
48
|
if (error === void 0 || error === null) return;
|
|
48
49
|
if (typeof error !== "object") return {
|
|
49
50
|
type: "Error",
|
|
50
|
-
message: String(error)
|
|
51
|
+
message: (0, _warlock_js_ai.scrubSecrets)(String(error))
|
|
51
52
|
};
|
|
52
53
|
const candidate = error;
|
|
53
54
|
const type = pickString(candidate.code) ?? pickString(candidate.name) ?? "Error";
|
|
@@ -55,9 +56,10 @@ function normalizeError(error) {
|
|
|
55
56
|
const stack = pickString(candidate.stack);
|
|
56
57
|
const normalized = {
|
|
57
58
|
type,
|
|
58
|
-
message
|
|
59
|
+
message: (0, _warlock_js_ai.scrubSecrets)(message)
|
|
59
60
|
};
|
|
60
|
-
if (stack !== void 0) normalized.stack = stack;
|
|
61
|
+
if (stack !== void 0) normalized.stack = (0, _warlock_js_ai.scrubSecrets)(stack);
|
|
62
|
+
if (candidate.cause !== void 0 && candidate.cause !== null) normalized.cause = typeof candidate.cause === "object" ? (0, _warlock_js_ai.redact)(candidate.cause) : (0, _warlock_js_ai.scrubSecrets)(String(candidate.cause));
|
|
61
63
|
return normalized;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
@@ -104,6 +106,7 @@ function extractSpanAttributes(report) {
|
|
|
104
106
|
addWorkflowAttributes(attributes, extensions);
|
|
105
107
|
break;
|
|
106
108
|
case "supervisor":
|
|
109
|
+
case "team":
|
|
107
110
|
addSupervisorAttributes(attributes, extensions);
|
|
108
111
|
break;
|
|
109
112
|
case "orchestrator":
|
|
@@ -121,6 +124,8 @@ function addAgentAttributes(attributes, extensions) {
|
|
|
121
124
|
if (Array.isArray(extensions.trips)) attributes["agent.trips"] = extensions.trips.length;
|
|
122
125
|
if (extensions.model?.name !== void 0) attributes["agent.model.name"] = extensions.model.name;
|
|
123
126
|
if (extensions.model?.provider !== void 0) attributes["agent.model.provider"] = extensions.model.provider;
|
|
127
|
+
if (extensions.promptName !== void 0) attributes["agent.promptName"] = extensions.promptName;
|
|
128
|
+
if (extensions.promptVersion !== void 0) attributes["agent.promptVersion"] = extensions.promptVersion;
|
|
124
129
|
}
|
|
125
130
|
function addWorkflowAttributes(attributes, extensions) {
|
|
126
131
|
if (extensions.workflowName !== void 0) attributes["workflow.name"] = extensions.workflowName;
|
|
@@ -159,6 +164,12 @@ function addToolAttributes(attributes, extensions) {
|
|
|
159
164
|
* identity, tool trip index) is routed into the optional `attributes`
|
|
160
165
|
* bag via {@link extractSpanAttributes}.
|
|
161
166
|
*
|
|
167
|
+
* When {@link ContentCaptureOptions.captureContent} is set, the raw
|
|
168
|
+
* prompt/response (agents) and args/result (tools) are additionally
|
|
169
|
+
* copied onto `span.input` / `span.output` — off by default because
|
|
170
|
+
* payloads are large and often sensitive. A {@link ContentRedactor} can
|
|
171
|
+
* mask each value first.
|
|
172
|
+
*
|
|
162
173
|
* No external lookup is needed — a `BaseReport` already carries
|
|
163
174
|
* everything a span requires, so a collector can flatten a tree without
|
|
164
175
|
* consulting any other source.
|
|
@@ -167,7 +178,7 @@ function addToolAttributes(attributes, extensions) {
|
|
|
167
178
|
* const root = reportToSpan(result.report);
|
|
168
179
|
* console.log(root.spanId, root.traceId, root.children.length);
|
|
169
180
|
*/
|
|
170
|
-
function reportToSpan(report) {
|
|
181
|
+
function reportToSpan(report, options) {
|
|
171
182
|
const span = {
|
|
172
183
|
spanId: report.runId,
|
|
173
184
|
traceId: report.rootRunId,
|
|
@@ -178,7 +189,7 @@ function reportToSpan(report) {
|
|
|
178
189
|
endedAt: report.endedAt,
|
|
179
190
|
duration: report.duration,
|
|
180
191
|
usage: report.usage,
|
|
181
|
-
children: report.children.map(reportToSpan)
|
|
192
|
+
children: report.children.map((child) => reportToSpan(child, options))
|
|
182
193
|
};
|
|
183
194
|
if (report.parentRunId !== void 0) span.parentSpanId = report.parentRunId;
|
|
184
195
|
if (report.sessionId !== void 0) span.sessionId = report.sessionId;
|
|
@@ -187,8 +198,87 @@ function reportToSpan(report) {
|
|
|
187
198
|
if (error !== void 0) span.error = error;
|
|
188
199
|
const attributes = extractSpanAttributes(report);
|
|
189
200
|
if (attributes !== void 0) span.attributes = attributes;
|
|
201
|
+
if (options?.captureContent) captureContent(span, report, options);
|
|
190
202
|
return span;
|
|
191
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Copy the node's raw content onto `span.input` / `span.output`.
|
|
206
|
+
*
|
|
207
|
+
* - Tools carry the call arguments + return value directly on the report
|
|
208
|
+
* (`ToolCall.input` / `ToolCall.output`).
|
|
209
|
+
* - Agents carry a `trips[]` history plus the resolved `systemPrompt`. The
|
|
210
|
+
* input is emitted as a `[system, user]` chat array — so backends like
|
|
211
|
+
* Langfuse render the full prompt as sent — or the bare user string when
|
|
212
|
+
* there's no system prompt. The output is the last NON-EMPTY trip
|
|
213
|
+
* `output` (the final response text); a failed / max-trips run can end on
|
|
214
|
+
* a trip whose `output` is `""` or tool-call-only, so we scan back for the
|
|
215
|
+
* last one that carried text. Intermediate trips store a `"[tool results]"`
|
|
216
|
+
* placeholder upstream.
|
|
217
|
+
* - When {@link ContentCaptureOptions.fullHistory} is on AND the agent run
|
|
218
|
+
* opted into `captureMessages` (so the report carries a `messages`
|
|
219
|
+
* array), the *whole* `CapturedMessage[]` is emitted as `span.input`
|
|
220
|
+
* instead of the `[system, user]` first-trip array — every role, every
|
|
221
|
+
* trip. The output stays the last non-empty trip output. When `messages`
|
|
222
|
+
* is absent the branch falls back to today's first-trip logic, so a run
|
|
223
|
+
* that didn't opt in degrades gracefully.
|
|
224
|
+
*
|
|
225
|
+
* Each value is passed through the optional {@link ContentRedactor};
|
|
226
|
+
* a redactor returning `undefined` drops the field. Under `fullHistory`
|
|
227
|
+
* the redactor receives the full array as a single value.
|
|
228
|
+
*/
|
|
229
|
+
function captureContent(span, report, options) {
|
|
230
|
+
const node = report;
|
|
231
|
+
const redact = options.redactContent;
|
|
232
|
+
let input;
|
|
233
|
+
let output;
|
|
234
|
+
if (report.type === "tool") {
|
|
235
|
+
input = node.input;
|
|
236
|
+
output = node.output;
|
|
237
|
+
} else if (options.fullHistory && Array.isArray(node.messages) && node.messages.length > 0) {
|
|
238
|
+
input = node.messages;
|
|
239
|
+
output = Array.isArray(node.trips) ? lastNonEmptyOutput(node.trips) : void 0;
|
|
240
|
+
} else if (Array.isArray(node.trips) && node.trips.length > 0) {
|
|
241
|
+
const userInput = node.trips[0]?.input;
|
|
242
|
+
input = typeof node.systemPrompt === "string" && node.systemPrompt.length > 0 ? [{
|
|
243
|
+
role: "system",
|
|
244
|
+
content: node.systemPrompt
|
|
245
|
+
}, {
|
|
246
|
+
role: "user",
|
|
247
|
+
content: userInput
|
|
248
|
+
}] : userInput;
|
|
249
|
+
output = lastNonEmptyOutput(node.trips);
|
|
250
|
+
}
|
|
251
|
+
if (input !== void 0) {
|
|
252
|
+
const value = redact ? redact(input, {
|
|
253
|
+
name: span.name,
|
|
254
|
+
type: span.type,
|
|
255
|
+
field: "input"
|
|
256
|
+
}) : input;
|
|
257
|
+
if (value !== void 0) span.input = value;
|
|
258
|
+
}
|
|
259
|
+
if (output !== void 0) {
|
|
260
|
+
const value = redact ? redact(output, {
|
|
261
|
+
name: span.name,
|
|
262
|
+
type: span.type,
|
|
263
|
+
field: "output"
|
|
264
|
+
}) : output;
|
|
265
|
+
if (value !== void 0) span.output = value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The last trip output that actually carries text — the agent's final
|
|
270
|
+
* response on the happy path. A failed or max-trips run can end on a trip
|
|
271
|
+
* whose `output` is `""` or tool-call-only, so we scan backwards for the
|
|
272
|
+
* last trip that produced text rather than blindly taking the final trip
|
|
273
|
+
* (which would surface an empty string). Returns `undefined` when no trip
|
|
274
|
+
* produced any output.
|
|
275
|
+
*/
|
|
276
|
+
function lastNonEmptyOutput(trips) {
|
|
277
|
+
for (let i = trips.length - 1; i >= 0; i -= 1) {
|
|
278
|
+
const out = trips[i]?.output;
|
|
279
|
+
if (typeof out === "string" ? out.length > 0 : out !== void 0) return out;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
192
282
|
|
|
193
283
|
//#endregion
|
|
194
284
|
//#region ../@warlock.js/ai-panoptic/src/collector/report-to-trace.ts
|
|
@@ -209,18 +299,21 @@ function reportToSpan(report) {
|
|
|
209
299
|
* without dispatching to exporters.
|
|
210
300
|
*
|
|
211
301
|
* The optional `rootError` threads the failing run's envelope error
|
|
212
|
-
* (`BaseResult.error`) onto the root span
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
302
|
+
* (`BaseResult.error`) onto the root span — a fallback for callers that
|
|
303
|
+
* hold the result envelope (the `attach`/middleware path). Root primitives
|
|
304
|
+
* now also stamp their terminal error onto the report itself
|
|
305
|
+
* (`BaseReport.error`), so the observe path — which delivers only the
|
|
306
|
+
* report, never the envelope — still surfaces a failed root's error.
|
|
307
|
+
* `rootError` is applied only when the projected root span carries none of
|
|
308
|
+
* its own; the subtree projection stays pure (each child surfaces its own
|
|
309
|
+
* report-level error, if any).
|
|
217
310
|
*
|
|
218
311
|
* @example
|
|
219
312
|
* const trace = reportToTrace(result.report, result.error);
|
|
220
313
|
* console.log(trace.traceId, trace.usage.total, trace.duration);
|
|
221
314
|
*/
|
|
222
|
-
function reportToTrace(report, rootError) {
|
|
223
|
-
const root = reportToSpan(report);
|
|
315
|
+
function reportToTrace(report, rootError, options) {
|
|
316
|
+
const root = reportToSpan(report, options);
|
|
224
317
|
if (root.error === void 0) {
|
|
225
318
|
const error = normalizeError(rootError);
|
|
226
319
|
if (error !== void 0) root.error = error;
|
|
@@ -254,18 +347,38 @@ function reportToTrace(report, rootError) {
|
|
|
254
347
|
* into the originating run — `collect`, `flush`, and `shutdown` settle
|
|
255
348
|
* every exporter independently (mirrors how the core event hooks
|
|
256
349
|
* swallow consumer errors). One broken exporter can neither crash the
|
|
257
|
-
* agent loop nor stop sibling exporters from receiving the trace.
|
|
350
|
+
* agent loop nor stop sibling exporters from receiving the trace. The
|
|
351
|
+
* failure is still **surfaced** — via the `onError` option, or a one-time
|
|
352
|
+
* `console.warn` per exporter — so a misconfiguration (e.g. a missing
|
|
353
|
+
* optional peer like `langfuse`) doesn't fail silently.
|
|
258
354
|
*/
|
|
259
355
|
var Collector = class {
|
|
260
|
-
|
|
261
|
-
|
|
356
|
+
options;
|
|
357
|
+
/**
|
|
358
|
+
* Registered exporters in insertion order. A `Map` keyed by
|
|
359
|
+
* `ExporterContract.name` gives O(1) dedupe on `use` while preserving
|
|
360
|
+
* registration order for deterministic fan-out.
|
|
361
|
+
*/
|
|
362
|
+
exporters = /* @__PURE__ */ new Map();
|
|
363
|
+
/**
|
|
364
|
+
* Exporters already warned about on the default error path — so a
|
|
365
|
+
* persistent misconfiguration surfaces once, not on every trace.
|
|
366
|
+
*/
|
|
367
|
+
warnedExporters = /* @__PURE__ */ new Set();
|
|
368
|
+
/**
|
|
369
|
+
* Collector options: content capture threaded into every `toTrace`
|
|
370
|
+
* projection (`captureContent` populates span `input` / `output`), plus
|
|
371
|
+
* an optional `onError` for isolated exporter failures.
|
|
372
|
+
*/
|
|
373
|
+
constructor(options = {}) {
|
|
374
|
+
this.options = options;
|
|
262
375
|
}
|
|
263
376
|
use(exporter) {
|
|
264
377
|
if (!this.exporters.has(exporter.name)) this.exporters.set(exporter.name, exporter);
|
|
265
378
|
return this;
|
|
266
379
|
}
|
|
267
380
|
toTrace(report, rootError) {
|
|
268
|
-
return reportToTrace(report, rootError);
|
|
381
|
+
return reportToTrace(report, rootError, this.options);
|
|
269
382
|
}
|
|
270
383
|
async collect(report, rootError) {
|
|
271
384
|
const trace = this.toTrace(report, rootError);
|
|
@@ -302,9 +415,29 @@ var Collector = class {
|
|
|
302
415
|
* others — the contract's failure-isolation requirement.
|
|
303
416
|
*/
|
|
304
417
|
async settleAll(task) {
|
|
305
|
-
const runs = [...this.exporters.
|
|
418
|
+
const runs = [...this.exporters.entries()].map(([name, exporter]) => Promise.resolve().then(() => task(exporter)).catch((error) => this.reportExporterError(name, error)));
|
|
306
419
|
await Promise.allSettled(runs);
|
|
307
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Surface an isolated exporter failure. The originating run never sees it
|
|
423
|
+
* (the isolation guarantee holds), but a silent failure is the wrong
|
|
424
|
+
* default for a config error — e.g. a missing optional peer like
|
|
425
|
+
* `langfuse` would otherwise drop every trace with no signal. The
|
|
426
|
+
* supplied `onError` is called, or — by default — a `console.warn` is
|
|
427
|
+
* emitted ONCE per exporter so the cause is visible without spamming.
|
|
428
|
+
*/
|
|
429
|
+
reportExporterError(name, error) {
|
|
430
|
+
if (this.options.onError) {
|
|
431
|
+
try {
|
|
432
|
+
this.options.onError(name, error);
|
|
433
|
+
} catch {}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.warnedExporters.has(name)) return;
|
|
437
|
+
this.warnedExporters.add(name);
|
|
438
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
439
|
+
console.warn(`[panoptic] exporter "${name}" failed and was isolated: ${message}`);
|
|
440
|
+
}
|
|
308
441
|
};
|
|
309
442
|
/**
|
|
310
443
|
* Create a Panoptic collector — the single integration point an app
|
|
@@ -318,8 +451,8 @@ var Collector = class {
|
|
|
318
451
|
* // on shutdown:
|
|
319
452
|
* await collector.shutdown();
|
|
320
453
|
*/
|
|
321
|
-
function createCollector() {
|
|
322
|
-
return new Collector();
|
|
454
|
+
function createCollector(options = {}) {
|
|
455
|
+
return new Collector(options);
|
|
323
456
|
}
|
|
324
457
|
|
|
325
458
|
//#endregion
|
|
@@ -425,6 +558,291 @@ function sumOptional(accumulator, next) {
|
|
|
425
558
|
return (accumulator ?? 0) + (next ?? 0);
|
|
426
559
|
}
|
|
427
560
|
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region ../@warlock.js/ai-panoptic/src/store/cache-trace-store.ts
|
|
563
|
+
const DEFAULT_PREFIX = "panoptic";
|
|
564
|
+
/**
|
|
565
|
+
* Cache-backed {@link TraceStoreContract} with a **write-through** design
|
|
566
|
+
* that reconciles the synchronous store contract with an asynchronous
|
|
567
|
+
* cache driver.
|
|
568
|
+
*
|
|
569
|
+
* **How the sync/async tension is resolved.** The contract's `get` /
|
|
570
|
+
* `query` / `aggregate` / `size` are synchronous (the dashboard polls them
|
|
571
|
+
* on every request and the in-memory store answers instantly). A cache
|
|
572
|
+
* driver is async. So this store keeps an **in-memory read mirror** — the
|
|
573
|
+
* same insertion-ordered `Map<traceId, Trace>` the in-memory store uses —
|
|
574
|
+
* and serves every read from it synchronously. Writes go **through** to the
|
|
575
|
+
* cache: `add` updates the mirror immediately, then asynchronously persists
|
|
576
|
+
* the trace + index to the cache (errors are swallowed via an optional
|
|
577
|
+
* `onError` hook so a flaky cache never throws into the collector's hot
|
|
578
|
+
* path). On process restart, {@link CacheTraceStore.ready} re-hydrates the
|
|
579
|
+
* mirror from the cache so traces survive the restart.
|
|
580
|
+
*
|
|
581
|
+
* **Durability is best-effort.** Reads never wait on the cache; the mirror
|
|
582
|
+
* is the source of truth at runtime and the cache is the durable backing
|
|
583
|
+
* store. A write that the cache rejects is still visible in the mirror for
|
|
584
|
+
* the life of the process — it just won't survive a restart.
|
|
585
|
+
*
|
|
586
|
+
* **Lazy driver resolution.** The driver (or its async factory) is resolved
|
|
587
|
+
* on first use and memoized, so a production deployment can defer the Redis
|
|
588
|
+
* connect until the first trace is collected, and the dashboard can be
|
|
589
|
+
* wired with a factory at import time without a live connection.
|
|
590
|
+
*
|
|
591
|
+
* Doubles as an {@link ExporterContract} (`export` ≡ `add`), so it drops
|
|
592
|
+
* straight into a collector via `collector.use(store)`.
|
|
593
|
+
*
|
|
594
|
+
* Instantiated via {@link createCacheTraceStore}; callers never see `new`.
|
|
595
|
+
*/
|
|
596
|
+
var CacheTraceStore = class {
|
|
597
|
+
/** Stable exporter id so a collector can dedupe / log this sink. */
|
|
598
|
+
name = "cache-trace-store";
|
|
599
|
+
/**
|
|
600
|
+
* In-memory read mirror keyed by `traceId`. A `Map` preserves insertion
|
|
601
|
+
* order, which newest-first `query` ordering and FIFO eviction both rely
|
|
602
|
+
* on. Every read is served from here synchronously.
|
|
603
|
+
*/
|
|
604
|
+
mirror = /* @__PURE__ */ new Map();
|
|
605
|
+
prefix;
|
|
606
|
+
capacity;
|
|
607
|
+
/**
|
|
608
|
+
* Monotonic insertion counter — the source of `IndexEntry.addedAt`.
|
|
609
|
+
* Deliberately NOT `Date.now()`: an internal counter guarantees a stable
|
|
610
|
+
* total order for the index even when many traces land in the same
|
|
611
|
+
* millisecond.
|
|
612
|
+
*/
|
|
613
|
+
addCounter = 0;
|
|
614
|
+
/** The optional input — a driver, a factory, or `undefined`. */
|
|
615
|
+
input;
|
|
616
|
+
resolvedDriver;
|
|
617
|
+
driverPending;
|
|
618
|
+
onError;
|
|
619
|
+
constructor(input, options = {}) {
|
|
620
|
+
this.input = input;
|
|
621
|
+
this.prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
622
|
+
this.capacity = options.capacity ?? 0;
|
|
623
|
+
this.onError = options.onError;
|
|
624
|
+
}
|
|
625
|
+
get size() {
|
|
626
|
+
return this.mirror.size;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Hydrate the in-memory mirror from the cache. Idempotent-safe to call
|
|
630
|
+
* once at startup (the dashboard / config wiring awaits it). Reads the
|
|
631
|
+
* persisted index, fetches each referenced trace, and replays them into
|
|
632
|
+
* the mirror in insertion order so newest-first ordering + eviction stay
|
|
633
|
+
* correct after a restart. A cache failure is routed to `onError` and
|
|
634
|
+
* leaves the mirror empty rather than throwing.
|
|
635
|
+
*/
|
|
636
|
+
async ready() {
|
|
637
|
+
try {
|
|
638
|
+
const driver = await this.resolveDriver();
|
|
639
|
+
const index = await driver.get(this.indexKey());
|
|
640
|
+
if (!Array.isArray(index)) return;
|
|
641
|
+
const ordered = [...index].sort((left, right) => left.addedAt - right.addedAt);
|
|
642
|
+
for (const entry of ordered) {
|
|
643
|
+
const trace = await driver.get(this.traceKey(entry.id));
|
|
644
|
+
if (trace !== null && trace !== void 0) {
|
|
645
|
+
this.mirror.delete(trace.traceId);
|
|
646
|
+
this.mirror.set(trace.traceId, trace);
|
|
647
|
+
}
|
|
648
|
+
if (entry.addedAt >= this.addCounter) this.addCounter = entry.addedAt + 1;
|
|
649
|
+
}
|
|
650
|
+
this.evictOverflow();
|
|
651
|
+
} catch (error) {
|
|
652
|
+
this.reportError(error);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
add(trace) {
|
|
656
|
+
this.mirror.delete(trace.traceId);
|
|
657
|
+
this.mirror.set(trace.traceId, trace);
|
|
658
|
+
const addedAt = this.addCounter;
|
|
659
|
+
this.addCounter += 1;
|
|
660
|
+
const evicted = this.evictOverflow();
|
|
661
|
+
this.persist(trace, addedAt, evicted);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* `ExporterContract.export` — a collector dispatches a completed trace
|
|
665
|
+
* here, which is exactly an `add`.
|
|
666
|
+
*/
|
|
667
|
+
export(trace) {
|
|
668
|
+
this.add(trace);
|
|
669
|
+
}
|
|
670
|
+
get(traceId) {
|
|
671
|
+
return this.mirror.get(traceId);
|
|
672
|
+
}
|
|
673
|
+
query(filter) {
|
|
674
|
+
const matched = [];
|
|
675
|
+
for (const trace of this.mirror.values()) if (matchTrace(trace, filter)) matched.push(trace);
|
|
676
|
+
return this.sortNewestFirst(matched);
|
|
677
|
+
}
|
|
678
|
+
aggregate(filter) {
|
|
679
|
+
const aggregate = {
|
|
680
|
+
traces: 0,
|
|
681
|
+
completed: 0,
|
|
682
|
+
failed: 0,
|
|
683
|
+
cancelled: 0,
|
|
684
|
+
usage: emptyUsage(),
|
|
685
|
+
totalDuration: 0
|
|
686
|
+
};
|
|
687
|
+
for (const trace of this.mirror.values()) {
|
|
688
|
+
if (!matchTrace(trace, filter)) continue;
|
|
689
|
+
aggregate.traces += 1;
|
|
690
|
+
aggregate.totalDuration += trace.duration;
|
|
691
|
+
aggregate.usage = sumUsage(aggregate.usage, trace.usage);
|
|
692
|
+
this.countStatus(aggregate, trace);
|
|
693
|
+
}
|
|
694
|
+
if (aggregate.usage.cost !== void 0) aggregate.cost = aggregate.usage.cost;
|
|
695
|
+
return aggregate;
|
|
696
|
+
}
|
|
697
|
+
clear() {
|
|
698
|
+
const ids = [...this.mirror.keys()];
|
|
699
|
+
this.mirror.clear();
|
|
700
|
+
this.purge(ids);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Persist one trace + the rebuilt index to the cache, optionally removing
|
|
704
|
+
* a trace evicted by the capacity cap. Best-effort: any cache failure is
|
|
705
|
+
* routed to `onError`, never thrown — the mirror already reflects the
|
|
706
|
+
* write so runtime reads are unaffected.
|
|
707
|
+
*/
|
|
708
|
+
async persist(trace, addedAt, evictedId) {
|
|
709
|
+
try {
|
|
710
|
+
const driver = await this.resolveDriver();
|
|
711
|
+
await driver.set(this.traceKey(trace.traceId), trace);
|
|
712
|
+
if (evictedId !== void 0) await driver.remove(this.traceKey(evictedId));
|
|
713
|
+
await driver.set(this.indexKey(), this.buildIndex(addedAt));
|
|
714
|
+
} catch (error) {
|
|
715
|
+
this.reportError(error);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/** Best-effort removal of every persisted trace + the index on `clear`. */
|
|
719
|
+
async purge(ids) {
|
|
720
|
+
try {
|
|
721
|
+
const driver = await this.resolveDriver();
|
|
722
|
+
for (const id of ids) await driver.remove(this.traceKey(id));
|
|
723
|
+
await driver.remove(this.indexKey());
|
|
724
|
+
} catch (error) {
|
|
725
|
+
this.reportError(error);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Rebuild the newest-first index from the current mirror. The mirror's
|
|
730
|
+
* `Map` iteration is oldest-first insertion order; we walk it and assign
|
|
731
|
+
* `addedAt` from the surviving counter span so the persisted order
|
|
732
|
+
* matches the in-memory one. The freshest entry uses `latestAddedAt`.
|
|
733
|
+
*/
|
|
734
|
+
buildIndex(latestAddedAt) {
|
|
735
|
+
const ids = [...this.mirror.keys()];
|
|
736
|
+
const base = latestAddedAt - (ids.length - 1);
|
|
737
|
+
return ids.map((id, offset) => ({
|
|
738
|
+
id,
|
|
739
|
+
addedAt: base + offset
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Resolve the driver once and memoize. Supports a bare driver, a sync
|
|
744
|
+
* factory, and an async factory. Concurrent first-callers share one
|
|
745
|
+
* in-flight resolution.
|
|
746
|
+
*/
|
|
747
|
+
async resolveDriver() {
|
|
748
|
+
if (this.resolvedDriver !== void 0) return this.resolvedDriver;
|
|
749
|
+
if (this.driverPending !== void 0) return this.driverPending;
|
|
750
|
+
const candidate = typeof this.input === "function" ? this.input() : this.input;
|
|
751
|
+
this.driverPending = Promise.resolve(candidate);
|
|
752
|
+
try {
|
|
753
|
+
this.resolvedDriver = await this.driverPending;
|
|
754
|
+
return this.resolvedDriver;
|
|
755
|
+
} finally {
|
|
756
|
+
this.driverPending = void 0;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** `${prefix}:trace:${traceId}` — the per-trace cache key. */
|
|
760
|
+
traceKey(traceId) {
|
|
761
|
+
return `${this.prefix}:trace:${traceId}`;
|
|
762
|
+
}
|
|
763
|
+
/** `${prefix}:index` — the newest-first index cache key. */
|
|
764
|
+
indexKey() {
|
|
765
|
+
return `${this.prefix}:index`;
|
|
766
|
+
}
|
|
767
|
+
/** Route a swallowed cache error to the optional handler. */
|
|
768
|
+
reportError(error) {
|
|
769
|
+
if (this.onError !== void 0) this.onError(error);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Increment the matching terminal-status counter for one trace.
|
|
773
|
+
* Mirrors the in-memory store: non-terminal statuses are counted in
|
|
774
|
+
* `traces` but tracked by none of the three headline counters.
|
|
775
|
+
*/
|
|
776
|
+
countStatus(aggregate, trace) {
|
|
777
|
+
switch (trace.root.status) {
|
|
778
|
+
case "completed":
|
|
779
|
+
aggregate.completed += 1;
|
|
780
|
+
break;
|
|
781
|
+
case "failed":
|
|
782
|
+
aggregate.failed += 1;
|
|
783
|
+
break;
|
|
784
|
+
case "cancelled":
|
|
785
|
+
aggregate.cancelled += 1;
|
|
786
|
+
break;
|
|
787
|
+
default: break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Sort matched traces newest-started first. A copy is sorted so the
|
|
792
|
+
* mirror's insertion order (which eviction depends on) is never disturbed.
|
|
793
|
+
*/
|
|
794
|
+
sortNewestFirst(traces) {
|
|
795
|
+
return traces.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt));
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Evict oldest-inserted traces from the mirror until within `capacity`.
|
|
799
|
+
* Returns the id of the single evicted trace (the common case — one `add`
|
|
800
|
+
* pushes at most one over the cap) so the caller can remove it from the
|
|
801
|
+
* cache too. No-op + `undefined` when unbounded or within the cap.
|
|
802
|
+
*/
|
|
803
|
+
evictOverflow() {
|
|
804
|
+
if (this.capacity <= 0) return;
|
|
805
|
+
let evicted;
|
|
806
|
+
while (this.mirror.size > this.capacity) {
|
|
807
|
+
const oldest = this.mirror.keys().next().value;
|
|
808
|
+
if (oldest === void 0) return evicted;
|
|
809
|
+
this.mirror.delete(oldest);
|
|
810
|
+
evicted = oldest;
|
|
811
|
+
}
|
|
812
|
+
return evicted;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
/**
|
|
816
|
+
* Create a cache-backed trace store. Reads are served synchronously from an
|
|
817
|
+
* in-memory mirror; writes go through to the cache, and {@link
|
|
818
|
+
* CacheTraceStoreHandle.ready} re-hydrates the mirror on startup so traces
|
|
819
|
+
* survive a restart. See {@link CacheTraceStore} for the full write-through
|
|
820
|
+
* design.
|
|
821
|
+
*
|
|
822
|
+
* @param cache a {@link CacheDriver}, or a (possibly async) factory that
|
|
823
|
+
* yields one on first use — resolved lazily and memoized so a production
|
|
824
|
+
* Redis connect can be deferred until the first trace is collected.
|
|
825
|
+
* @param options `prefix` (default `"panoptic"`), `capacity` (FIFO cap),
|
|
826
|
+
* and an optional `onError` hook for swallowed cache write failures.
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* import { RedisCacheDriver } from "@warlock.js/cache";
|
|
830
|
+
*
|
|
831
|
+
* // Lazy async factory — defers the Redis connect until first use.
|
|
832
|
+
* const store = createCacheTraceStore(async () => {
|
|
833
|
+
* const driver = new RedisCacheDriver();
|
|
834
|
+
* await driver.connect();
|
|
835
|
+
* return driver;
|
|
836
|
+
* });
|
|
837
|
+
*
|
|
838
|
+
* await store.ready(); // hydrate from a prior run
|
|
839
|
+
* collector.use(store); // fills as traces complete
|
|
840
|
+
* const failed = store.query({ status: "failed" });
|
|
841
|
+
*/
|
|
842
|
+
function createCacheTraceStore(cache, options = {}) {
|
|
843
|
+
return new CacheTraceStore(cache, options);
|
|
844
|
+
}
|
|
845
|
+
|
|
428
846
|
//#endregion
|
|
429
847
|
//#region ../@warlock.js/ai-panoptic/src/store/in-memory-trace-store.ts
|
|
430
848
|
/**
|
|
@@ -444,9 +862,16 @@ function sumOptional(accumulator, next) {
|
|
|
444
862
|
* callers never see `new`.
|
|
445
863
|
*/
|
|
446
864
|
var InMemoryTraceStore = class {
|
|
865
|
+
/** Stable exporter id so a collector can dedupe / log this sink. */
|
|
866
|
+
name = "in-memory-trace-store";
|
|
867
|
+
/**
|
|
868
|
+
* Retained traces keyed by `traceId`. A `Map` preserves insertion
|
|
869
|
+
* order, which is what FIFO eviction and newest-first `query` ordering
|
|
870
|
+
* both rely on.
|
|
871
|
+
*/
|
|
872
|
+
traces = /* @__PURE__ */ new Map();
|
|
873
|
+
capacity;
|
|
447
874
|
constructor(options) {
|
|
448
|
-
this.name = "in-memory-trace-store";
|
|
449
|
-
this.traces = /* @__PURE__ */ new Map();
|
|
450
875
|
this.capacity = options?.capacity ?? 0;
|
|
451
876
|
}
|
|
452
877
|
get size() {
|
|
@@ -592,7 +1017,11 @@ const GEN_AI_ATTRIBUTES = {
|
|
|
592
1017
|
responseModel: "gen_ai.response.model",
|
|
593
1018
|
usageInputTokens: "gen_ai.usage.input_tokens",
|
|
594
1019
|
usageOutputTokens: "gen_ai.usage.output_tokens",
|
|
595
|
-
conversationId: "gen_ai.conversation.id"
|
|
1020
|
+
conversationId: "gen_ai.conversation.id",
|
|
1021
|
+
/** Captured prompt/input (set only under content capture). */
|
|
1022
|
+
prompt: "gen_ai.prompt",
|
|
1023
|
+
/** Captured completion/output (set only under content capture). */
|
|
1024
|
+
completion: "gen_ai.completion"
|
|
596
1025
|
};
|
|
597
1026
|
/**
|
|
598
1027
|
* Panoptic-specific attribute keys that have no GenAI-convention
|
|
@@ -659,6 +1088,56 @@ function mergeScalarAttributes(target, source) {
|
|
|
659
1088
|
for (const [key, value] of Object.entries(source)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") target[key] = value;
|
|
660
1089
|
}
|
|
661
1090
|
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region ../@warlock.js/ai-panoptic/src/exporters/console/format-span-io.ts
|
|
1093
|
+
/** Default cap on how many characters of captured content a console line shows. */
|
|
1094
|
+
const DEFAULT_IO_MAX_CHARS = 500;
|
|
1095
|
+
/**
|
|
1096
|
+
* Render a span's captured content ({@link TraceSpan.input} /
|
|
1097
|
+
* {@link TraceSpan.output}) as extra indented console lines, one level
|
|
1098
|
+
* below the span's own line:
|
|
1099
|
+
*
|
|
1100
|
+
* ```text
|
|
1101
|
+
* ok agent "market-research" — 1794ms, 224 tok, $0.0008
|
|
1102
|
+
* in: Research the market for Acme Coffee Roasters …
|
|
1103
|
+
* out: Demand is steady; specialty buyers skew premium …
|
|
1104
|
+
* ```
|
|
1105
|
+
*
|
|
1106
|
+
* Returns `[]` when the span carries no content — capture disabled, or a
|
|
1107
|
+
* composite node with no own I/O. Each value is stringified (JSON for
|
|
1108
|
+
* non-strings), whitespace-collapsed to stay scannable, and truncated to
|
|
1109
|
+
* `maxChars` (default {@link DEFAULT_IO_MAX_CHARS}) with an ellipsis. Use
|
|
1110
|
+
* the file exporter for the full, untruncated payload.
|
|
1111
|
+
*
|
|
1112
|
+
* @example
|
|
1113
|
+
* formatSpanIO(toolSpan, 1);
|
|
1114
|
+
* // [' in: {"query":"specialty coffee demand"}', ' out: {"results":[…]}']
|
|
1115
|
+
*/
|
|
1116
|
+
function formatSpanIO(span, depth = 0, maxChars = DEFAULT_IO_MAX_CHARS) {
|
|
1117
|
+
const lines = [];
|
|
1118
|
+
const indent = " ".repeat(depth + 1);
|
|
1119
|
+
if (span.input !== void 0) lines.push(`${indent}in: ${preview(span.input, maxChars)}`);
|
|
1120
|
+
if (span.output !== void 0) lines.push(`${indent}out: ${preview(span.output, maxChars)}`);
|
|
1121
|
+
return lines;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* One-line, length-capped preview of a captured value. Strings pass
|
|
1125
|
+
* through; everything else is JSON-stringified (falling back to
|
|
1126
|
+
* `String()` on a circular / unstringifiable value). Internal whitespace
|
|
1127
|
+
* is collapsed so the preview never breaks the tree layout.
|
|
1128
|
+
*/
|
|
1129
|
+
function preview(value, maxChars) {
|
|
1130
|
+
const collapsed = (typeof value === "string" ? value : stringify(value)).replace(/\s+/g, " ").trim();
|
|
1131
|
+
return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}…` : collapsed;
|
|
1132
|
+
}
|
|
1133
|
+
function stringify(value) {
|
|
1134
|
+
try {
|
|
1135
|
+
return JSON.stringify(value) ?? String(value);
|
|
1136
|
+
} catch {
|
|
1137
|
+
return String(value);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
662
1141
|
//#endregion
|
|
663
1142
|
//#region ../@warlock.js/ai-panoptic/src/exporters/console/format-span-line.ts
|
|
664
1143
|
/**
|
|
@@ -705,9 +1184,10 @@ const EXPORTER_NAME$3 = "console";
|
|
|
705
1184
|
* as the reference implementation of the contract.
|
|
706
1185
|
*
|
|
707
1186
|
* By default it prints one summary line per completed trace. Set
|
|
708
|
-
* `tree: true` to print the full indented span tree,
|
|
709
|
-
*
|
|
710
|
-
*
|
|
1187
|
+
* `tree: true` to print the full indented span tree, `io: true` to also
|
|
1188
|
+
* print each span's captured `input` / `output` (needs the collector's
|
|
1189
|
+
* `captureContent`), and `streaming: true` to print each span the moment
|
|
1190
|
+
* it finalizes (via the optional `exportSpan` hook).
|
|
711
1191
|
*
|
|
712
1192
|
* @example
|
|
713
1193
|
* collector.use(consoleExporter());
|
|
@@ -715,36 +1195,52 @@ const EXPORTER_NAME$3 = "console";
|
|
|
715
1195
|
*
|
|
716
1196
|
* @example
|
|
717
1197
|
* collector.use(consoleExporter({ tree: true }));
|
|
1198
|
+
*
|
|
1199
|
+
* @example
|
|
1200
|
+
* // Full content trace — prompts, responses, and tool I/O:
|
|
1201
|
+
* const observe = panoptic({
|
|
1202
|
+
* captureContent: true,
|
|
1203
|
+
* exporters: [consoleExporter({ tree: true, io: true })],
|
|
1204
|
+
* });
|
|
718
1205
|
*/
|
|
719
1206
|
function consoleExporter(options = {}) {
|
|
720
1207
|
const sink = options.console ?? console;
|
|
721
1208
|
const tree = options.tree ?? false;
|
|
1209
|
+
const io = options.io ?? false;
|
|
1210
|
+
const ioMaxChars = options.ioMaxChars;
|
|
722
1211
|
const exporter = {
|
|
723
1212
|
name: EXPORTER_NAME$3,
|
|
724
1213
|
export(trace) {
|
|
725
|
-
writeTrace(sink, trace, tree);
|
|
1214
|
+
writeTrace(sink, trace, tree, io, ioMaxChars);
|
|
726
1215
|
}
|
|
727
1216
|
};
|
|
728
1217
|
if (options.streaming) exporter.exportSpan = (span) => {
|
|
729
1218
|
sink.log(formatSpanLine(span));
|
|
1219
|
+
if (io) for (const line of formatSpanIO(span, 0, ioMaxChars)) sink.log(line);
|
|
730
1220
|
};
|
|
731
1221
|
return exporter;
|
|
732
1222
|
}
|
|
733
1223
|
/**
|
|
734
1224
|
* Write a completed trace — either a single root summary line or the
|
|
735
|
-
* full indented tree
|
|
736
|
-
*
|
|
1225
|
+
* full indented tree, each span optionally followed by its captured
|
|
1226
|
+
* `input` / `output`. Failed / cancelled spans route to `console.error`
|
|
1227
|
+
* so they surface at the right severity in log aggregators.
|
|
737
1228
|
*/
|
|
738
|
-
function writeTrace(sink, trace, tree) {
|
|
1229
|
+
function writeTrace(sink, trace, tree, io, ioMaxChars) {
|
|
739
1230
|
if (!tree) {
|
|
740
|
-
|
|
741
|
-
writeAtSeverity(sink, trace.root.status, line);
|
|
1231
|
+
writeSpan(sink, trace.root, 0, io, ioMaxChars);
|
|
742
1232
|
return;
|
|
743
1233
|
}
|
|
744
|
-
for (const span of walkSpans(trace.root))
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1234
|
+
for (const span of walkSpans(trace.root)) writeSpan(sink, span, spanDepth(trace.root, span.spanId), io, ioMaxChars);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Write one span's line and — when `io` is on — its captured content,
|
|
1238
|
+
* all routed at the span's own severity so a failed span keeps its
|
|
1239
|
+
* content beside it in the error stream.
|
|
1240
|
+
*/
|
|
1241
|
+
function writeSpan(sink, span, depth, io, ioMaxChars) {
|
|
1242
|
+
writeAtSeverity(sink, span.status, formatSpanLine(span, depth));
|
|
1243
|
+
if (io) for (const line of formatSpanIO(span, depth, ioMaxChars)) writeAtSeverity(sink, span.status, line);
|
|
748
1244
|
}
|
|
749
1245
|
/**
|
|
750
1246
|
* Route a line to `error` when the span failed/cancelled, otherwise to
|
|
@@ -809,9 +1305,12 @@ function fileExporter(options) {
|
|
|
809
1305
|
* lifetime; kept unexported so callers only ever see the factory.
|
|
810
1306
|
*/
|
|
811
1307
|
var FileTraceWriter = class {
|
|
1308
|
+
path;
|
|
1309
|
+
flushEvery;
|
|
1310
|
+
pretty;
|
|
1311
|
+
buffer = [];
|
|
1312
|
+
directoryReady = false;
|
|
812
1313
|
constructor(options) {
|
|
813
|
-
this.buffer = [];
|
|
814
|
-
this.directoryReady = false;
|
|
815
1314
|
this.path = options.path;
|
|
816
1315
|
this.flushEvery = Math.max(1, options.flushEvery ?? 1);
|
|
817
1316
|
this.pretty = options.pretty ?? false;
|
|
@@ -901,12 +1400,16 @@ function loadLangfuse() {
|
|
|
901
1400
|
* installed, and a missing SDK surfaces as a curated "install this"
|
|
902
1401
|
* error when the exporter first needs to build a client.
|
|
903
1402
|
*
|
|
904
|
-
* The root {@link TraceSpan} becomes a Langfuse trace
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
1403
|
+
* The root {@link TraceSpan} becomes a Langfuse trace AND its top
|
|
1404
|
+
* observation (so the root execution's own tokens are metered, not just
|
|
1405
|
+
* its children's); every node is an observation — a `generation` when it
|
|
1406
|
+
* metered its OWN tokens (LLM-backed agents, supervisors), otherwise a
|
|
1407
|
+
* plain `span` (tools, callbacks, and composite nodes whose tokens came
|
|
1408
|
+
* only from children). Timing, status, and version map 1:1; each
|
|
1409
|
+
* generation reports its own token usage (rolled-up minus children) so
|
|
1410
|
+
* the trace total isn't double-counted; non-usage GenAI attributes ride
|
|
1411
|
+
* along as metadata, and captured content (under `captureContent`)
|
|
1412
|
+
* surfaces as native `input` / `output`.
|
|
910
1413
|
*
|
|
911
1414
|
* @example
|
|
912
1415
|
* collector.use(langfuseExporter({ publicKey: "pk-...", secretKey: "sk-..." }));
|
|
@@ -948,20 +1451,23 @@ function langfuseExporter(options) {
|
|
|
948
1451
|
*/
|
|
949
1452
|
function emitTrace(client, trace) {
|
|
950
1453
|
const root = trace.root;
|
|
951
|
-
const
|
|
1454
|
+
const traceBody = {
|
|
952
1455
|
id: root.traceId,
|
|
953
1456
|
name: root.name,
|
|
954
1457
|
sessionId: trace.sessionId,
|
|
955
1458
|
version: root.version,
|
|
956
1459
|
timestamp: new Date(root.startedAt),
|
|
957
|
-
metadata:
|
|
958
|
-
}
|
|
959
|
-
|
|
1460
|
+
metadata: langfuseMetadata(root)
|
|
1461
|
+
};
|
|
1462
|
+
if (root.input !== void 0) traceBody.input = root.input;
|
|
1463
|
+
if (root.output !== void 0) traceBody.output = root.output;
|
|
1464
|
+
emitObservation(client.trace(traceBody), root);
|
|
960
1465
|
}
|
|
961
1466
|
/**
|
|
962
1467
|
* Map one {@link TraceSpan} onto a Langfuse observation under `parent`,
|
|
963
|
-
* then recurse its children.
|
|
964
|
-
* `generation`s; everything else
|
|
1468
|
+
* then recurse its children. Spans that metered their OWN tokens become
|
|
1469
|
+
* `generation`s; everything else (tools, callbacks, composite nodes)
|
|
1470
|
+
* becomes a plain `span`.
|
|
965
1471
|
*/
|
|
966
1472
|
function emitObservation(parent, span) {
|
|
967
1473
|
const body = {
|
|
@@ -972,14 +1478,17 @@ function emitObservation(parent, span) {
|
|
|
972
1478
|
level: toLevel(span),
|
|
973
1479
|
statusMessage: span.error?.message,
|
|
974
1480
|
version: span.version,
|
|
975
|
-
metadata:
|
|
1481
|
+
metadata: langfuseMetadata(span)
|
|
976
1482
|
};
|
|
1483
|
+
if (span.input !== void 0) body.input = span.input;
|
|
1484
|
+
if (span.output !== void 0) body.output = span.output;
|
|
977
1485
|
let observation;
|
|
978
|
-
|
|
1486
|
+
const own = ownUsage(span);
|
|
1487
|
+
if (own.total > 0) {
|
|
979
1488
|
body.usage = {
|
|
980
|
-
input:
|
|
981
|
-
output:
|
|
982
|
-
total:
|
|
1489
|
+
input: own.input,
|
|
1490
|
+
output: own.output,
|
|
1491
|
+
total: own.total,
|
|
983
1492
|
unit: "TOKENS"
|
|
984
1493
|
};
|
|
985
1494
|
observation = parent.generation(body);
|
|
@@ -988,20 +1497,60 @@ function emitObservation(parent, span) {
|
|
|
988
1497
|
observation.end({ endTime: body.endTime });
|
|
989
1498
|
}
|
|
990
1499
|
/**
|
|
991
|
-
*
|
|
992
|
-
*
|
|
1500
|
+
* Own token usage for a span — its rolled-up {@link TraceSpan.usage}
|
|
1501
|
+
* minus the rolled-up usage of its direct children. `TraceSpan.usage` is
|
|
1502
|
+
* the subtree total (this node plus every descendant), so subtracting the
|
|
1503
|
+
* children leaves the tokens THIS node alone metered, clamped at zero
|
|
1504
|
+
* defensively.
|
|
993
1505
|
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
|
|
1001
|
-
|
|
1506
|
+
* Langfuse sums observation usage into the trace total, so reporting
|
|
1507
|
+
* own-usage on each generation (rather than the subtree rollup) is what
|
|
1508
|
+
* keeps the trace total correct instead of multiply-counting nested
|
|
1509
|
+
* spans. A composite node with no own tokens (e.g. a workflow whose
|
|
1510
|
+
* tokens all came from agent children) yields `total: 0` and is emitted
|
|
1511
|
+
* as a plain span, not a generation.
|
|
1512
|
+
*/
|
|
1513
|
+
function ownUsage(span) {
|
|
1514
|
+
let childInput = 0;
|
|
1515
|
+
let childOutput = 0;
|
|
1516
|
+
let childTotal = 0;
|
|
1517
|
+
for (const child of span.children) {
|
|
1518
|
+
childInput += child.usage.input;
|
|
1519
|
+
childOutput += child.usage.output;
|
|
1520
|
+
childTotal += child.usage.total;
|
|
1521
|
+
}
|
|
1522
|
+
return {
|
|
1523
|
+
input: Math.max(0, span.usage.input - childInput),
|
|
1524
|
+
output: Math.max(0, span.usage.output - childOutput),
|
|
1525
|
+
total: Math.max(0, span.usage.total - childTotal)
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Token-usage + cost keys that the per-observation `usage` block already
|
|
1530
|
+
* carries authoritatively (as OWN usage). Omitting them from `metadata`
|
|
1531
|
+
* avoids a confusing contradiction — metadata would otherwise show the
|
|
1532
|
+
* rolled-up subtree totals next to an own-usage `usage` block. Cost is
|
|
1533
|
+
* omitted for the same reason; Langfuse prices the own tokens itself.
|
|
1534
|
+
*/
|
|
1535
|
+
const LANGFUSE_METADATA_OMIT = new Set([
|
|
1536
|
+
GEN_AI_ATTRIBUTES.usageInputTokens,
|
|
1537
|
+
GEN_AI_ATTRIBUTES.usageOutputTokens,
|
|
1538
|
+
WARLOCK_ATTRIBUTES.totalTokens,
|
|
1539
|
+
WARLOCK_ATTRIBUTES.cachedTokens,
|
|
1540
|
+
WARLOCK_ATTRIBUTES.reasoningTokens,
|
|
1541
|
+
WARLOCK_ATTRIBUTES.costUsd
|
|
1542
|
+
]);
|
|
1543
|
+
/**
|
|
1544
|
+
* Observation metadata — the GenAI attribute set minus the usage/cost
|
|
1545
|
+
* keys that live authoritatively on the `usage` block (see
|
|
1546
|
+
* {@link LANGFUSE_METADATA_OMIT}). Keeps model identity, report type,
|
|
1547
|
+
* version, session id, and any collector-set attributes.
|
|
1002
1548
|
*/
|
|
1003
|
-
function
|
|
1004
|
-
|
|
1549
|
+
function langfuseMetadata(span) {
|
|
1550
|
+
const all = toGenAiAttributes(span);
|
|
1551
|
+
const metadata = {};
|
|
1552
|
+
for (const [key, value] of Object.entries(all)) if (!LANGFUSE_METADATA_OMIT.has(key)) metadata[key] = value;
|
|
1553
|
+
return metadata;
|
|
1005
1554
|
}
|
|
1006
1555
|
/**
|
|
1007
1556
|
* Map the Panoptic span status onto a Langfuse observation level —
|
|
@@ -1113,9 +1662,24 @@ function emitSpan(tracer, span, parentContext, options) {
|
|
|
1113
1662
|
function applyAttributes(otelSpan, span, options) {
|
|
1114
1663
|
const attributes = toGenAiAttributes(span);
|
|
1115
1664
|
if (options.system !== void 0 && attributes[GEN_AI_ATTRIBUTES.system] === void 0) attributes[GEN_AI_ATTRIBUTES.system] = options.system;
|
|
1665
|
+
if (span.input !== void 0) attributes[GEN_AI_ATTRIBUTES.prompt] = stringifyContent(span.input);
|
|
1666
|
+
if (span.output !== void 0) attributes[GEN_AI_ATTRIBUTES.completion] = stringifyContent(span.output);
|
|
1116
1667
|
otelSpan.setAttributes(attributes);
|
|
1117
1668
|
}
|
|
1118
1669
|
/**
|
|
1670
|
+
* Coerce a captured content value to a string OTel attribute. Strings
|
|
1671
|
+
* pass through; structured values are JSON-encoded (falling back to
|
|
1672
|
+
* `String()` if they can't be serialized).
|
|
1673
|
+
*/
|
|
1674
|
+
function stringifyContent(value) {
|
|
1675
|
+
if (typeof value === "string") return value;
|
|
1676
|
+
try {
|
|
1677
|
+
return JSON.stringify(value) ?? String(value);
|
|
1678
|
+
} catch {
|
|
1679
|
+
return String(value);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1119
1683
|
* Map the Panoptic span status onto the OTel span status, recording the
|
|
1120
1684
|
* normalized error as an exception event + ERROR status when present.
|
|
1121
1685
|
*/
|
|
@@ -1241,8 +1805,16 @@ const DEFAULT_COMPLETED_EVENTS = [
|
|
|
1241
1805
|
* {@link panoptic}; callers never see `new`.
|
|
1242
1806
|
*/
|
|
1243
1807
|
var PanopticSubscriber = class {
|
|
1808
|
+
collector;
|
|
1809
|
+
completedEvents;
|
|
1810
|
+
middlewareName;
|
|
1244
1811
|
constructor(options = {}) {
|
|
1245
|
-
this.collector = options.collector ?? createCollector(
|
|
1812
|
+
this.collector = options.collector ?? createCollector({
|
|
1813
|
+
captureContent: options.captureContent,
|
|
1814
|
+
redactContent: options.redactContent,
|
|
1815
|
+
fullHistory: options.fullHistory,
|
|
1816
|
+
onError: options.onError
|
|
1817
|
+
});
|
|
1246
1818
|
for (const exporter of options.exporters ?? []) this.collector.use(exporter);
|
|
1247
1819
|
this.completedEvents = options.completedEvents ?? [...DEFAULT_COMPLETED_EVENTS];
|
|
1248
1820
|
this.middlewareName = options.middlewareName ?? "panoptic";
|
|
@@ -1345,26 +1917,2023 @@ function panoptic(options = {}) {
|
|
|
1345
1917
|
return new PanopticSubscriber(options);
|
|
1346
1918
|
}
|
|
1347
1919
|
|
|
1920
|
+
//#endregion
|
|
1921
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/parse-query.ts
|
|
1922
|
+
/**
|
|
1923
|
+
* The set of terminal statuses a stored trace can carry. Used to keep
|
|
1924
|
+
* `parseQuery` from forwarding arbitrary `?status=` junk into the store.
|
|
1925
|
+
*/
|
|
1926
|
+
const KNOWN_STATUSES = [
|
|
1927
|
+
"completed",
|
|
1928
|
+
"failed",
|
|
1929
|
+
"cancelled",
|
|
1930
|
+
"max-iterations",
|
|
1931
|
+
"awaiting-input",
|
|
1932
|
+
"awaiting-approval"
|
|
1933
|
+
];
|
|
1934
|
+
/**
|
|
1935
|
+
* Map a request's query string onto a {@link TraceQuery} the trace store
|
|
1936
|
+
* understands. Every field is optional — an absent param is "don't care",
|
|
1937
|
+
* so an empty query string yields an empty filter that matches every
|
|
1938
|
+
* stored trace.
|
|
1939
|
+
*
|
|
1940
|
+
* - `traceId` / `sessionId` — passed through verbatim (first value wins).
|
|
1941
|
+
* - `status` — **repeatable**: `?status=failed&status=cancelled` becomes
|
|
1942
|
+
* `["failed", "cancelled"]`; a single value stays a scalar. Unknown
|
|
1943
|
+
* status tokens are dropped so a typo never silently matches nothing in
|
|
1944
|
+
* a confusing way (it simply isn't filtered on).
|
|
1945
|
+
* - `startedAfter` / `startedBefore` — forwarded as ISO strings; the
|
|
1946
|
+
* store accepts either a string or `Date`, and `matchTrace` does the
|
|
1947
|
+
* inclusive bound comparison.
|
|
1948
|
+
*
|
|
1949
|
+
* Unknown params are ignored. Pure — takes a `URLSearchParams`, returns a
|
|
1950
|
+
* plain object — so it's trivially testable without a live server.
|
|
1951
|
+
*
|
|
1952
|
+
* @example
|
|
1953
|
+
* parseQuery(new URLSearchParams("status=failed&status=cancelled&sessionId=s1"));
|
|
1954
|
+
* // → { status: ["failed", "cancelled"], sessionId: "s1" }
|
|
1955
|
+
*/
|
|
1956
|
+
function parseQuery(params) {
|
|
1957
|
+
const query = {};
|
|
1958
|
+
const traceId = params.get("traceId");
|
|
1959
|
+
if (traceId !== null && traceId.length > 0) query.traceId = traceId;
|
|
1960
|
+
const sessionId = params.get("sessionId");
|
|
1961
|
+
if (sessionId !== null && sessionId.length > 0) query.sessionId = sessionId;
|
|
1962
|
+
const statuses = params.getAll("status").filter((value) => KNOWN_STATUSES.includes(value));
|
|
1963
|
+
if (statuses.length === 1) query.status = statuses[0];
|
|
1964
|
+
else if (statuses.length > 1) query.status = statuses;
|
|
1965
|
+
const startedAfter = params.get("startedAfter");
|
|
1966
|
+
if (startedAfter !== null && startedAfter.length > 0) query.startedAfter = startedAfter;
|
|
1967
|
+
const startedBefore = params.get("startedBefore");
|
|
1968
|
+
if (startedBefore !== null && startedBefore.length > 0) query.startedBefore = startedBefore;
|
|
1969
|
+
return query;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/warlock-logo.ts
|
|
1974
|
+
/**
|
|
1975
|
+
* The Warlock logo as an inlined base64 PNG data URI (64x64, ~9KB).
|
|
1976
|
+
* Inlined so the dashboard stays a single self-contained, offline-capable
|
|
1977
|
+
* page with no external asset request. Regenerate from
|
|
1978
|
+
* @warlock.js/docs/public/logo.png via:
|
|
1979
|
+
* magick logo.png -resize 64x64 -strip out.png && base64 -w0 out.png
|
|
1980
|
+
*/
|
|
1981
|
+
const WARLOCK_LOGO_DATA_URI = "data:image/png;base64,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";
|
|
1982
|
+
|
|
1983
|
+
//#endregion
|
|
1984
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/ui.html.ts
|
|
1985
|
+
/**
|
|
1986
|
+
* Build the single self-contained dashboard HTML page. No external
|
|
1987
|
+
* assets, no bundler, no framework — one inlined string that polls the
|
|
1988
|
+
* read-only JSON API (`{basePath}api/aggregate`, `{basePath}api/traces`,
|
|
1989
|
+
* `{basePath}api/traces/:id`) and renders:
|
|
1990
|
+
*
|
|
1991
|
+
* - the {@link TraceAggregate} headline counts (traces / completed /
|
|
1992
|
+
* failed / cancelled / tokens in·out·total / cost);
|
|
1993
|
+
* - a newest-first trace list as clickable master rows, each led by a
|
|
1994
|
+
* colour-coded, title-cased type label (Supervisor / Agent / Tool / …);
|
|
1995
|
+
* - a two-pane drawer: a collapsible call tree on the left (the nested
|
|
1996
|
+
* span hierarchy) and the selected node's detail on the right — rich
|
|
1997
|
+
* input/output, token breakdown, and a metadata panel (session id,
|
|
1998
|
+
* ids, version, attributes).
|
|
1999
|
+
*
|
|
2000
|
+
* Span input/output is rendered structurally (chat bubbles / key-value /
|
|
2001
|
+
* Markdown), durations in seconds, tokens as ↓input · ↑output · total.
|
|
2002
|
+
* Theme is light / dark / system (persisted to localStorage). All UI
|
|
2003
|
+
* state — selected trace, selected span, collapsed nodes — lives in JS,
|
|
2004
|
+
* NOT the DOM, so the 2s poll never disturbs an open drawer.
|
|
2005
|
+
*
|
|
2006
|
+
* The list is filterable entirely client-side over the polled traces: a
|
|
2007
|
+
* free-text search (name + session), status / type / session filter
|
|
2008
|
+
* chips, an "errors only" header toggle, and an optional group-by-session
|
|
2009
|
+
* view with collapsible headers. Each tree node and trace row carries a
|
|
2010
|
+
* cost heatmap accent scaled to the trace's most expensive node, with a
|
|
2011
|
+
* small legend. The drawer's left pane toggles between the nested call
|
|
2012
|
+
* TREE and a Gantt TIMELINE (span offset from root start + duration,
|
|
2013
|
+
* critical path highlighted). The selected trace and span are reflected
|
|
2014
|
+
* in the URL hash (`#trace=&span=`) and re-opened from it on load — so a
|
|
2015
|
+
* drawer view is shareable/bookmarkable. A live socket tail is a noted
|
|
2016
|
+
* follow-up; this pass stays on the 2s poll.
|
|
2017
|
+
*
|
|
2018
|
+
* `basePath` and `title` are baked in at serve time. The page is
|
|
2019
|
+
* intentionally dependency-free vanilla JS so it works offline.
|
|
2020
|
+
*
|
|
2021
|
+
* @param basePath Normalized mount path ending in `/` (e.g. `"/"`).
|
|
2022
|
+
* @param title Header title shown in the page.
|
|
2023
|
+
*/
|
|
2024
|
+
function dashboardHtml(basePath, title) {
|
|
2025
|
+
const apiBase = `${basePath}api`;
|
|
2026
|
+
const safeTitle = escapeHtml(title);
|
|
2027
|
+
return `<!doctype html>
|
|
2028
|
+
<html lang="en">
|
|
2029
|
+
<head>
|
|
2030
|
+
<meta charset="utf-8" />
|
|
2031
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2032
|
+
<title>${safeTitle}</title>
|
|
2033
|
+
<script>
|
|
2034
|
+
(function () {
|
|
2035
|
+
try {
|
|
2036
|
+
var m = localStorage.getItem("panoptic-theme") || "system";
|
|
2037
|
+
var light = m === "light" || (m === "system" && window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches);
|
|
2038
|
+
document.documentElement.setAttribute("data-theme", light ? "light" : "dark");
|
|
2039
|
+
} catch (e) {}
|
|
2040
|
+
})();
|
|
2041
|
+
<\/script>
|
|
2042
|
+
<style>
|
|
2043
|
+
:root {
|
|
2044
|
+
color-scheme: dark;
|
|
2045
|
+
--bg: #0b0d10; --surface: #101317; --surface2: #14171c; --panel: #0d1014;
|
|
2046
|
+
--border: #23272e; --border2: #2d333b;
|
|
2047
|
+
--text: #e6e8eb; --text2: #adbac7; --dim: #8b949e;
|
|
2048
|
+
--sel-bg: #0f1722; --sel-border: #316dca;
|
|
2049
|
+
--ty-agent: #539bf5; --ty-tool: #c297ff; --ty-model: #4cc2b0; --ty-prim: #e3b341; --ty-other: #adbac7;
|
|
2050
|
+
--tok-in: #58a6ff; --tok-out: #3fb950; --tok-total: #b899ff; --cost: #e3b341;
|
|
2051
|
+
--ok: #56d364; --ok-bg: #0f2e1d; --fail: #f85149; --fail-bg: #3a1416; --cancel: #d29922; --cancel-bg: #332701; --other: #8b949e; --other-bg: #1c2128;
|
|
2052
|
+
--code-bg: #0b0d10; --inline-bg: #1c2128; --link: #539bf5;
|
|
2053
|
+
}
|
|
2054
|
+
:root[data-theme="light"] {
|
|
2055
|
+
color-scheme: light;
|
|
2056
|
+
--bg: #ffffff; --surface: #f6f8fa; --surface2: #eef1f4; --panel: #ffffff;
|
|
2057
|
+
--border: #d0d7de; --border2: #afb8c1;
|
|
2058
|
+
--text: #1f2328; --text2: #3b4350; --dim: #636c76;
|
|
2059
|
+
--sel-bg: #ddf4ff; --sel-border: #0969da;
|
|
2060
|
+
--ty-agent: #0969da; --ty-tool: #8250df; --ty-model: #0f7d6b; --ty-prim: #9a6700; --ty-other: #57606a;
|
|
2061
|
+
--tok-in: #0969da; --tok-out: #1a7f37; --tok-total: #8250df; --cost: #9a6700;
|
|
2062
|
+
--ok: #1a7f37; --ok-bg: #dafbe1; --fail: #cf222e; --fail-bg: #ffebe9; --cancel: #9a6700; --cancel-bg: #fff8c5; --other: #57606a; --other-bg: #eaeef2;
|
|
2063
|
+
--code-bg: #f6f8fa; --inline-bg: #eaeef2; --link: #0969da;
|
|
2064
|
+
}
|
|
2065
|
+
* { box-sizing: border-box; }
|
|
2066
|
+
body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
2067
|
+
header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
|
|
2068
|
+
header img.logo { height: 26px; width: 26px; display: block; flex: none; }
|
|
2069
|
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
2070
|
+
header .meta { color: var(--dim); font-size: 12px; }
|
|
2071
|
+
.theme { margin-left: auto; display: flex; gap: 2px; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
|
|
2072
|
+
.theme button { background: transparent; border: none; color: var(--dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 4px 8px; border-radius: 6px; }
|
|
2073
|
+
.theme button:hover { color: var(--text); }
|
|
2074
|
+
.theme button.active { background: var(--surface2); color: var(--text); }
|
|
2075
|
+
.stats { display: flex; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
|
2076
|
+
.stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; min-width: 84px; }
|
|
2077
|
+
.stat .label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
|
2078
|
+
.stat .value { font-size: 18px; font-weight: 600; }
|
|
2079
|
+
main { padding: 12px 20px 40px; }
|
|
2080
|
+
|
|
2081
|
+
.trace-row { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: var(--surface); padding: 9px 12px; display: flex; align-items: center; gap: 9px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }
|
|
2082
|
+
.trace-row:hover { background: var(--surface2); border-color: var(--border2); }
|
|
2083
|
+
.trace-row.selected { border-color: var(--sel-border); background: var(--sel-bg); }
|
|
2084
|
+
.rname { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2085
|
+
.rprompt { font-size: 11px; color: var(--ty-agent); border: 1px solid var(--border2); border-radius: 6px; padding: 1px 6px; white-space: nowrap; }
|
|
2086
|
+
.rright { margin-left: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
2087
|
+
.chev { color: var(--dim); font-size: 16px; font-style: normal; }
|
|
2088
|
+
|
|
2089
|
+
.tylabel { font-weight: 600; flex: none; }
|
|
2090
|
+
.ty-agent { color: var(--ty-agent); } .ty-tool { color: var(--ty-tool); } .ty-model { color: var(--ty-model); } .ty-prim { color: var(--ty-prim); } .ty-other { color: var(--ty-other); }
|
|
2091
|
+
.badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; flex: none; }
|
|
2092
|
+
.badge.completed { background: var(--ok-bg); color: var(--ok); }
|
|
2093
|
+
.badge.failed { background: var(--fail-bg); color: var(--fail); }
|
|
2094
|
+
.badge.cancelled { background: var(--cancel-bg); color: var(--cancel); }
|
|
2095
|
+
.badge.other { background: var(--other-bg); color: var(--other); }
|
|
2096
|
+
.sdot { width: 7px; height: 7px; border-radius: 999px; display: inline-block; flex: none; }
|
|
2097
|
+
.sdot-completed { background: var(--ok); } .sdot-failed { background: var(--fail); } .sdot-cancelled { background: var(--cancel); } .sdot-other { background: var(--other); }
|
|
2098
|
+
.dim { color: var(--dim); font-size: 12px; }
|
|
2099
|
+
.tok { white-space: nowrap; font-size: 12px; }
|
|
2100
|
+
.tok-in { color: var(--tok-in); } .tok-out { color: var(--tok-out); } .tok-total { color: var(--tok-total); }
|
|
2101
|
+
.cost { color: var(--cost); font-weight: 600; font-size: 12px; white-space: nowrap; }
|
|
2102
|
+
|
|
2103
|
+
.io-body { margin-bottom: 6px; }
|
|
2104
|
+
.piol { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; margin: 11px 0 3px; }
|
|
2105
|
+
.messages { display: flex; flex-direction: column; gap: 8px; }
|
|
2106
|
+
.msg-collapse { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
|
2107
|
+
.msg-collapse > summary { cursor: pointer; padding: 7px 10px; list-style: none; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
|
2108
|
+
.msg-collapse > summary::-webkit-details-marker { display: none; }
|
|
2109
|
+
.msg-collapse > summary::before { content: "\\25B8"; color: var(--dim); }
|
|
2110
|
+
.msg-collapse[open] > summary::before { content: "\\25BE"; }
|
|
2111
|
+
.msg-collapse[open] > summary { border-bottom: 1px solid var(--border); }
|
|
2112
|
+
.msg-collapse .messages { padding: 8px; }
|
|
2113
|
+
.msg-collapse .msg-count { font-weight: 600; color: var(--text2); }
|
|
2114
|
+
.msg { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
|
|
2115
|
+
.msg-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; padding: 4px 10px; color: var(--text2); background: var(--surface2); border-bottom: 1px solid var(--border); }
|
|
2116
|
+
.msg-role.role-system { color: var(--cancel); } .msg-role.role-user { color: var(--ty-agent); } .msg-role.role-assistant { color: var(--ok); } .msg-role.role-tool { color: var(--ty-tool); }
|
|
2117
|
+
.msg-content { padding: 8px 10px; }
|
|
2118
|
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; align-items: start; }
|
|
2119
|
+
.kv-row { display: contents; }
|
|
2120
|
+
.kv-k { color: var(--dim); font-family: ui-monospace, monospace; font-size: 12px; padding: 2px 0; white-space: nowrap; }
|
|
2121
|
+
.kv-v { font-size: 13px; min-width: 0; overflow: auto; padding: 1px 0; word-break: break-word; }
|
|
2122
|
+
.part { border-left: 2px solid var(--border2); padding-left: 8px; margin: 4px 0; }
|
|
2123
|
+
.part-label { font-size: 11px; text-transform: uppercase; color: var(--dim); margin-bottom: 2px; }
|
|
2124
|
+
.md-h { font-weight: 700; margin: 8px 0 4px; }
|
|
2125
|
+
.md-h1 { font-size: 16px; } .md-h2 { font-size: 14px; } .md-h3 { font-size: 13px; color: var(--text2); } .md-h4 { font-size: 12px; color: var(--dim); }
|
|
2126
|
+
.md-p { margin: 4px 0; white-space: pre-wrap; word-break: break-word; }
|
|
2127
|
+
.io-body ul, .msg-content ul { margin: 4px 0; padding-left: 18px; }
|
|
2128
|
+
pre.code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; overflow: auto; max-height: 320px; font-size: 12px; white-space: pre; margin: 6px 0; }
|
|
2129
|
+
pre.mini { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; overflow: auto; max-height: 200px; font-size: 12px; margin: 0; }
|
|
2130
|
+
code { background: var(--inline-bg); border-radius: 4px; padding: 1px 4px; font-family: ui-monospace, monospace; font-size: 12px; }
|
|
2131
|
+
a { color: var(--link); }
|
|
2132
|
+
.empty { color: var(--dim); padding: 30px; text-align: center; }
|
|
2133
|
+
|
|
2134
|
+
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .18s ease; z-index: 40; }
|
|
2135
|
+
.backdrop.open { opacity: 1; pointer-events: auto; }
|
|
2136
|
+
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 96vw); background: var(--panel); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 50; display: flex; flex-direction: column; box-shadow: -16px 0 40px rgba(0,0,0,.4); }
|
|
2137
|
+
.drawer.open { transform: translateX(0); }
|
|
2138
|
+
.drawer-head { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
|
2139
|
+
.drawer-close { margin-left: auto; background: transparent; border: 1px solid var(--border); color: var(--dim); border-radius: 6px; cursor: pointer; font-size: 13px; line-height: 1; padding: 5px 9px; }
|
|
2140
|
+
.drawer-close:hover { color: var(--text); border-color: var(--border2); }
|
|
2141
|
+
.drawer-body { flex: 1; overflow: hidden; }
|
|
2142
|
+
.dsplit { display: flex; height: 100%; }
|
|
2143
|
+
.dtree { flex: 0 0 44%; overflow: auto; padding: 8px 6px; border-right: 1px solid var(--border); }
|
|
2144
|
+
.ddetail { flex: 1; overflow: auto; padding: 10px 14px; min-width: 0; }
|
|
2145
|
+
.tnode { display: flex; align-items: center; gap: 7px; padding: 5px 7px; border-radius: 6px; cursor: pointer; font-size: 13px; border: 1px solid transparent; }
|
|
2146
|
+
.tnode:hover { background: var(--surface); }
|
|
2147
|
+
.tnode.selected { background: var(--sel-bg); border-color: var(--sel-border); }
|
|
2148
|
+
.tname { color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2149
|
+
.tmeta { margin-left: auto; color: var(--dim); font-size: 12px; white-space: nowrap; }
|
|
2150
|
+
.twisty { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }
|
|
2151
|
+
.tkids { margin-left: 10px; padding-left: 9px; border-left: 1px solid var(--border); }
|
|
2152
|
+
.crumb { font-size: 12px; color: var(--dim); margin-bottom: 6px; word-break: break-all; }
|
|
2153
|
+
.crumb .sep { color: var(--border2); }
|
|
2154
|
+
.dhead-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
|
2155
|
+
.meta-sec { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
|
|
2156
|
+
.meta-title { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); margin-bottom: 6px; }
|
|
2157
|
+
|
|
2158
|
+
/* Toolbar: search box + filter chips over the trace list. */
|
|
2159
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }
|
|
2160
|
+
.search { flex: 1 1 220px; min-width: 160px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
|
2161
|
+
.search:focus { outline: none; border-color: var(--sel-border); }
|
|
2162
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
2163
|
+
.chip { background: var(--surface2); border: 1px solid var(--border); color: var(--text2); border-radius: 999px; padding: 4px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }
|
|
2164
|
+
.chip:hover { color: var(--text); border-color: var(--border2); }
|
|
2165
|
+
.chip.active { background: var(--sel-bg); border-color: var(--sel-border); color: var(--text); }
|
|
2166
|
+
.chip-clear { color: var(--dim); border-style: dashed; }
|
|
2167
|
+
.chip-group { display: inline-flex; gap: 6px; align-items: center; }
|
|
2168
|
+
.chip-group .gl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); }
|
|
2169
|
+
.toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: var(--text2); user-select: none; }
|
|
2170
|
+
.toggle input { accent-color: var(--sel-border); }
|
|
2171
|
+
.toggle select { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 2px 6px; font-size: 12px; cursor: pointer; }
|
|
2172
|
+
.toggle select:hover { border-color: var(--border2); }
|
|
2173
|
+
|
|
2174
|
+
/* Session grouping headers. */
|
|
2175
|
+
.sgroup { margin-bottom: 10px; }
|
|
2176
|
+
.sgroup-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; background: var(--surface2); border: 1px solid var(--border); cursor: pointer; margin-bottom: 6px; }
|
|
2177
|
+
.sgroup-head:hover { border-color: var(--border2); }
|
|
2178
|
+
.sgroup-tw { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }
|
|
2179
|
+
.sgroup-id { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2180
|
+
.sgroup-count { margin-left: auto; color: var(--dim); font-size: 12px; }
|
|
2181
|
+
.sgroup-body { padding-left: 6px; }
|
|
2182
|
+
|
|
2183
|
+
/* Per-type aggregate stats panel (a CSS-grid table above the trace list). */
|
|
2184
|
+
.stats-table { margin: 0 0 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; font-size: 12px; }
|
|
2185
|
+
.strow { display: grid; grid-template-columns: 1.5fr 0.7fr 1fr 0.8fr 0.8fr 1fr 1fr; gap: 10px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); }
|
|
2186
|
+
.strow:last-child { border-bottom: none; }
|
|
2187
|
+
.strow.sthead { background: var(--surface2); color: var(--dim); font-weight: 600; }
|
|
2188
|
+
.strow > span { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2189
|
+
.strow > .stc-type { text-align: left; }
|
|
2190
|
+
.stc-fail.fail { color: var(--fail); }
|
|
2191
|
+
|
|
2192
|
+
/* Cost heatmap: a left accent bar tinted by relative rollup cost. */
|
|
2193
|
+
.trace-row { position: relative; }
|
|
2194
|
+
.tnode { position: relative; }
|
|
2195
|
+
.heat { position: absolute; left: 0; top: 3px; bottom: 3px; width: 3px; border-radius: 2px; background: var(--cost); }
|
|
2196
|
+
|
|
2197
|
+
/* Timeline / waterfall view in the drawer. */
|
|
2198
|
+
.dview { display: flex; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
|
2199
|
+
.dview button { background: transparent; border: 1px solid var(--border); color: var(--dim); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; }
|
|
2200
|
+
.dview button:hover { color: var(--text); }
|
|
2201
|
+
.dview button.active { background: var(--surface2); color: var(--text); border-color: var(--border2); }
|
|
2202
|
+
.legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }
|
|
2203
|
+
.legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }
|
|
2204
|
+
.gantt { padding: 8px 10px; }
|
|
2205
|
+
.grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
|
|
2206
|
+
.grow:hover { background: var(--surface); }
|
|
2207
|
+
.grow.selected { background: var(--sel-bg); }
|
|
2208
|
+
.glabel { flex: 0 0 38%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }
|
|
2209
|
+
.gtrack { position: relative; flex: 1; height: 14px; background: var(--surface2); border-radius: 4px; overflow: hidden; }
|
|
2210
|
+
.gbar { position: absolute; top: 2px; bottom: 2px; border-radius: 3px; background: var(--ty-agent); min-width: 2px; }
|
|
2211
|
+
.gbar.crit { background: var(--fail); }
|
|
2212
|
+
.gbar.bar-completed { background: var(--ty-agent); } .gbar.bar-failed { background: var(--fail); } .gbar.bar-cancelled { background: var(--cancel); } .gbar.bar-other { background: var(--other); }
|
|
2213
|
+
.gdur { flex: 0 0 auto; color: var(--dim); white-space: nowrap; min-width: 44px; text-align: right; }
|
|
2214
|
+
.gantt-legend { display: flex; gap: 14px; margin-top: 8px; font-size: 11px; color: var(--dim); }
|
|
2215
|
+
.gantt-legend .ck { display: inline-flex; align-items: center; gap: 5px; }
|
|
2216
|
+
.gantt-legend .sw { width: 12px; height: 8px; border-radius: 2px; display: inline-block; }
|
|
2217
|
+
|
|
2218
|
+
@media (max-width: 560px) {
|
|
2219
|
+
.dsplit { flex-direction: column; }
|
|
2220
|
+
.dtree { flex: none; max-height: 42vh; border-right: none; border-bottom: 1px solid var(--border); }
|
|
2221
|
+
.glabel { flex-basis: 30%; }
|
|
2222
|
+
}
|
|
2223
|
+
</style>
|
|
2224
|
+
</head>
|
|
2225
|
+
<body>
|
|
2226
|
+
<header>
|
|
2227
|
+
<img class="logo" src="${WARLOCK_LOGO_DATA_URI}" alt="Warlock" />
|
|
2228
|
+
<h1>${safeTitle}</h1>
|
|
2229
|
+
<span class="meta" id="meta">connecting…</span>
|
|
2230
|
+
<div class="theme" id="theme" role="group" aria-label="Theme" style="margin-left:auto">
|
|
2231
|
+
<button type="button" data-theme-set="light" title="Light" aria-label="Light theme">☀</button>
|
|
2232
|
+
<button type="button" data-theme-set="dark" title="Dark" aria-label="Dark theme">☾</button>
|
|
2233
|
+
<button type="button" data-theme-set="system" title="System" aria-label="System theme">◐</button>
|
|
2234
|
+
</div>
|
|
2235
|
+
</header>
|
|
2236
|
+
<div class="stats" id="stats"></div>
|
|
2237
|
+
<div class="toolbar" id="toolbar">
|
|
2238
|
+
<input class="search" id="search" type="search" placeholder="Search name or session…" aria-label="Search traces" autocomplete="off" />
|
|
2239
|
+
<div class="chips" id="status-chips" role="group" aria-label="Filter by status"></div>
|
|
2240
|
+
<div class="chips" id="type-chips" role="group" aria-label="Filter by type"></div>
|
|
2241
|
+
<div class="chips" id="session-chips" role="group" aria-label="Filter by session"></div>
|
|
2242
|
+
<div class="chips" id="prompt-chips" role="group" aria-label="Filter by prompt version"></div>
|
|
2243
|
+
<label class="toggle" id="group-wrap" title="Group the trace list (mutually exclusive)">
|
|
2244
|
+
Group
|
|
2245
|
+
<select id="group-by" aria-label="Group the trace list">
|
|
2246
|
+
<option value="">None</option>
|
|
2247
|
+
<option value="session">Session</option>
|
|
2248
|
+
<option value="prompt">Prompt</option>
|
|
2249
|
+
<option value="type">Type</option>
|
|
2250
|
+
</select>
|
|
2251
|
+
</label>
|
|
2252
|
+
<label class="toggle" id="stats-wrap" title="Show a per-type aggregate stats panel (count, failure rate, p50/p95 latency, tokens, cost)">
|
|
2253
|
+
<input type="checkbox" id="show-stats" /> Stats
|
|
2254
|
+
</label>
|
|
2255
|
+
<button class="chip chip-clear" id="clear-filters" type="button" title="Clear all filters">Clear</button>
|
|
2256
|
+
</div>
|
|
2257
|
+
<div id="stats-panel"></div>
|
|
2258
|
+
<main id="traces"><div class="empty">Loading…</div></main>
|
|
2259
|
+
|
|
2260
|
+
<div class="backdrop" id="backdrop"></div>
|
|
2261
|
+
<aside class="drawer" id="drawer" aria-hidden="true" aria-label="Trace detail">
|
|
2262
|
+
<div class="drawer-head" id="drawer-head"></div>
|
|
2263
|
+
<div class="drawer-body" id="drawer-body"></div>
|
|
2264
|
+
</aside>
|
|
2265
|
+
|
|
2266
|
+
<script>
|
|
2267
|
+
(function () {
|
|
2268
|
+
var API = ${JSON.stringify(apiBase)};
|
|
2269
|
+
var BT = String.fromCharCode(96);
|
|
2270
|
+
|
|
2271
|
+
var state = {
|
|
2272
|
+
traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,
|
|
2273
|
+
// Client-side filter state (search box + chips + errors-only header toggle).
|
|
2274
|
+
filter: { text: "", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false },
|
|
2275
|
+
groupBySession: false, // session-grouping list toggle
|
|
2276
|
+
groupByPrompt: false, // prompt-version-grouping list toggle
|
|
2277
|
+
groupByType: false, // root-type-grouping list toggle
|
|
2278
|
+
showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)
|
|
2279
|
+
collapsedGroups: {}, // collapsed group headers (session, prompt, or type)
|
|
2280
|
+
view: "tree", // drawer left pane: "tree" | "timeline"
|
|
2281
|
+
hashApplied: false // guards one-time deep-link open on load
|
|
2282
|
+
};
|
|
2283
|
+
|
|
2284
|
+
var STATUS_FILTERS = ["completed", "failed", "cancelled"];
|
|
2285
|
+
var TYPE_FILTERS = ["agent", "tool", "model", "supervisor", "team", "workflow", "orchestrator", "planner", "batch", "callback"];
|
|
2286
|
+
var ERROR_STATUSES = { failed: 1, cancelled: 1 };
|
|
2287
|
+
var NO_SESSION_KEY = "(no session)";
|
|
2288
|
+
var NO_PROMPT_KEY = "(no prompt)";
|
|
2289
|
+
var NO_TYPE_KEY = "(no type)";
|
|
2290
|
+
|
|
2291
|
+
var statusClass = function (s) {
|
|
2292
|
+
if (s === "completed" || s === "failed" || s === "cancelled") return s;
|
|
2293
|
+
return "other";
|
|
2294
|
+
};
|
|
2295
|
+
var esc = function (v) {
|
|
2296
|
+
return String(v).replace(/[&<>"']/g, function (c) {
|
|
2297
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
2298
|
+
});
|
|
2299
|
+
};
|
|
2300
|
+
var num = function (n) { return (n == null ? 0 : n).toLocaleString(); };
|
|
2301
|
+
var fmt = function (v) {
|
|
2302
|
+
if (v == null) return "";
|
|
2303
|
+
if (typeof v === "string") return v;
|
|
2304
|
+
try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }
|
|
2305
|
+
};
|
|
2306
|
+
|
|
2307
|
+
function dur(ms) {
|
|
2308
|
+
if (ms == null) return "—";
|
|
2309
|
+
if (ms >= 1000) return (ms / 1000).toFixed(1) + "s";
|
|
2310
|
+
if (ms >= 1) return Math.round(ms) + "ms";
|
|
2311
|
+
return ms.toFixed(1) + "ms";
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function tokens(u) {
|
|
2315
|
+
if (!u) return "";
|
|
2316
|
+
var i = u.input || 0, o = u.output || 0, t = (u.total != null) ? u.total : (i + o);
|
|
2317
|
+
if (i === 0 && o === 0 && t === 0) return ""; // nothing to show — hide the row
|
|
2318
|
+
return '<span class="tok">'
|
|
2319
|
+
+ '<span class="tok-in" title="input tokens">↓ ' + num(i) + "</span> "
|
|
2320
|
+
+ '<span class="tok-out" title="output tokens">↑ ' + num(o) + "</span> "
|
|
2321
|
+
+ '<span class="tok-total" title="total tokens">' + num(t) + " total</span></span>";
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
var TYPE_LABELS = {
|
|
2325
|
+
agent: "Agent", tool: "Tool", model: "Model", supervisor: "Supervisor",
|
|
2326
|
+
workflow: "Workflow", orchestrator: "Orchestrator", team: "Team",
|
|
2327
|
+
planner: "Planner", prompt: "Prompt", guardrail: "Guardrail"
|
|
2328
|
+
};
|
|
2329
|
+
var PRIMITIVES = { supervisor: 1, workflow: 1, orchestrator: 1, team: 1, planner: 1 };
|
|
2330
|
+
function typeClass(type) {
|
|
2331
|
+
if (PRIMITIVES[type]) return "ty-prim";
|
|
2332
|
+
if (type === "agent") return "ty-agent";
|
|
2333
|
+
if (type === "tool") return "ty-tool";
|
|
2334
|
+
if (type === "model") return "ty-model";
|
|
2335
|
+
return "ty-other";
|
|
2336
|
+
}
|
|
2337
|
+
function typeLabel(type) {
|
|
2338
|
+
var t = type || "node";
|
|
2339
|
+
var label = TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));
|
|
2340
|
+
return '<span class="tylabel ' + typeClass(t) + '">' + esc(label) + "</span>";
|
|
2341
|
+
}
|
|
2342
|
+
// Plain-text (no span wrapper) capitalized labels for bare-text sites —
|
|
2343
|
+
// group headers, filter-chip labels — where the colored typeLabel span is
|
|
2344
|
+
// not wanted. Underlying keys/classes/filter values stay raw lowercase.
|
|
2345
|
+
function typeText(type) {
|
|
2346
|
+
var t = type || "node";
|
|
2347
|
+
return TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));
|
|
2348
|
+
}
|
|
2349
|
+
function statusText(s) {
|
|
2350
|
+
return s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : s;
|
|
2351
|
+
}
|
|
2352
|
+
function statusDot(s) { return '<span class="sdot sdot-' + statusClass(s) + '" title="' + esc(s) + '"></span>'; }
|
|
2353
|
+
|
|
2354
|
+
function findTrace(id) {
|
|
2355
|
+
for (var i = 0; i < state.traces.length; i++) if (state.traces[i].traceId === id) return state.traces[i];
|
|
2356
|
+
return null;
|
|
2357
|
+
}
|
|
2358
|
+
function findSpan(span, id) {
|
|
2359
|
+
if (span.spanId === id) return span;
|
|
2360
|
+
var k = span.children || [];
|
|
2361
|
+
for (var i = 0; i < k.length; i++) { var r = findSpan(k[i], id); if (r) return r; }
|
|
2362
|
+
return null;
|
|
2363
|
+
}
|
|
2364
|
+
function findPath(span, id, acc) {
|
|
2365
|
+
var p = (acc || []).concat([span]);
|
|
2366
|
+
if (span.spanId === id) return p;
|
|
2367
|
+
var k = span.children || [];
|
|
2368
|
+
for (var i = 0; i < k.length; i++) { var r = findPath(k[i], id, p); if (r) return r; }
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
function countSpans(span) {
|
|
2372
|
+
var n = 1, k = span.children || [];
|
|
2373
|
+
for (var i = 0; i < k.length; i++) n += countSpans(k[i]);
|
|
2374
|
+
return n;
|
|
2375
|
+
}
|
|
2376
|
+
function traceSig(t) { return t.root.status + "|" + countSpans(t.root) + "|" + t.duration + "|" + (t.usage && t.usage.total); }
|
|
2377
|
+
// Sum every priced lane of a cost object into one USD number.
|
|
2378
|
+
function costSumObj(c) {
|
|
2379
|
+
if (!c) return 0;
|
|
2380
|
+
return (c.input || 0) + (c.output || 0) + (c.cachedInput || 0) + (c.cachedOutput || 0) + (c.reasoning || 0);
|
|
2381
|
+
}
|
|
2382
|
+
// The cost a single span directly carries (on its rolled-up usage).
|
|
2383
|
+
function usageCost(usage) { return usage ? costSumObj(usage.cost) : 0; }
|
|
2384
|
+
// Rollup-aware subtree cost: take a node's own cost when it has one
|
|
2385
|
+
// (it already rolls up its trips); otherwise sum the children. This
|
|
2386
|
+
// avoids double-counting on wrapper nodes (workflow/supervisor roots
|
|
2387
|
+
// carry tokens but no cost, so we descend to the priced agent/model).
|
|
2388
|
+
function rollupCost(span) {
|
|
2389
|
+
var own = usageCost(span.usage);
|
|
2390
|
+
if (own > 0) return own;
|
|
2391
|
+
var sum = 0;
|
|
2392
|
+
(span.children || []).forEach(function (c) { sum += rollupCost(c); });
|
|
2393
|
+
return sum;
|
|
2394
|
+
}
|
|
2395
|
+
function traceCost(t) {
|
|
2396
|
+
var explicit = costSumObj(t.cost);
|
|
2397
|
+
return explicit > 0 ? explicit : rollupCost(t.root);
|
|
2398
|
+
}
|
|
2399
|
+
// Format a USD amount; tiny per-node costs need more decimals to read.
|
|
2400
|
+
function money(n) {
|
|
2401
|
+
if (!n) return "$0";
|
|
2402
|
+
return "$" + (n < 0.01 ? n.toFixed(6) : n.toFixed(4));
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// --- Client-side filtering / grouping / heatmap ----------------------
|
|
2406
|
+
// These mirror the pure, unit-tested helpers in trace-filter.ts. Keep
|
|
2407
|
+
// the two in sync: trace-filter.ts is the spec, this is its inlined twin.
|
|
2408
|
+
function anySelected(map) {
|
|
2409
|
+
for (var k in map) { if (map[k]) return true; }
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
function matchesFilter(t) {
|
|
2413
|
+
var f = state.filter, root = t.root;
|
|
2414
|
+
if (f.errorsOnly && !ERROR_STATUSES[root.status]) return false;
|
|
2415
|
+
if (anySelected(f.statuses) && !f.statuses[root.status]) return false;
|
|
2416
|
+
if (anySelected(f.types) && !f.types[root.type]) return false;
|
|
2417
|
+
if (f.sessionId && t.sessionId !== f.sessionId) return false;
|
|
2418
|
+
if (f.promptKey && tracePromptKey(t) !== f.promptKey) return false;
|
|
2419
|
+
var text = (f.text || "").trim().toLowerCase();
|
|
2420
|
+
if (text) {
|
|
2421
|
+
var hay = (String(root.name) + " " + (t.sessionId || "")).toLowerCase();
|
|
2422
|
+
if (hay.indexOf(text) === -1) return false;
|
|
2423
|
+
}
|
|
2424
|
+
return true;
|
|
2425
|
+
}
|
|
2426
|
+
function filteredTraces() {
|
|
2427
|
+
var out = [];
|
|
2428
|
+
for (var i = 0; i < state.traces.length; i++) if (matchesFilter(state.traces[i])) out.push(state.traces[i]);
|
|
2429
|
+
return out;
|
|
2430
|
+
}
|
|
2431
|
+
function groupBySession(list) {
|
|
2432
|
+
var order = [], byKey = {};
|
|
2433
|
+
for (var i = 0; i < list.length; i++) {
|
|
2434
|
+
var key = list[i].sessionId || NO_SESSION_KEY;
|
|
2435
|
+
if (!byKey[key]) { byKey[key] = []; order.push(key); }
|
|
2436
|
+
byKey[key].push(list[i]);
|
|
2437
|
+
}
|
|
2438
|
+
return order.map(function (k) { return { sessionId: k, traces: byKey[k] }; });
|
|
2439
|
+
}
|
|
2440
|
+
// Group by prompt version (name@version) — the second group-by dimension
|
|
2441
|
+
// beside session. Mirrors groupByPrompt in trace-filter.ts. Unlinked runs
|
|
2442
|
+
// bucket under NO_PROMPT_KEY so they stay visible.
|
|
2443
|
+
function groupByPrompt(list) {
|
|
2444
|
+
var order = [], byKey = {};
|
|
2445
|
+
for (var i = 0; i < list.length; i++) {
|
|
2446
|
+
var key = tracePromptKey(list[i]) || NO_PROMPT_KEY;
|
|
2447
|
+
if (!byKey[key]) { byKey[key] = []; order.push(key); }
|
|
2448
|
+
byKey[key].push(list[i]);
|
|
2449
|
+
}
|
|
2450
|
+
return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });
|
|
2451
|
+
}
|
|
2452
|
+
// Group by root type (agent/workflow/supervisor/planner/…) — the coarsest
|
|
2453
|
+
// group-by dimension. Mirrors groupByType in trace-filter.ts. root.type is
|
|
2454
|
+
// always present, so the NO_TYPE_KEY bucket is only a defensive fallback.
|
|
2455
|
+
function typeGroups(list) {
|
|
2456
|
+
var order = [], byKey = {};
|
|
2457
|
+
for (var i = 0; i < list.length; i++) {
|
|
2458
|
+
var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;
|
|
2459
|
+
if (!byKey[key]) { byKey[key] = []; order.push(key); }
|
|
2460
|
+
byKey[key].push(list[i]);
|
|
2461
|
+
}
|
|
2462
|
+
return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });
|
|
2463
|
+
}
|
|
2464
|
+
// Inlined twins of percentile + aggregateByType in trace-filter.ts (the
|
|
2465
|
+
// spec). Power the per-type stats panel from the same filtered list the
|
|
2466
|
+
// trace view renders, so the panel honors active filters with no API call.
|
|
2467
|
+
function percentile(values, p) {
|
|
2468
|
+
if (!values.length) return 0;
|
|
2469
|
+
var sorted = values.slice().sort(function (a, b) { return a - b; });
|
|
2470
|
+
var rank = Math.ceil((p / 100) * sorted.length) - 1;
|
|
2471
|
+
var index = Math.min(Math.max(rank, 0), sorted.length - 1);
|
|
2472
|
+
return sorted[index];
|
|
2473
|
+
}
|
|
2474
|
+
function aggregateByType(list) {
|
|
2475
|
+
var order = [], byKey = {};
|
|
2476
|
+
for (var i = 0; i < list.length; i++) {
|
|
2477
|
+
var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;
|
|
2478
|
+
if (!byKey[key]) { byKey[key] = []; order.push(key); }
|
|
2479
|
+
byKey[key].push(list[i]);
|
|
2480
|
+
}
|
|
2481
|
+
return order.map(function (k) {
|
|
2482
|
+
var bucket = byKey[k];
|
|
2483
|
+
var durations = [], failed = 0, tokens = 0, cost = 0;
|
|
2484
|
+
for (var j = 0; j < bucket.length; j++) {
|
|
2485
|
+
var t = bucket[j];
|
|
2486
|
+
durations.push(t.duration);
|
|
2487
|
+
if (ERROR_STATUSES[t.root.status]) failed += 1;
|
|
2488
|
+
tokens += (t.usage && t.usage.total) || 0;
|
|
2489
|
+
cost += traceCost(t);
|
|
2490
|
+
}
|
|
2491
|
+
return {
|
|
2492
|
+
type: k, count: bucket.length, failed: failed,
|
|
2493
|
+
failRate: bucket.length ? failed / bucket.length : 0,
|
|
2494
|
+
p50: percentile(durations, 50), p95: percentile(durations, 95),
|
|
2495
|
+
tokens: tokens, cost: cost
|
|
2496
|
+
};
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
// Per-type aggregate panel above the trace list. Honors the active filters
|
|
2500
|
+
// (computed over filteredTraces) and re-renders on every poll/filter tick.
|
|
2501
|
+
function renderStatsPanel() {
|
|
2502
|
+
var host = document.getElementById("stats-panel");
|
|
2503
|
+
if (!host) return;
|
|
2504
|
+
var stats = state.showStats ? aggregateByType(filteredTraces()) : [];
|
|
2505
|
+
if (!stats.length) { host.innerHTML = ""; return; }
|
|
2506
|
+
var head = '<div class="strow sthead">'
|
|
2507
|
+
+ '<span class="stc-type">Type</span><span>Count</span><span>Failed</span>'
|
|
2508
|
+
+ '<span>p50</span><span>p95</span><span>Tokens</span><span>Cost</span></div>';
|
|
2509
|
+
var rows = stats.map(function (s) {
|
|
2510
|
+
var failTxt = s.failed
|
|
2511
|
+
? s.failed + " (" + Math.round(s.failRate * 100) + "%)"
|
|
2512
|
+
: "0";
|
|
2513
|
+
return '<div class="strow">'
|
|
2514
|
+
+ '<span class="stc-type">' + typeLabel(s.type) + "</span>"
|
|
2515
|
+
+ '<span>' + num(s.count) + "</span>"
|
|
2516
|
+
+ '<span class="stc-fail' + (s.failed ? " fail" : "") + '">' + failTxt + "</span>"
|
|
2517
|
+
+ '<span class="dim">' + dur(s.p50) + "</span>"
|
|
2518
|
+
+ '<span class="dim">' + dur(s.p95) + "</span>"
|
|
2519
|
+
+ '<span>' + num(s.tokens) + "</span>"
|
|
2520
|
+
+ '<span class="cost">' + money(s.cost) + "</span>"
|
|
2521
|
+
+ "</div>";
|
|
2522
|
+
}).join("");
|
|
2523
|
+
host.innerHTML = '<div class="stats-table">' + head + rows + "</div>";
|
|
2524
|
+
}
|
|
2525
|
+
// Largest single-node rollup cost in a subtree — heatmap denominator.
|
|
2526
|
+
function maxNodeCost(span) {
|
|
2527
|
+
var max = rollupCost(span);
|
|
2528
|
+
(span.children || []).forEach(function (c) { var m = maxNodeCost(c); if (m > max) max = m; });
|
|
2529
|
+
return max;
|
|
2530
|
+
}
|
|
2531
|
+
// Intensity in [0,1] of a node's cost vs the trace max. Free trace → 0.
|
|
2532
|
+
function heatIntensity(nodeCost, maxCost) {
|
|
2533
|
+
if (maxCost <= 0 || nodeCost <= 0) return 0;
|
|
2534
|
+
var r = nodeCost / maxCost;
|
|
2535
|
+
return r > 1 ? 1 : r;
|
|
2536
|
+
}
|
|
2537
|
+
// The distinct sessionIds present across the polled traces, first-seen
|
|
2538
|
+
// order, capped so the chip row never overflows the toolbar.
|
|
2539
|
+
function presentSessions() {
|
|
2540
|
+
var seen = {}, out = [];
|
|
2541
|
+
for (var i = 0; i < state.traces.length && out.length < 12; i++) {
|
|
2542
|
+
var s = state.traces[i].sessionId;
|
|
2543
|
+
if (s && !seen[s]) { seen[s] = 1; out.push(s); }
|
|
2544
|
+
}
|
|
2545
|
+
return out;
|
|
2546
|
+
}
|
|
2547
|
+
// The root types actually present in the polled traces — so the type filter
|
|
2548
|
+
// chips show only what exists (no dead "Tool"/"Model"/"Batch"/… chips), in
|
|
2549
|
+
// canonical TYPE_FILTERS order. An active-but-aged-out selection stays so
|
|
2550
|
+
// the filter is never stranded with no chip to clear it.
|
|
2551
|
+
function presentTypes() {
|
|
2552
|
+
var seen = {};
|
|
2553
|
+
for (var i = 0; i < state.traces.length; i++) {
|
|
2554
|
+
var t = state.traces[i].root && state.traces[i].root.type;
|
|
2555
|
+
if (t) seen[t] = 1;
|
|
2556
|
+
}
|
|
2557
|
+
for (var k in state.filter.types) { if (state.filter.types[k]) seen[k] = 1; }
|
|
2558
|
+
var out = [];
|
|
2559
|
+
for (var j = 0; j < TYPE_FILTERS.length; j++) {
|
|
2560
|
+
if (seen[TYPE_FILTERS[j]]) { out.push(TYPE_FILTERS[j]); delete seen[TYPE_FILTERS[j]]; }
|
|
2561
|
+
}
|
|
2562
|
+
for (var x in seen) { out.push(x); }
|
|
2563
|
+
return out;
|
|
2564
|
+
}
|
|
2565
|
+
// The distinct prompt name@version keys present across the polled traces,
|
|
2566
|
+
// first-seen order, capped so the chip row never overflows the toolbar.
|
|
2567
|
+
function presentPrompts() {
|
|
2568
|
+
var seen = {}, out = [];
|
|
2569
|
+
for (var i = 0; i < state.traces.length && out.length < 12; i++) {
|
|
2570
|
+
var p = tracePromptKey(state.traces[i]);
|
|
2571
|
+
if (p && !seen[p]) { seen[p] = 1; out.push(p); }
|
|
2572
|
+
}
|
|
2573
|
+
return out;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function mdInline(s) {
|
|
2577
|
+
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>");
|
|
2578
|
+
var codeRe = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
|
|
2579
|
+
s = s.replace(codeRe, "<code>$1</code>");
|
|
2580
|
+
s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
2581
|
+
return s;
|
|
2582
|
+
}
|
|
2583
|
+
function mdToHtml(raw) {
|
|
2584
|
+
var src = esc(String(raw));
|
|
2585
|
+
var fence = BT + BT + BT;
|
|
2586
|
+
var html = "", idx = 0;
|
|
2587
|
+
while (true) {
|
|
2588
|
+
var start = src.indexOf(fence, idx);
|
|
2589
|
+
if (start === -1) { html += mdBlocks(src.slice(idx)); break; }
|
|
2590
|
+
html += mdBlocks(src.slice(idx, start));
|
|
2591
|
+
var nl = src.indexOf("\\n", start + 3);
|
|
2592
|
+
var bodyStart = (nl === -1) ? start + 3 : nl + 1;
|
|
2593
|
+
var end = src.indexOf(fence, bodyStart);
|
|
2594
|
+
if (end === -1) { html += mdBlocks(src.slice(start)); break; }
|
|
2595
|
+
html += '<pre class="code">' + src.slice(bodyStart, end).replace(/\\n$/, "") + "</pre>";
|
|
2596
|
+
idx = end + 3;
|
|
2597
|
+
}
|
|
2598
|
+
return html;
|
|
2599
|
+
}
|
|
2600
|
+
function mdBlocks(src) {
|
|
2601
|
+
var lines = src.split("\\n"), html = "", inList = false;
|
|
2602
|
+
function closeList() { if (inList) { html += "</ul>"; inList = false; } }
|
|
2603
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2604
|
+
var ln = lines[i];
|
|
2605
|
+
var h = ln.match(/^(#{1,4})\\s+(.*)$/);
|
|
2606
|
+
if (h) { closeList(); html += '<div class="md-h md-h' + h[1].length + '">' + mdInline(h[2]) + "</div>"; continue; }
|
|
2607
|
+
var li = ln.match(/^\\s*[-*]\\s+(.*)$/);
|
|
2608
|
+
if (li) { if (!inList) { html += "<ul>"; inList = true; } html += "<li>" + mdInline(li[1]) + "</li>"; continue; }
|
|
2609
|
+
if (ln.trim() === "") { closeList(); continue; }
|
|
2610
|
+
closeList();
|
|
2611
|
+
html += '<div class="md-p">' + mdInline(ln) + "</div>";
|
|
2612
|
+
}
|
|
2613
|
+
closeList();
|
|
2614
|
+
return html;
|
|
2615
|
+
}
|
|
2616
|
+
function renderKv(obj) {
|
|
2617
|
+
var keys = Object.keys(obj);
|
|
2618
|
+
if (!keys.length) return '<span class="dim">{}</span>';
|
|
2619
|
+
return '<div class="kv">' + keys.map(function (k) {
|
|
2620
|
+
var v = obj[k], vs;
|
|
2621
|
+
if (v === null || v === undefined) vs = '<span class="dim">null</span>';
|
|
2622
|
+
else if (typeof v === "object") vs = '<pre class="mini">' + esc(fmt(v)) + "</pre>";
|
|
2623
|
+
else vs = esc(String(v));
|
|
2624
|
+
return '<div class="kv-row"><span class="kv-k">' + esc(k) + '</span><span class="kv-v">' + vs + "</span></div>";
|
|
2625
|
+
}).join("") + "</div>";
|
|
2626
|
+
}
|
|
2627
|
+
function isMessageArray(v) {
|
|
2628
|
+
return Array.isArray(v) && v.length > 0 && v.every(function (m) { return m && typeof m === "object" && typeof m.role === "string"; });
|
|
2629
|
+
}
|
|
2630
|
+
function renderContent(c) {
|
|
2631
|
+
if (c == null) return '<span class="dim">—</span>';
|
|
2632
|
+
if (typeof c === "string") return mdToHtml(c);
|
|
2633
|
+
if (Array.isArray(c)) return c.map(renderPart).join("");
|
|
2634
|
+
if (typeof c === "object") return renderKv(c);
|
|
2635
|
+
return esc(String(c));
|
|
2636
|
+
}
|
|
2637
|
+
function renderPart(p) {
|
|
2638
|
+
if (p == null) return "";
|
|
2639
|
+
if (typeof p === "string") return mdToHtml(p);
|
|
2640
|
+
if (p.type === "text" && typeof p.text === "string") return mdToHtml(p.text);
|
|
2641
|
+
return '<div class="part"><div class="part-label">' + esc(p.type || "part") + "</div>" + renderKv(p) + "</div>";
|
|
2642
|
+
}
|
|
2643
|
+
function previewText(c) {
|
|
2644
|
+
if (c == null) return "";
|
|
2645
|
+
if (typeof c === "string") return c.replace(/\\s+/g, " ").slice(0, 70);
|
|
2646
|
+
if (Array.isArray(c)) {
|
|
2647
|
+
for (var i = 0; i < c.length; i++) {
|
|
2648
|
+
var p = c[i];
|
|
2649
|
+
if (typeof p === "string") return p.slice(0, 70);
|
|
2650
|
+
if (p && p.type === "text" && p.text) return String(p.text).slice(0, 70);
|
|
2651
|
+
}
|
|
2652
|
+
return "";
|
|
2653
|
+
}
|
|
2654
|
+
try { return JSON.stringify(c).slice(0, 70); } catch (e) { return ""; }
|
|
2655
|
+
}
|
|
2656
|
+
function renderMsg(m) {
|
|
2657
|
+
var role = m.role || "msg";
|
|
2658
|
+
var body = (m.content !== undefined) ? renderContent(m.content) : renderKv(m);
|
|
2659
|
+
return '<div class="msg"><div class="msg-role role-' + esc(role) + '">' + esc(role) + '</div><div class="msg-content">' + body + "</div></div>";
|
|
2660
|
+
}
|
|
2661
|
+
function renderMessages(arr) {
|
|
2662
|
+
var inner = '<div class="messages">' + arr.map(renderMsg).join("") + "</div>";
|
|
2663
|
+
// Short threads render inline; a long history (10-15+ messages) collapses
|
|
2664
|
+
// behind a <details> so it doesn't blow up the detail pane — the summary
|
|
2665
|
+
// shows the count + a preview of the latest message; click to expand.
|
|
2666
|
+
if (arr.length <= 6) return inner;
|
|
2667
|
+
var last = arr[arr.length - 1] || {};
|
|
2668
|
+
var preview = (last.role ? last.role + ": " : "") + previewText(last.content);
|
|
2669
|
+
return '<details class="msg-collapse"><summary><span class="msg-count">' + arr.length
|
|
2670
|
+
+ ' messages</span> <span class="dim">' + esc(preview) + "</span></summary>" + inner + "</details>";
|
|
2671
|
+
}
|
|
2672
|
+
function smartValue(v) {
|
|
2673
|
+
if (v === null || v === undefined) return '<span class="dim">—</span>';
|
|
2674
|
+
if (typeof v === "string") {
|
|
2675
|
+
var t = v.trim();
|
|
2676
|
+
if (t.charAt(0) === "{" || t.charAt(0) === "[") { try { return smartValue(JSON.parse(t)); } catch (e) {} }
|
|
2677
|
+
return mdToHtml(v);
|
|
2678
|
+
}
|
|
2679
|
+
if (isMessageArray(v)) return renderMessages(v);
|
|
2680
|
+
if (Array.isArray(v)) {
|
|
2681
|
+
return '<div class="kv">' + v.map(function (item, i) {
|
|
2682
|
+
var vs = (item && typeof item === "object") ? renderKv(item) : esc(String(item));
|
|
2683
|
+
return '<div class="kv-row"><span class="kv-k">' + i + '</span><span class="kv-v">' + vs + "</span></div>";
|
|
2684
|
+
}).join("") + "</div>";
|
|
2685
|
+
}
|
|
2686
|
+
if (typeof v === "object") return renderKv(v);
|
|
2687
|
+
return esc(String(v));
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function renderStats(a) {
|
|
2691
|
+
var cost = a.cost ? (((a.cost.input || 0) + (a.cost.output || 0) + (a.cost.cachedInput || 0) + (a.cost.cachedOutput || 0)).toFixed(4)) : "—";
|
|
2692
|
+
var u = a.usage || {};
|
|
2693
|
+
var cells = [
|
|
2694
|
+
["traces", num(a.traces), null],
|
|
2695
|
+
["completed", num(a.completed), null],
|
|
2696
|
+
["failed", num(a.failed), null],
|
|
2697
|
+
["cancelled", num(a.cancelled), null],
|
|
2698
|
+
["↓ tokens in", num(u.input), "var(--tok-in)"],
|
|
2699
|
+
["↑ tokens out", num(u.output), "var(--tok-out)"],
|
|
2700
|
+
["tokens total", num(u.total), "var(--tok-total)"],
|
|
2701
|
+
["cost (usd)", cost, null]
|
|
2702
|
+
];
|
|
2703
|
+
document.getElementById("stats").innerHTML = cells.map(function (c) {
|
|
2704
|
+
var st = c[2] ? ' style="color:' + c[2] + '"' : "";
|
|
2705
|
+
return '<div class="stat"><div class="label"' + st + ">" + c[0] + '</div><div class="value"' + st + ">" + esc(c[1]) + "</div></div>";
|
|
2706
|
+
}).join("");
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// Per-row heat accent: tint the left edge by the trace's own cost
|
|
2710
|
+
// relative to the most expensive trace currently in the (filtered) list.
|
|
2711
|
+
function heatStyle(intensity) {
|
|
2712
|
+
if (intensity <= 0) return "";
|
|
2713
|
+
var op = (0.12 + intensity * 0.88).toFixed(3);
|
|
2714
|
+
return ' style="--heat-op:' + op + '"';
|
|
2715
|
+
}
|
|
2716
|
+
function heatBar(intensity) {
|
|
2717
|
+
if (intensity <= 0) return "";
|
|
2718
|
+
var op = (0.12 + intensity * 0.88).toFixed(3);
|
|
2719
|
+
return '<span class="heat" style="opacity:' + op + '" title="relative cost"></span>';
|
|
2720
|
+
}
|
|
2721
|
+
function renderRow(t, maxTraceCost) {
|
|
2722
|
+
var sel = t.traceId === state.selectedId ? " selected" : "";
|
|
2723
|
+
var intensity = heatIntensity(traceCost(t), maxTraceCost);
|
|
2724
|
+
var pk = tracePromptKey(t);
|
|
2725
|
+
return '<div class="trace-row' + sel + '" data-id="' + esc(t.traceId) + '">'
|
|
2726
|
+
+ heatBar(intensity)
|
|
2727
|
+
+ typeLabel(t.root.type)
|
|
2728
|
+
+ '<span class="badge ' + statusClass(t.root.status) + '">' + esc(statusText(t.root.status)) + "</span>"
|
|
2729
|
+
+ '<span class="rname">' + esc(t.root.name) + "</span>"
|
|
2730
|
+
+ (pk ? '<span class="rprompt" title="prompt version">' + esc(pk) + "</span>" : "")
|
|
2731
|
+
+ (t.sessionId ? '<span class="dim">' + esc(t.sessionId) + "</span>" : "")
|
|
2732
|
+
+ '<span class="rright">' + tokens(t.usage) + '<span class="dim">· ' + dur(t.duration) + "</span></span>"
|
|
2733
|
+
+ '<i class="chev">›</i>'
|
|
2734
|
+
+ "</div>";
|
|
2735
|
+
}
|
|
2736
|
+
function maxTraceCostOf(list) {
|
|
2737
|
+
var max = 0;
|
|
2738
|
+
for (var i = 0; i < list.length; i++) { var c = traceCost(list[i]); if (c > max) max = c; }
|
|
2739
|
+
return max;
|
|
2740
|
+
}
|
|
2741
|
+
// Render grouped buckets. "groups" is the normalized list each group-by
|
|
2742
|
+
// dimension produces — a groupKey + its traces. Shared by session, prompt,
|
|
2743
|
+
// and type. Optional "labelFn" maps the raw groupKey to a display label and
|
|
2744
|
+
// switches the header to the compact "Label (N)" form (used by type, whose
|
|
2745
|
+
// keys are friendly enums); session/prompt omit it and keep their raw id
|
|
2746
|
+
// plus the right-aligned "N trace(s)" count. data-group stays the RAW key
|
|
2747
|
+
// so collapse state keys consistently regardless of the display label.
|
|
2748
|
+
function renderGroups(groups, maxTraceCost, labelFn) {
|
|
2749
|
+
return groups.map(function (g) {
|
|
2750
|
+
var collapsed = !!state.collapsedGroups[g.groupKey];
|
|
2751
|
+
var idAndCount = labelFn
|
|
2752
|
+
? '<span class="sgroup-id">' + esc(labelFn(g.groupKey)) + " (" + g.traces.length + ")</span>"
|
|
2753
|
+
: '<span class="sgroup-id">' + esc(g.groupKey) + "</span>"
|
|
2754
|
+
+ '<span class="sgroup-count">' + g.traces.length + " trace(s)</span>";
|
|
2755
|
+
var head = '<div class="sgroup-head" data-group="' + esc(g.groupKey) + '">'
|
|
2756
|
+
+ '<span class="sgroup-tw">' + (collapsed ? "▸" : "▾") + "</span>"
|
|
2757
|
+
+ idAndCount + "</div>";
|
|
2758
|
+
var body = collapsed ? "" : '<div class="sgroup-body">'
|
|
2759
|
+
+ g.traces.map(function (t) { return renderRow(t, maxTraceCost); }).join("") + "</div>";
|
|
2760
|
+
return '<div class="sgroup">' + head + body + "</div>";
|
|
2761
|
+
}).join("");
|
|
2762
|
+
}
|
|
2763
|
+
// Normalize a session group ({sessionId,…}) to the shared {groupKey,…} shape.
|
|
2764
|
+
function sessionGroups(list) {
|
|
2765
|
+
return groupBySession(list).map(function (g) { return { groupKey: g.sessionId, traces: g.traces }; });
|
|
2766
|
+
}
|
|
2767
|
+
function renderList() {
|
|
2768
|
+
var list = filteredTraces();
|
|
2769
|
+
// Keep the per-type stats panel in sync — runs on every poll + filter
|
|
2770
|
+
// change, before the empty-state early return below.
|
|
2771
|
+
renderStatsPanel();
|
|
2772
|
+
var host = document.getElementById("traces");
|
|
2773
|
+
if (!list.length) {
|
|
2774
|
+
host.innerHTML = state.traces.length
|
|
2775
|
+
? '<div class="empty">No traces match the current filters.</div>'
|
|
2776
|
+
: '<div class="empty">No traces yet. Run an observed flow and they will appear here.</div>';
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
var maxTraceCost = maxTraceCostOf(list);
|
|
2780
|
+
// One grouping dimension renders at a time. The toggles are kept mutually
|
|
2781
|
+
// exclusive in their change handlers, so this precedence chain (most
|
|
2782
|
+
// specific → coarsest: prompt → session → type) only ever matches one.
|
|
2783
|
+
var html;
|
|
2784
|
+
if (state.groupByPrompt) html = renderGroups(groupByPrompt(list), maxTraceCost);
|
|
2785
|
+
else if (state.groupBySession) html = renderGroups(sessionGroups(list), maxTraceCost);
|
|
2786
|
+
else if (state.groupByType) html = renderGroups(typeGroups(list), maxTraceCost, typeText);
|
|
2787
|
+
else html = list.map(function (t) { return renderRow(t, maxTraceCost); }).join("");
|
|
2788
|
+
host.innerHTML = html;
|
|
2789
|
+
document.getElementById("meta").textContent = list.length + " of " + state.traces.length + " trace(s) · live";
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
// --- Filter UI rendering ---------------------------------------------
|
|
2793
|
+
function renderChips() {
|
|
2794
|
+
var f = state.filter;
|
|
2795
|
+
document.getElementById("status-chips").innerHTML =
|
|
2796
|
+
'<span class="chip-group"><span class="gl">status</span>'
|
|
2797
|
+
+ STATUS_FILTERS.map(function (s) {
|
|
2798
|
+
return '<button type="button" class="chip' + (f.statuses[s] ? " active" : "") + '" data-status="' + esc(s) + '">' + esc(statusText(s)) + "</button>";
|
|
2799
|
+
}).join("")
|
|
2800
|
+
+ '<button type="button" class="chip' + (f.errorsOnly ? " active" : "") + '" data-errors="1" title="Show only failed / cancelled traces">Errors only</button>'
|
|
2801
|
+
+ "</span>";
|
|
2802
|
+
var types = presentTypes();
|
|
2803
|
+
document.getElementById("type-chips").innerHTML = types.length
|
|
2804
|
+
? '<span class="chip-group"><span class="gl">type</span>'
|
|
2805
|
+
+ types.map(function (ty) {
|
|
2806
|
+
return '<button type="button" class="chip' + (f.types[ty] ? " active" : "") + '" data-type="' + esc(ty) + '">' + esc(typeText(ty)) + "</button>";
|
|
2807
|
+
}).join("") + "</span>"
|
|
2808
|
+
: "";
|
|
2809
|
+
var sessions = presentSessions();
|
|
2810
|
+
document.getElementById("session-chips").innerHTML = sessions.length
|
|
2811
|
+
? '<span class="chip-group"><span class="gl">session</span>'
|
|
2812
|
+
+ sessions.map(function (s) {
|
|
2813
|
+
return '<button type="button" class="chip' + (f.sessionId === s ? " active" : "") + '" data-session="' + esc(s) + '">' + esc(s) + "</button>";
|
|
2814
|
+
}).join("") + "</span>"
|
|
2815
|
+
: "";
|
|
2816
|
+
// Prompt-version filter chips — one per distinct name@version seen.
|
|
2817
|
+
// Hidden entirely until a named-prompt run shows up, so the toolbar stays
|
|
2818
|
+
// clean for projects that don't use the ai.prompts registry.
|
|
2819
|
+
var prompts = presentPrompts();
|
|
2820
|
+
document.getElementById("prompt-chips").innerHTML = prompts.length
|
|
2821
|
+
? '<span class="chip-group"><span class="gl">prompt</span>'
|
|
2822
|
+
+ prompts.map(function (p) {
|
|
2823
|
+
return '<button type="button" class="chip' + (f.promptKey === p ? " active" : "") + '" data-prompt="' + esc(p) + '">' + esc(p) + "</button>";
|
|
2824
|
+
}).join("") + "</span>"
|
|
2825
|
+
: "";
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Heatmap denominator for the currently rendered trace tree. Set by
|
|
2829
|
+
// renderDrawer before each renderTree pass so node accents are scaled
|
|
2830
|
+
// against the most expensive node in this trace.
|
|
2831
|
+
var currentTreeMax = 0;
|
|
2832
|
+
function renderTree(span) {
|
|
2833
|
+
var hasKids = span.children && span.children.length;
|
|
2834
|
+
var collapsed = !!state.collapsed[span.spanId];
|
|
2835
|
+
var sel = span.spanId === state.selectedSpanId ? " selected" : "";
|
|
2836
|
+
var tw = hasKids ? (collapsed ? "▸" : "▾") : "·";
|
|
2837
|
+
var node = '<div class="tnode' + sel + '" data-span="' + esc(span.spanId) + '">'
|
|
2838
|
+
+ heatBar(heatIntensity(rollupCost(span), currentTreeMax))
|
|
2839
|
+
+ '<span class="twisty"' + (hasKids ? ' data-toggle="' + esc(span.spanId) + '"' : "") + ">" + tw + "</span>"
|
|
2840
|
+
+ typeLabel(span.type)
|
|
2841
|
+
+ '<span class="tname">' + esc(span.name) + "</span>"
|
|
2842
|
+
+ statusDot(span.status)
|
|
2843
|
+
+ '<span class="tmeta">' + dur(span.duration) + "</span>"
|
|
2844
|
+
+ "</div>";
|
|
2845
|
+
var kids = (hasKids && !collapsed) ? '<div class="tkids">' + span.children.map(renderTree).join("") + "</div>" : "";
|
|
2846
|
+
return node + kids;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// --- Timeline / waterfall (Gantt) ------------------------------------
|
|
2850
|
+
// Flatten the span tree to a depth-first ordered list, each entry
|
|
2851
|
+
// carrying its offset (ms from root start) + duration, so we can lay
|
|
2852
|
+
// out concurrency without re-walking. parseTs tolerates a missing/bad
|
|
2853
|
+
// startedAt by falling back to 0 so a bad clock never NaNs the bars.
|
|
2854
|
+
function parseTs(s) { var n = Date.parse(s); return isNaN(n) ? 0 : n; }
|
|
2855
|
+
// Render an ISO timestamp for the detail drawer as a readable, locale-
|
|
2856
|
+
// unambiguous local time — "28 Jun 2026 03:16 PM" (named month so there is
|
|
2857
|
+
// no M/D vs D/M confusion; minute precision). Falls back to the raw string
|
|
2858
|
+
// on a bad clock.
|
|
2859
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2860
|
+
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
|
2861
|
+
function fmtTs(iso) {
|
|
2862
|
+
if (!iso) return "—";
|
|
2863
|
+
var d = new Date(iso);
|
|
2864
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
2865
|
+
var h = d.getHours();
|
|
2866
|
+
var ampm = h >= 12 ? "PM" : "AM";
|
|
2867
|
+
var h12 = h % 12;
|
|
2868
|
+
if (h12 === 0) h12 = 12;
|
|
2869
|
+
return d.getDate() + " " + MONTHS[d.getMonth()] + " " + d.getFullYear()
|
|
2870
|
+
+ " " + pad2(h12) + ":" + pad2(d.getMinutes()) + " " + ampm;
|
|
2871
|
+
}
|
|
2872
|
+
function flattenSpans(root) {
|
|
2873
|
+
var base = parseTs(root.startedAt);
|
|
2874
|
+
var rows = [];
|
|
2875
|
+
(function walk(span, depth) {
|
|
2876
|
+
var start = parseTs(span.startedAt) - base;
|
|
2877
|
+
if (start < 0) start = 0;
|
|
2878
|
+
var d = (span.duration != null) ? span.duration : 0;
|
|
2879
|
+
rows.push({ span: span, depth: depth, offset: start, duration: d, end: start + d });
|
|
2880
|
+
(span.children || []).forEach(function (c) { walk(c, depth + 1); });
|
|
2881
|
+
})(root, 0);
|
|
2882
|
+
return rows;
|
|
2883
|
+
}
|
|
2884
|
+
// Critical path: from the root, repeatedly step into the child whose
|
|
2885
|
+
// end time is latest (the one that pushed the parent's finish). Marks
|
|
2886
|
+
// the spans that determine total wall-clock time.
|
|
2887
|
+
function criticalPath(root) {
|
|
2888
|
+
var crit = {};
|
|
2889
|
+
(function walk(span) {
|
|
2890
|
+
crit[span.spanId] = 1;
|
|
2891
|
+
var kids = span.children || [];
|
|
2892
|
+
if (!kids.length) return;
|
|
2893
|
+
var pick = null, pe = -1;
|
|
2894
|
+
for (var i = 0; i < kids.length; i++) {
|
|
2895
|
+
var e = parseTs(kids[i].startedAt) + ((kids[i].duration != null) ? kids[i].duration : 0);
|
|
2896
|
+
if (e > pe) { pe = e; pick = kids[i]; }
|
|
2897
|
+
}
|
|
2898
|
+
if (pick) walk(pick);
|
|
2899
|
+
})(root);
|
|
2900
|
+
return crit;
|
|
2901
|
+
}
|
|
2902
|
+
function renderGantt(root) {
|
|
2903
|
+
var rows = flattenSpans(root);
|
|
2904
|
+
var span0 = rows.length ? rows[0] : null;
|
|
2905
|
+
var total = 0;
|
|
2906
|
+
rows.forEach(function (r) { if (r.end > total) total = r.end; });
|
|
2907
|
+
if (total <= 0) total = (span0 && span0.duration) || 1;
|
|
2908
|
+
var crit = criticalPath(root);
|
|
2909
|
+
var body = rows.map(function (r) {
|
|
2910
|
+
var leftPct = (r.offset / total) * 100;
|
|
2911
|
+
var widthPct = Math.max((r.duration / total) * 100, 0.6);
|
|
2912
|
+
var sel = r.span.spanId === state.selectedSpanId ? " selected" : "";
|
|
2913
|
+
var isCrit = crit[r.span.spanId] ? " crit" : "";
|
|
2914
|
+
var barClass = "gbar bar-" + statusClass(r.span.status) + (isCrit ? " crit" : "");
|
|
2915
|
+
var pad = "padding-left:" + (r.depth * 10) + "px";
|
|
2916
|
+
return '<div class="grow' + sel + '" data-span="' + esc(r.span.spanId) + '">'
|
|
2917
|
+
+ '<span class="glabel" style="' + pad + '" title="' + esc(r.span.name) + '">' + esc(r.span.name) + "</span>"
|
|
2918
|
+
+ '<span class="gtrack"><span class="' + barClass + '" style="left:' + leftPct.toFixed(2) + "%;width:" + widthPct.toFixed(2) + '%"></span></span>'
|
|
2919
|
+
+ '<span class="gdur">' + dur(r.duration) + "</span>"
|
|
2920
|
+
+ "</div>";
|
|
2921
|
+
}).join("");
|
|
2922
|
+
var legend = '<div class="gantt-legend">'
|
|
2923
|
+
+ '<span class="ck"><span class="sw" style="background:var(--fail)"></span> critical path</span>'
|
|
2924
|
+
+ '<span class="ck"><span class="sw" style="background:var(--ty-agent)"></span> span (offset + duration)</span>'
|
|
2925
|
+
+ "</div>";
|
|
2926
|
+
return '<div class="gantt">' + (body || '<div class="dim">No spans.</div>') + legend + "</div>";
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// The name@version of the named prompt this span's run resolved, read
|
|
2930
|
+
// from the collector's prompt-version-linkage attributes. Returns null when
|
|
2931
|
+
// the run carried no named prompt. Mirrors tracePromptKey in trace-filter.ts.
|
|
2932
|
+
function spanPromptKey(span) {
|
|
2933
|
+
var a = span && span.attributes;
|
|
2934
|
+
if (!a || typeof a !== "object") return null;
|
|
2935
|
+
var name = a["agent.promptName"];
|
|
2936
|
+
if (typeof name !== "string" || !name.length) return null;
|
|
2937
|
+
var ver = a["agent.promptVersion"];
|
|
2938
|
+
var vl = (typeof ver === "string" && ver.length) ? ver : "1";
|
|
2939
|
+
return name + "@" + vl;
|
|
2940
|
+
}
|
|
2941
|
+
function tracePromptKey(t) { return spanPromptKey(t.root); }
|
|
2942
|
+
|
|
2943
|
+
// Humanize a metadata key for display: split dot.notation + camelCase,
|
|
2944
|
+
// Title-case each word, upcase "id". e.g. "supervisor.terminatedBy" →
|
|
2945
|
+
// "Supervisor Terminated By", "span id" → "Span ID", "agent.trips" →
|
|
2946
|
+
// "Agent Trips". Underlying attribute keys are untouched.
|
|
2947
|
+
// NB: this whole script is a template literal — regex backslash classes
|
|
2948
|
+
// MUST be double-escaped (\\s, not \s) or "\\s" collapses to a literal "s".
|
|
2949
|
+
function humanizeKey(key) {
|
|
2950
|
+
return String(key).split(/[.\\s]+/).map(function (seg) {
|
|
2951
|
+
return seg.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\\s+/).map(function (w) {
|
|
2952
|
+
if (!w) return w;
|
|
2953
|
+
if (w.toLowerCase() === "id") return "ID";
|
|
2954
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
2955
|
+
}).join(" ");
|
|
2956
|
+
}).join(" ");
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function renderMeta(span, trace) {
|
|
2960
|
+
var rows = [];
|
|
2961
|
+
// Absolute wall-clock span — the head shows only the elapsed duration, so
|
|
2962
|
+
// surface when this node actually started/ended for log correlation.
|
|
2963
|
+
rows.push(["started", fmtTs(span.startedAt)]);
|
|
2964
|
+
rows.push(["ended", fmtTs(span.endedAt)]);
|
|
2965
|
+
var sid = span.sessionId || trace.sessionId;
|
|
2966
|
+
if (sid) rows.push(["session", sid]);
|
|
2967
|
+
// Prompt-version linkage: surface the resolved named prompt as one clean
|
|
2968
|
+
// name@version row right under session, so the panel reads it as a
|
|
2969
|
+
// first-class dimension rather than two raw attribute keys.
|
|
2970
|
+
var pk = spanPromptKey(span);
|
|
2971
|
+
if (pk) rows.push(["prompt", pk]);
|
|
2972
|
+
if (span.version) rows.push(["version", span.version]);
|
|
2973
|
+
rows.push(["span id", span.spanId]);
|
|
2974
|
+
if (span.parentSpanId) rows.push(["parent", span.parentSpanId]);
|
|
2975
|
+
rows.push(["trace id", span.traceId || trace.traceId]);
|
|
2976
|
+
var attrs = span.attributes;
|
|
2977
|
+
// Skip the two raw prompt keys — already shown as the clean prompt row.
|
|
2978
|
+
if (attrs && typeof attrs === "object") Object.keys(attrs).forEach(function (k) {
|
|
2979
|
+
if (k === "agent.promptName" || k === "agent.promptVersion") return;
|
|
2980
|
+
rows.push([k, attrs[k]]);
|
|
2981
|
+
});
|
|
2982
|
+
var kv = rows.map(function (r) {
|
|
2983
|
+
var v = r[1];
|
|
2984
|
+
var vs = (v && typeof v === "object") ? '<pre class="mini">' + esc(fmt(v)) + "</pre>" : esc(String(v));
|
|
2985
|
+
return '<div class="kv-row"><span class="kv-k">' + esc(humanizeKey(r[0])) + '</span><span class="kv-v">' + vs + "</span></div>";
|
|
2986
|
+
}).join("");
|
|
2987
|
+
return '<div class="meta-sec"><div class="meta-title">metadata</div><div class="kv">' + kv + "</div></div>";
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function renderDetail(span, trace) {
|
|
2991
|
+
var path = findPath(trace.root, span.spanId) || [span];
|
|
2992
|
+
var crumb = path.map(function (p, i) { return (i ? '<span class="sep"> › </span>' : "") + "<span>" + esc(p.name) + "</span>"; }).join("");
|
|
2993
|
+
var io = "";
|
|
2994
|
+
if (span.input !== undefined) io += '<div class="piol" style="color:var(--tok-in)">input</div><div class="io-body">' + smartValue(span.input) + "</div>";
|
|
2995
|
+
if (span.output !== undefined) io += '<div class="piol" style="color:var(--tok-out)">output</div><div class="io-body">' + smartValue(span.output) + "</div>";
|
|
2996
|
+
if (span.error) io += '<div class="piol" style="color:var(--fail)">error</div><div class="io-body">' + smartValue(span.error) + "</div>";
|
|
2997
|
+
|
|
2998
|
+
// Tokens + cost line — omitted entirely when the node has neither
|
|
2999
|
+
// (e.g. a free tool with zero usage), so the panel stays uncluttered.
|
|
3000
|
+
var tk = tokens(span.usage);
|
|
3001
|
+
var c = rollupCost(span);
|
|
3002
|
+
var metaLine = (tk || c > 0)
|
|
3003
|
+
? '<div style="margin-bottom:4px">' + tk + (c > 0 ? '<span class="cost">' + (tk ? " · " : "") + money(c) + "</span>" : "") + "</div>"
|
|
3004
|
+
: "";
|
|
3005
|
+
|
|
3006
|
+
return '<div class="crumb">' + crumb + "</div>"
|
|
3007
|
+
+ '<div class="dhead-row">' + typeLabel(span.type) + '<span style="font-weight:600">' + esc(span.name) + "</span>"
|
|
3008
|
+
+ '<span class="badge ' + statusClass(span.status) + '">' + esc(statusText(span.status)) + "</span>"
|
|
3009
|
+
+ '<span class="dim">· ' + dur(span.duration) + "</span></div>"
|
|
3010
|
+
+ metaLine
|
|
3011
|
+
+ io
|
|
3012
|
+
+ renderMeta(span, trace);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
function headHtml(t) {
|
|
3016
|
+
var cost = traceCost(t);
|
|
3017
|
+
return typeLabel(t.root.type)
|
|
3018
|
+
+ '<span class="badge ' + statusClass(t.root.status) + '">' + esc(statusText(t.root.status)) + "</span>"
|
|
3019
|
+
+ '<span style="font-weight:600">' + esc(t.root.name) + "</span>"
|
|
3020
|
+
+ '<span class="dim">· ' + dur(t.duration) + "</span>"
|
|
3021
|
+
+ tokens(t.usage)
|
|
3022
|
+
+ (cost > 0 ? '<span class="cost">· ' + money(cost) + "</span>" : "")
|
|
3023
|
+
+ '<button class="drawer-close" type="button" title="Close (Esc)">✕ Close</button>';
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
function viewSwitcher() {
|
|
3027
|
+
var tree = state.view === "tree" ? " active" : "";
|
|
3028
|
+
var tl = state.view === "timeline" ? " active" : "";
|
|
3029
|
+
return '<div class="dview" id="drawer-view">'
|
|
3030
|
+
+ '<button type="button" class="' + tree.trim() + '" data-view="tree">Tree</button>'
|
|
3031
|
+
+ '<button type="button" class="' + tl.trim() + '" data-view="timeline">Timeline</button>'
|
|
3032
|
+
+ '<span class="legend" title="Node colour = relative cost"><span>cost</span><span class="grad"></span></span>'
|
|
3033
|
+
+ "</div>";
|
|
3034
|
+
}
|
|
3035
|
+
function leftPaneHtml(t) {
|
|
3036
|
+
if (state.view === "timeline") return renderGantt(t.root);
|
|
3037
|
+
currentTreeMax = maxNodeCost(t.root);
|
|
3038
|
+
return renderTree(t.root);
|
|
3039
|
+
}
|
|
3040
|
+
function renderDrawer(t) {
|
|
3041
|
+
document.getElementById("drawer-head").innerHTML = headHtml(t);
|
|
3042
|
+
var sel = findSpan(t.root, state.selectedSpanId) || t.root;
|
|
3043
|
+
document.getElementById("drawer-body").innerHTML =
|
|
3044
|
+
'<div class="dsplit"><div class="dtree" id="drawer-tree">' + viewSwitcher() + leftPaneHtml(t) + "</div>"
|
|
3045
|
+
+ '<div class="ddetail" id="drawer-detail">' + renderDetail(sel, t) + "</div></div>";
|
|
3046
|
+
}
|
|
3047
|
+
// Re-render only the left pane (after a view switch) without disturbing
|
|
3048
|
+
// the detail panel or scroll position of the detail side.
|
|
3049
|
+
function renderLeftPane() {
|
|
3050
|
+
var t = findTrace(state.selectedId);
|
|
3051
|
+
if (!t) return;
|
|
3052
|
+
document.getElementById("drawer-tree").innerHTML = viewSwitcher() + leftPaneHtml(t);
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
function selectSpan(id) {
|
|
3056
|
+
state.selectedSpanId = id;
|
|
3057
|
+
var t = findTrace(state.selectedId);
|
|
3058
|
+
if (!t) return;
|
|
3059
|
+
document.getElementById("drawer-detail").innerHTML = renderDetail(findSpan(t.root, id) || t.root, t);
|
|
3060
|
+
// Highlight in whichever left pane is active (tree nodes or Gantt rows).
|
|
3061
|
+
var nodes = document.querySelectorAll(".tnode[data-span], .grow[data-span]");
|
|
3062
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
3063
|
+
if (nodes[i].getAttribute("data-span") === id) nodes[i].classList.add("selected");
|
|
3064
|
+
else nodes[i].classList.remove("selected");
|
|
3065
|
+
}
|
|
3066
|
+
writeHash();
|
|
3067
|
+
}
|
|
3068
|
+
function toggleSpan(id) {
|
|
3069
|
+
state.collapsed[id] = !state.collapsed[id];
|
|
3070
|
+
var t = findTrace(state.selectedId);
|
|
3071
|
+
if (t) renderLeftPane();
|
|
3072
|
+
}
|
|
3073
|
+
function setView(v) {
|
|
3074
|
+
if (state.view === v) return;
|
|
3075
|
+
state.view = v;
|
|
3076
|
+
renderLeftPane();
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
function openTrace(id, spanId) {
|
|
3080
|
+
state.selectedId = id;
|
|
3081
|
+
var t = findTrace(id);
|
|
3082
|
+
if (t) {
|
|
3083
|
+
state.selectedSpanId = (spanId && findSpan(t.root, spanId)) ? spanId : t.root.spanId;
|
|
3084
|
+
state.collapsed = {};
|
|
3085
|
+
renderDrawer(t);
|
|
3086
|
+
state.sig = traceSig(t);
|
|
3087
|
+
}
|
|
3088
|
+
document.getElementById("drawer").classList.add("open");
|
|
3089
|
+
document.getElementById("drawer").setAttribute("aria-hidden", "false");
|
|
3090
|
+
document.getElementById("backdrop").classList.add("open");
|
|
3091
|
+
markSelectedRow();
|
|
3092
|
+
writeHash();
|
|
3093
|
+
}
|
|
3094
|
+
function closeDrawer() {
|
|
3095
|
+
state.selectedId = null; state.selectedSpanId = null; state.sig = null;
|
|
3096
|
+
document.getElementById("drawer").classList.remove("open");
|
|
3097
|
+
document.getElementById("drawer").setAttribute("aria-hidden", "true");
|
|
3098
|
+
document.getElementById("backdrop").classList.remove("open");
|
|
3099
|
+
markSelectedRow();
|
|
3100
|
+
writeHash();
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// --- Deep-links: reflect the open trace + span in the URL hash -------
|
|
3104
|
+
// #trace=<id>&span=<id>. Written on open/close/select; read on load and
|
|
3105
|
+
// on manual hash edits (back/forward). A guard flag stops writeHash from
|
|
3106
|
+
// re-triggering our own hashchange handler in a loop.
|
|
3107
|
+
var suppressHash = false;
|
|
3108
|
+
function writeHash() {
|
|
3109
|
+
var h = "";
|
|
3110
|
+
if (state.selectedId) {
|
|
3111
|
+
h = "#trace=" + encodeURIComponent(state.selectedId);
|
|
3112
|
+
if (state.selectedSpanId && state.selectedSpanId !== state.selectedId) {
|
|
3113
|
+
h += "&span=" + encodeURIComponent(state.selectedSpanId);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
suppressHash = true;
|
|
3117
|
+
try {
|
|
3118
|
+
if (history && history.replaceState) history.replaceState(null, "", h || (location.pathname + location.search));
|
|
3119
|
+
else location.hash = h;
|
|
3120
|
+
} catch (e) { location.hash = h; }
|
|
3121
|
+
suppressHash = false;
|
|
3122
|
+
}
|
|
3123
|
+
function readHash() {
|
|
3124
|
+
var raw = (location.hash || "").replace(/^#/, "");
|
|
3125
|
+
var out = { trace: null, span: null };
|
|
3126
|
+
raw.split("&").forEach(function (kv) {
|
|
3127
|
+
var i = kv.indexOf("=");
|
|
3128
|
+
if (i === -1) return;
|
|
3129
|
+
var k = kv.slice(0, i), v = decodeURIComponent(kv.slice(i + 1));
|
|
3130
|
+
if (k === "trace") out.trace = v;
|
|
3131
|
+
else if (k === "span") out.span = v;
|
|
3132
|
+
});
|
|
3133
|
+
return out;
|
|
3134
|
+
}
|
|
3135
|
+
// Open whatever the hash points at, if that trace is loaded. Returns
|
|
3136
|
+
// true when it acted so the caller can mark the one-time load as done.
|
|
3137
|
+
function applyHash() {
|
|
3138
|
+
var h = readHash();
|
|
3139
|
+
if (!h.trace) {
|
|
3140
|
+
if (state.selectedId) closeDrawer();
|
|
3141
|
+
return true;
|
|
3142
|
+
}
|
|
3143
|
+
if (!findTrace(h.trace)) return false; // not polled yet — retry next poll
|
|
3144
|
+
openTrace(h.trace, h.span || undefined);
|
|
3145
|
+
return true;
|
|
3146
|
+
}
|
|
3147
|
+
function markSelectedRow() {
|
|
3148
|
+
var rows = document.querySelectorAll(".trace-row");
|
|
3149
|
+
for (var i = 0; i < rows.length; i++) {
|
|
3150
|
+
if (rows[i].getAttribute("data-id") === state.selectedId) rows[i].classList.add("selected");
|
|
3151
|
+
else rows[i].classList.remove("selected");
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
document.getElementById("traces").addEventListener("click", function (e) {
|
|
3156
|
+
var head = e.target.closest ? e.target.closest(".sgroup-head") : null;
|
|
3157
|
+
if (head) {
|
|
3158
|
+
var g = head.getAttribute("data-group");
|
|
3159
|
+
state.collapsedGroups[g] = !state.collapsedGroups[g];
|
|
3160
|
+
renderList();
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
var row = e.target.closest ? e.target.closest(".trace-row") : null;
|
|
3164
|
+
if (row) openTrace(row.getAttribute("data-id"));
|
|
3165
|
+
});
|
|
3166
|
+
document.getElementById("drawer-head").addEventListener("click", function (e) {
|
|
3167
|
+
if (e.target.closest && e.target.closest(".drawer-close")) closeDrawer();
|
|
3168
|
+
});
|
|
3169
|
+
document.getElementById("drawer-body").addEventListener("click", function (e) {
|
|
3170
|
+
var vb = e.target.closest ? e.target.closest("[data-view]") : null;
|
|
3171
|
+
if (vb) { setView(vb.getAttribute("data-view")); return; }
|
|
3172
|
+
var tog = e.target.closest ? e.target.closest("[data-toggle]") : null;
|
|
3173
|
+
if (tog) { toggleSpan(tog.getAttribute("data-toggle")); return; }
|
|
3174
|
+
var node = e.target.closest ? e.target.closest(".tnode[data-span], .grow[data-span]") : null;
|
|
3175
|
+
if (node) selectSpan(node.getAttribute("data-span"));
|
|
3176
|
+
});
|
|
3177
|
+
document.getElementById("backdrop").addEventListener("click", closeDrawer);
|
|
3178
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape" || e.keyCode === 27) closeDrawer(); });
|
|
3179
|
+
|
|
3180
|
+
// --- Filter / toolbar wiring -----------------------------------------
|
|
3181
|
+
function toggleMapKey(map, key) { if (map[key]) delete map[key]; else map[key] = 1; }
|
|
3182
|
+
document.getElementById("status-chips").addEventListener("click", function (e) {
|
|
3183
|
+
if (!e.target.closest) return;
|
|
3184
|
+
// The "Errors only" shortcut chip lives in the status group now.
|
|
3185
|
+
if (e.target.closest("[data-errors]")) {
|
|
3186
|
+
state.filter.errorsOnly = !state.filter.errorsOnly;
|
|
3187
|
+
renderChips(); renderList();
|
|
3188
|
+
return;
|
|
3189
|
+
}
|
|
3190
|
+
var b = e.target.closest("[data-status]");
|
|
3191
|
+
if (!b) return;
|
|
3192
|
+
toggleMapKey(state.filter.statuses, b.getAttribute("data-status"));
|
|
3193
|
+
renderChips(); renderList();
|
|
3194
|
+
});
|
|
3195
|
+
document.getElementById("type-chips").addEventListener("click", function (e) {
|
|
3196
|
+
var b = e.target.closest ? e.target.closest("[data-type]") : null;
|
|
3197
|
+
if (!b) return;
|
|
3198
|
+
toggleMapKey(state.filter.types, b.getAttribute("data-type"));
|
|
3199
|
+
renderChips(); renderList();
|
|
3200
|
+
});
|
|
3201
|
+
document.getElementById("session-chips").addEventListener("click", function (e) {
|
|
3202
|
+
var b = e.target.closest ? e.target.closest("[data-session]") : null;
|
|
3203
|
+
if (!b) return;
|
|
3204
|
+
var s = b.getAttribute("data-session");
|
|
3205
|
+
state.filter.sessionId = (state.filter.sessionId === s) ? null : s;
|
|
3206
|
+
renderChips(); renderList();
|
|
3207
|
+
});
|
|
3208
|
+
document.getElementById("prompt-chips").addEventListener("click", function (e) {
|
|
3209
|
+
var b = e.target.closest ? e.target.closest("[data-prompt]") : null;
|
|
3210
|
+
if (!b) return;
|
|
3211
|
+
var p = b.getAttribute("data-prompt");
|
|
3212
|
+
state.filter.promptKey = (state.filter.promptKey === p) ? null : p;
|
|
3213
|
+
renderChips(); renderList();
|
|
3214
|
+
});
|
|
3215
|
+
document.getElementById("search").addEventListener("input", function (e) {
|
|
3216
|
+
state.filter.text = e.target.value || "";
|
|
3217
|
+
renderList();
|
|
3218
|
+
});
|
|
3219
|
+
// One mutually-exclusive grouping dimension, chosen from the Group dropdown.
|
|
3220
|
+
// "" = no grouping; renderList's precedence chain only ever matches one.
|
|
3221
|
+
// Switching dimensions drops stale collapsed-header keys.
|
|
3222
|
+
function setGrouping(dim) {
|
|
3223
|
+
state.groupBySession = dim === "session";
|
|
3224
|
+
state.groupByPrompt = dim === "prompt";
|
|
3225
|
+
state.groupByType = dim === "type";
|
|
3226
|
+
state.collapsedGroups = {};
|
|
3227
|
+
renderList();
|
|
3228
|
+
}
|
|
3229
|
+
document.getElementById("group-by").addEventListener("change", function (e) {
|
|
3230
|
+
setGrouping(e.target.value);
|
|
3231
|
+
});
|
|
3232
|
+
document.getElementById("show-stats").addEventListener("change", function (e) {
|
|
3233
|
+
state.showStats = !!e.target.checked;
|
|
3234
|
+
renderStatsPanel();
|
|
3235
|
+
});
|
|
3236
|
+
document.getElementById("clear-filters").addEventListener("click", function () {
|
|
3237
|
+
state.filter = { text: "", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false };
|
|
3238
|
+
document.getElementById("search").value = "";
|
|
3239
|
+
renderChips(); renderList();
|
|
3240
|
+
});
|
|
3241
|
+
|
|
3242
|
+
// Back/forward or a manual hash edit re-syncs the open trace/span.
|
|
3243
|
+
window.addEventListener("hashchange", function () {
|
|
3244
|
+
if (suppressHash) return;
|
|
3245
|
+
applyHash();
|
|
3246
|
+
});
|
|
3247
|
+
|
|
3248
|
+
var THEME_KEY = "panoptic-theme";
|
|
3249
|
+
var mql = window.matchMedia ? window.matchMedia("(prefers-color-scheme: light)") : null;
|
|
3250
|
+
function applyTheme(mode) {
|
|
3251
|
+
try { localStorage.setItem(THEME_KEY, mode); } catch (e) {}
|
|
3252
|
+
var light = mode === "light" || (mode === "system" && mql && mql.matches);
|
|
3253
|
+
document.documentElement.setAttribute("data-theme", light ? "light" : "dark");
|
|
3254
|
+
var btns = document.querySelectorAll("[data-theme-set]");
|
|
3255
|
+
for (var i = 0; i < btns.length; i++) btns[i].classList.toggle("active", btns[i].getAttribute("data-theme-set") === mode);
|
|
3256
|
+
}
|
|
3257
|
+
document.getElementById("theme").addEventListener("click", function (e) {
|
|
3258
|
+
var b = e.target.closest ? e.target.closest("[data-theme-set]") : null;
|
|
3259
|
+
if (b) applyTheme(b.getAttribute("data-theme-set"));
|
|
3260
|
+
});
|
|
3261
|
+
if (mql && mql.addEventListener) mql.addEventListener("change", function () {
|
|
3262
|
+
var cur = "system";
|
|
3263
|
+
try { cur = localStorage.getItem(THEME_KEY) || "system"; } catch (e) {}
|
|
3264
|
+
if (cur === "system") applyTheme("system");
|
|
3265
|
+
});
|
|
3266
|
+
var savedTheme = "system";
|
|
3267
|
+
try { savedTheme = localStorage.getItem(THEME_KEY) || "system"; } catch (e) {}
|
|
3268
|
+
applyTheme(savedTheme);
|
|
3269
|
+
|
|
3270
|
+
function poll() {
|
|
3271
|
+
Promise.all([
|
|
3272
|
+
fetch(API + "/aggregate").then(function (r) { return r.json(); }),
|
|
3273
|
+
fetch(API + "/traces").then(function (r) { return r.json(); })
|
|
3274
|
+
]).then(function (res) {
|
|
3275
|
+
renderStats(res[0]);
|
|
3276
|
+
state.traces = res[1] || [];
|
|
3277
|
+
document.getElementById("meta").textContent = state.traces.length + " trace(s) · live";
|
|
3278
|
+
renderChips();
|
|
3279
|
+
renderList();
|
|
3280
|
+
// Open whatever the URL hash deep-links to, once the target trace
|
|
3281
|
+
// has actually arrived in a poll (it may not be in the first batch).
|
|
3282
|
+
if (!state.hashApplied) {
|
|
3283
|
+
if (applyHash()) state.hashApplied = true;
|
|
3284
|
+
} else if (state.selectedId) {
|
|
3285
|
+
var t = findTrace(state.selectedId);
|
|
3286
|
+
if (t) { var sig = traceSig(t); if (sig !== state.sig) { renderDrawer(t); state.sig = sig; } }
|
|
3287
|
+
}
|
|
3288
|
+
}).catch(function (e) {
|
|
3289
|
+
document.getElementById("meta").textContent = "disconnected";
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// FOLLOW-UP: a live socket tail (SSE / WebSocket push) is out of scope
|
|
3294
|
+
// for this pass; the dashboard stays on the 2s JSON poll below. When
|
|
3295
|
+
// added, it should reuse renderList/renderDrawer and keep the poll as a
|
|
3296
|
+
// reconnect fallback.
|
|
3297
|
+
poll();
|
|
3298
|
+
setInterval(poll, 2000);
|
|
3299
|
+
})();
|
|
3300
|
+
<\/script>
|
|
3301
|
+
</body>
|
|
3302
|
+
</html>`;
|
|
3303
|
+
}
|
|
3304
|
+
/** Escape a string for safe interpolation into static HTML text. */
|
|
3305
|
+
function escapeHtml(value) {
|
|
3306
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
//#endregion
|
|
3310
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/serve.ts
|
|
3311
|
+
/**
|
|
3312
|
+
* Security response headers added to every dashboard response (S4):
|
|
3313
|
+
* block MIME-sniffing, framing, referrer leakage, and lock the page's
|
|
3314
|
+
* content sources down to itself (the UI is fully self-contained — no CDN).
|
|
3315
|
+
*/
|
|
3316
|
+
const SECURITY_HEADERS = {
|
|
3317
|
+
"x-content-type-options": "nosniff",
|
|
3318
|
+
"x-frame-options": "DENY",
|
|
3319
|
+
"referrer-policy": "no-referrer",
|
|
3320
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
3321
|
+
};
|
|
3322
|
+
/** Extract the host (no port) from a `Host` header value. */
|
|
3323
|
+
function hostHeaderName(hostHeader) {
|
|
3324
|
+
if (!hostHeader) return void 0;
|
|
3325
|
+
if (hostHeader.startsWith("[")) return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
|
|
3326
|
+
const colon = hostHeader.indexOf(":");
|
|
3327
|
+
return colon === -1 ? hostHeader : hostHeader.slice(0, colon);
|
|
3328
|
+
}
|
|
3329
|
+
/** Constant-ish bearer-token check from `Authorization` header or `?token=`. */
|
|
3330
|
+
function isAuthorized(req, url, token) {
|
|
3331
|
+
const header = req.headers.authorization;
|
|
3332
|
+
if (header && header === `Bearer ${token}`) return true;
|
|
3333
|
+
return url.searchParams.get("token") === token;
|
|
3334
|
+
}
|
|
3335
|
+
/**
|
|
3336
|
+
* Build the `node:http` request handler for the dashboard over a given
|
|
3337
|
+
* trace store. Kept separate from the server lifecycle so it can be unit
|
|
3338
|
+
* tested by feeding it a fake `req`/`res` without binding a port.
|
|
3339
|
+
*
|
|
3340
|
+
* Routes (all under `config.basePath`, all read-only):
|
|
3341
|
+
*
|
|
3342
|
+
* - `GET api/traces` → `store.query(parseQuery(searchParams))`
|
|
3343
|
+
* - `GET api/traces/:id` → `store.get(id)` or `404`
|
|
3344
|
+
* - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`
|
|
3345
|
+
* - `GET {basePath}` → the self-contained HTML page
|
|
3346
|
+
*
|
|
3347
|
+
* Anything else → `404`. Non-`GET` methods → `405`. The store shapes
|
|
3348
|
+
* ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so responses
|
|
3349
|
+
* are a plain `JSON.stringify` with no serializer.
|
|
3350
|
+
*
|
|
3351
|
+
* @example
|
|
3352
|
+
* const handler = createRequestHandler(store, { basePath: "/", title: "Panoptic" });
|
|
3353
|
+
* http.createServer(handler).listen(4319, "127.0.0.1");
|
|
3354
|
+
*/
|
|
3355
|
+
function createRequestHandler(store, config) {
|
|
3356
|
+
const base = config.basePath;
|
|
3357
|
+
const apiPrefix = `${base}api`;
|
|
3358
|
+
return function handle(req, res) {
|
|
3359
|
+
if (req.method !== "GET") {
|
|
3360
|
+
sendJson(res, 405, { error: "method_not_allowed" });
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3364
|
+
const pathname = url.pathname;
|
|
3365
|
+
const host = hostHeaderName(req.headers.host);
|
|
3366
|
+
if (!host || !config.allowedHosts.includes(host.toLowerCase())) {
|
|
3367
|
+
sendJson(res, 403, { error: "host_not_allowed" });
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
if (config.authToken && !isAuthorized(req, url, config.authToken)) {
|
|
3371
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
if (pathname === `${apiPrefix}/traces`) {
|
|
3375
|
+
sendJson(res, 200, store.query(parseQuery(url.searchParams)));
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
if (pathname.startsWith(`${apiPrefix}/traces/`)) {
|
|
3379
|
+
const traceId = decodeURIComponent(pathname.slice(`${apiPrefix}/traces/`.length));
|
|
3380
|
+
const trace = traceId.length > 0 ? store.get(traceId) : void 0;
|
|
3381
|
+
if (trace === void 0) {
|
|
3382
|
+
sendJson(res, 404, {
|
|
3383
|
+
error: "trace_not_found",
|
|
3384
|
+
traceId
|
|
3385
|
+
});
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
sendJson(res, 200, trace);
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
if (pathname === `${apiPrefix}/aggregate`) {
|
|
3392
|
+
sendJson(res, 200, store.aggregate(parseQuery(url.searchParams)));
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
3395
|
+
if (pathname === base || pathname === base.replace(/\/$/, "")) {
|
|
3396
|
+
res.writeHead(200, {
|
|
3397
|
+
"content-type": "text/html; charset=utf-8",
|
|
3398
|
+
...SECURITY_HEADERS
|
|
3399
|
+
});
|
|
3400
|
+
res.end(dashboardHtml(base, config.title));
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
sendJson(res, 404, { error: "not_found" });
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
/** Write a JSON response with the given status code and security headers. */
|
|
3407
|
+
function sendJson(res, status, body) {
|
|
3408
|
+
res.writeHead(status, {
|
|
3409
|
+
"content-type": "application/json; charset=utf-8",
|
|
3410
|
+
...SECURITY_HEADERS
|
|
3411
|
+
});
|
|
3412
|
+
res.end(JSON.stringify(body));
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
//#endregion
|
|
3416
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts
|
|
3417
|
+
const DEFAULT_PORT = 4319;
|
|
3418
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
3419
|
+
const DEFAULT_TITLE = "Panoptic";
|
|
3420
|
+
/**
|
|
3421
|
+
* Start the local Panoptic dashboard over a {@link TraceStoreContract} —
|
|
3422
|
+
* a zero-dependency `node:http` server that serves a read-only JSON API
|
|
3423
|
+
* and one self-contained HTML page polling it. The store is the live
|
|
3424
|
+
* object the collector fills, so each poll reflects the latest completed
|
|
3425
|
+
* traces with no extra wiring.
|
|
3426
|
+
*
|
|
3427
|
+
* This is a low-level building block. The documented path is
|
|
3428
|
+
* `ai.config({ panoptic: { dashboard } })`, which constructs (or reuses)
|
|
3429
|
+
* the store and calls this for you. Use it directly only when you manage
|
|
3430
|
+
* the store yourself.
|
|
3431
|
+
*
|
|
3432
|
+
* Binds loopback-only by default (`127.0.0.1`) so prompt content is never
|
|
3433
|
+
* exposed to the LAN. Pass `port: 0` for an ephemeral port — the resolved
|
|
3434
|
+
* port comes back on the handle. A port already in use rejects with a
|
|
3435
|
+
* clear, actionable `Error` rather than the raw `EADDRINUSE`.
|
|
3436
|
+
*
|
|
3437
|
+
* @example
|
|
3438
|
+
* const store = createInMemoryTraceStore();
|
|
3439
|
+
* const handle = await dashboard(store, { port: 4319, open: true });
|
|
3440
|
+
* console.log(handle.url); // http://127.0.0.1:4319/
|
|
3441
|
+
* // ...later:
|
|
3442
|
+
* await handle.close();
|
|
3443
|
+
*/
|
|
3444
|
+
function dashboard(store, options = {}) {
|
|
3445
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
3446
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
3447
|
+
const title = options.title ?? DEFAULT_TITLE;
|
|
3448
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
3449
|
+
if (!isLoopbackHost(host) && !options.authToken) return Promise.reject(/* @__PURE__ */ new Error(`Panoptic dashboard: binding to a non-loopback host ("${host}") requires an \`authToken\` (the dashboard exposes raw prompt content). Pass \`authToken\`, or bind to 127.0.0.1.`));
|
|
3450
|
+
const allowedHosts = (options.allowedHosts ?? defaultAllowedHosts(host)).map((h) => h.toLowerCase());
|
|
3451
|
+
const server = (0, node_http.createServer)(createRequestHandler(store, {
|
|
3452
|
+
basePath,
|
|
3453
|
+
title,
|
|
3454
|
+
authToken: options.authToken,
|
|
3455
|
+
allowedHosts
|
|
3456
|
+
}));
|
|
3457
|
+
return new Promise((resolve, reject) => {
|
|
3458
|
+
const onError = (error) => {
|
|
3459
|
+
server.off("error", onError);
|
|
3460
|
+
if (error.code === "EADDRINUSE") {
|
|
3461
|
+
reject(/* @__PURE__ */ new Error(`Panoptic dashboard: port ${port} in use; pass { port: 0 } for an ephemeral port`));
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
reject(error);
|
|
3465
|
+
};
|
|
3466
|
+
server.on("error", onError);
|
|
3467
|
+
server.listen(port, host, () => {
|
|
3468
|
+
server.off("error", onError);
|
|
3469
|
+
const address = server.address();
|
|
3470
|
+
const resolvedPort = typeof address === "object" && address !== null ? address.port : port;
|
|
3471
|
+
const url = `http://${host}:${resolvedPort}${basePath}`;
|
|
3472
|
+
if (options.open) openBrowser(url);
|
|
3473
|
+
resolve({
|
|
3474
|
+
url,
|
|
3475
|
+
port: resolvedPort,
|
|
3476
|
+
close() {
|
|
3477
|
+
return new Promise((closeResolve, closeReject) => {
|
|
3478
|
+
server.close((closeError) => {
|
|
3479
|
+
if (closeError) {
|
|
3480
|
+
closeReject(closeError);
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
closeResolve();
|
|
3484
|
+
});
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
});
|
|
3488
|
+
});
|
|
3489
|
+
});
|
|
3490
|
+
}
|
|
3491
|
+
/** Loopback hosts the dashboard may bind without an auth token. */
|
|
3492
|
+
function isLoopbackHost(host) {
|
|
3493
|
+
const h = host.toLowerCase();
|
|
3494
|
+
return h === "127.0.0.1" || h === "::1" || h === "[::1]" || h === "localhost";
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* Default `Host` allowlist for a given bind host. A loopback bind accepts
|
|
3498
|
+
* the loopback names a browser would send; a non-loopback bind defaults to
|
|
3499
|
+
* just the bound host (callers can widen via `allowedHosts`).
|
|
3500
|
+
*/
|
|
3501
|
+
function defaultAllowedHosts(host) {
|
|
3502
|
+
if (isLoopbackHost(host)) return [
|
|
3503
|
+
"localhost",
|
|
3504
|
+
"127.0.0.1",
|
|
3505
|
+
"[::1]",
|
|
3506
|
+
"::1"
|
|
3507
|
+
];
|
|
3508
|
+
return [host];
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Normalize a caller `basePath` to a leading-and-trailing-slash form the
|
|
3512
|
+
* router can prefix routes with. `undefined` / `""` → `"/"`.
|
|
3513
|
+
*/
|
|
3514
|
+
function normalizeBasePath(basePath) {
|
|
3515
|
+
if (basePath === void 0 || basePath.length === 0 || basePath === "/") return "/";
|
|
3516
|
+
const withLeading = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
3517
|
+
return withLeading.endsWith("/") ? withLeading : `${withLeading}/`;
|
|
3518
|
+
}
|
|
3519
|
+
/**
|
|
3520
|
+
* Best-effort open of the default browser at `url`. Fire-and-forget and
|
|
3521
|
+
* fully swallowed — failing to open a browser must never reject the
|
|
3522
|
+
* dashboard start. Uses the platform's native opener via a lazy
|
|
3523
|
+
* `node:child_process` import so the dependency is paid only when
|
|
3524
|
+
* `open: true`.
|
|
3525
|
+
*/
|
|
3526
|
+
function openBrowser(url) {
|
|
3527
|
+
import("node:child_process").then(({ spawn }) => {
|
|
3528
|
+
const child = spawn(process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open", process.platform === "win32" ? [
|
|
3529
|
+
"/c",
|
|
3530
|
+
"start",
|
|
3531
|
+
"",
|
|
3532
|
+
url
|
|
3533
|
+
] : [url], {
|
|
3534
|
+
stdio: "ignore",
|
|
3535
|
+
detached: true
|
|
3536
|
+
});
|
|
3537
|
+
child.on("error", () => {});
|
|
3538
|
+
child.unref();
|
|
3539
|
+
}).catch(() => {});
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
//#endregion
|
|
3543
|
+
//#region ../@warlock.js/ai-panoptic/src/dashboard/trace-filter.ts
|
|
3544
|
+
/** Statuses the errors-only toggle keeps. */
|
|
3545
|
+
const ERROR_STATUSES = ["failed", "cancelled"];
|
|
3546
|
+
/**
|
|
3547
|
+
* Span-attribute keys the collector stamps with prompt-version linkage when
|
|
3548
|
+
* an agent ran against a *named* `ai.prompts` builder. Read here (not
|
|
3549
|
+
* imported) so this pure module stays decoupled from the collector.
|
|
3550
|
+
*/
|
|
3551
|
+
const PROMPT_NAME_ATTR = "agent.promptName";
|
|
3552
|
+
const PROMPT_VERSION_ATTR = "agent.promptVersion";
|
|
3553
|
+
/**
|
|
3554
|
+
* The `name@version` prompt key a trace falls under, read from its root
|
|
3555
|
+
* span's prompt-version-linkage attributes (`agent.promptName` /
|
|
3556
|
+
* `agent.promptVersion`). Returns `undefined` when the run carried no named
|
|
3557
|
+
* prompt (raw-string / anonymous / no prompt), so callers can bucket those
|
|
3558
|
+
* under a synthetic "no prompt" key or skip them.
|
|
3559
|
+
*
|
|
3560
|
+
* The version is included so two runs of the same prompt name at different
|
|
3561
|
+
* versions are distinct keys — that is the whole point of the linkage: to
|
|
3562
|
+
* group / filter by the *exact* prompt revision that produced a run.
|
|
3563
|
+
*/
|
|
3564
|
+
function tracePromptKey(trace) {
|
|
3565
|
+
const attributes = trace.root.attributes;
|
|
3566
|
+
if (attributes === void 0) return;
|
|
3567
|
+
const name = attributes[PROMPT_NAME_ATTR];
|
|
3568
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
3569
|
+
const version = attributes[PROMPT_VERSION_ATTR];
|
|
3570
|
+
return `${name}@${typeof version === "string" && version.length > 0 ? version : "1"}`;
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* Does a single trace pass the given filter? Pure predicate — all
|
|
3574
|
+
* conditions are ANDed; an unset field never excludes. Used by
|
|
3575
|
+
* {@link filterTraces} and mirrored by the dashboard's inlined JS.
|
|
3576
|
+
*/
|
|
3577
|
+
function matchesFilter(trace, filter) {
|
|
3578
|
+
const root = trace.root;
|
|
3579
|
+
if (filter.errorsOnly === true && !ERROR_STATUSES.includes(root.status)) return false;
|
|
3580
|
+
if (filter.statuses !== void 0 && filter.statuses.length > 0) {
|
|
3581
|
+
if (!filter.statuses.includes(root.status)) return false;
|
|
3582
|
+
}
|
|
3583
|
+
if (filter.types !== void 0 && filter.types.length > 0) {
|
|
3584
|
+
if (!filter.types.includes(root.type)) return false;
|
|
3585
|
+
}
|
|
3586
|
+
if (filter.sessionId !== void 0 && filter.sessionId.length > 0) {
|
|
3587
|
+
if (trace.sessionId !== filter.sessionId) return false;
|
|
3588
|
+
}
|
|
3589
|
+
if (filter.promptKey !== void 0 && filter.promptKey.length > 0) {
|
|
3590
|
+
if (tracePromptKey(trace) !== filter.promptKey) return false;
|
|
3591
|
+
}
|
|
3592
|
+
const text = filter.text?.trim().toLowerCase();
|
|
3593
|
+
if (text !== void 0 && text.length > 0) {
|
|
3594
|
+
if (!`${root.name} ${trace.sessionId ?? ""}`.toLowerCase().includes(text)) return false;
|
|
3595
|
+
}
|
|
3596
|
+
return true;
|
|
3597
|
+
}
|
|
3598
|
+
/** Narrow a trace list to those matching the filter, order preserved. */
|
|
3599
|
+
function filterTraces(traces, filter) {
|
|
3600
|
+
return traces.filter((trace) => matchesFilter(trace, filter));
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* The grouping key a trace falls under when the list is grouped by
|
|
3604
|
+
* session. Traces without a `sessionId` collapse into one synthetic
|
|
3605
|
+
* "(no session)" bucket so they remain visible.
|
|
3606
|
+
*/
|
|
3607
|
+
const NO_SESSION_KEY = "(no session)";
|
|
3608
|
+
/**
|
|
3609
|
+
* Group an (already filtered, newest-first) trace list by session id,
|
|
3610
|
+
* preserving first-seen order of both the groups and the traces within
|
|
3611
|
+
* each. Sessionless traces bucket under {@link NO_SESSION_KEY}.
|
|
3612
|
+
*/
|
|
3613
|
+
function groupBySession(traces) {
|
|
3614
|
+
const order = [];
|
|
3615
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3616
|
+
for (const trace of traces) {
|
|
3617
|
+
const key = trace.sessionId ?? "(no session)";
|
|
3618
|
+
let bucket = byKey.get(key);
|
|
3619
|
+
if (bucket === void 0) {
|
|
3620
|
+
bucket = [];
|
|
3621
|
+
byKey.set(key, bucket);
|
|
3622
|
+
order.push(key);
|
|
3623
|
+
}
|
|
3624
|
+
bucket.push(trace);
|
|
3625
|
+
}
|
|
3626
|
+
return order.map((sessionId) => ({
|
|
3627
|
+
sessionId,
|
|
3628
|
+
traces: byKey.get(sessionId) ?? []
|
|
3629
|
+
}));
|
|
3630
|
+
}
|
|
3631
|
+
/**
|
|
3632
|
+
* The grouping key a trace falls under when the list is grouped by prompt
|
|
3633
|
+
* version. Runs that resolved no named prompt collapse into one synthetic
|
|
3634
|
+
* "(no prompt)" bucket so they remain visible.
|
|
3635
|
+
*/
|
|
3636
|
+
const NO_PROMPT_KEY = "(no prompt)";
|
|
3637
|
+
/**
|
|
3638
|
+
* Group an (already filtered, newest-first) trace list by prompt version —
|
|
3639
|
+
* the `name@version` key {@link tracePromptKey} derives from each run's
|
|
3640
|
+
* prompt-version-linkage attributes. Preserves first-seen order of both the
|
|
3641
|
+
* groups and the traces within each. Runs with no named prompt bucket under
|
|
3642
|
+
* {@link NO_PROMPT_KEY}.
|
|
3643
|
+
*
|
|
3644
|
+
* The dashboard offers this as a second group-by dimension beside session,
|
|
3645
|
+
* so a reviewer can see every run of "support@2" together and compare its
|
|
3646
|
+
* cost / failure rate against "support@3".
|
|
3647
|
+
*/
|
|
3648
|
+
function groupByPrompt(traces) {
|
|
3649
|
+
const order = [];
|
|
3650
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3651
|
+
for (const trace of traces) {
|
|
3652
|
+
const key = tracePromptKey(trace) ?? "(no prompt)";
|
|
3653
|
+
let bucket = byKey.get(key);
|
|
3654
|
+
if (bucket === void 0) {
|
|
3655
|
+
bucket = [];
|
|
3656
|
+
byKey.set(key, bucket);
|
|
3657
|
+
order.push(key);
|
|
3658
|
+
}
|
|
3659
|
+
bucket.push(trace);
|
|
3660
|
+
}
|
|
3661
|
+
return order.map((promptKey) => ({
|
|
3662
|
+
promptKey,
|
|
3663
|
+
traces: byKey.get(promptKey) ?? []
|
|
3664
|
+
}));
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* The grouping key a trace falls under when the list is grouped by root
|
|
3668
|
+
* type. `root.type` is always present, so this is only a defensive
|
|
3669
|
+
* fallback for a malformed trace with no typed root.
|
|
3670
|
+
*/
|
|
3671
|
+
const NO_TYPE_KEY = "(no type)";
|
|
3672
|
+
/**
|
|
3673
|
+
* Group an (already filtered, newest-first) trace list by root type —
|
|
3674
|
+
* the `agent` / `workflow` / `supervisor` / `orchestrator` / `planner` /
|
|
3675
|
+
* `tool` / `batch` / `callback` discriminator on each trace's root span.
|
|
3676
|
+
* Preserves first-seen order of both the groups and the traces within each.
|
|
3677
|
+
*
|
|
3678
|
+
* The dashboard offers this as the coarsest group-by dimension beside
|
|
3679
|
+
* session and prompt: when a benchmark floods the flat list with hundreds
|
|
3680
|
+
* of one primitive (e.g. standalone `agent` runs), this collapses them
|
|
3681
|
+
* behind a per-type header so the structural primitives (the lone planner,
|
|
3682
|
+
* the team, the supervisors) are one click away instead of a scroll past.
|
|
3683
|
+
*/
|
|
3684
|
+
function groupByType(traces) {
|
|
3685
|
+
const order = [];
|
|
3686
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3687
|
+
for (const trace of traces) {
|
|
3688
|
+
const key = trace.root.type || "(no type)";
|
|
3689
|
+
let bucket = byKey.get(key);
|
|
3690
|
+
if (bucket === void 0) {
|
|
3691
|
+
bucket = [];
|
|
3692
|
+
byKey.set(key, bucket);
|
|
3693
|
+
order.push(key);
|
|
3694
|
+
}
|
|
3695
|
+
bucket.push(trace);
|
|
3696
|
+
}
|
|
3697
|
+
return order.map((type) => ({
|
|
3698
|
+
type,
|
|
3699
|
+
traces: byKey.get(type) ?? []
|
|
3700
|
+
}));
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Nearest-rank percentile of `values` at `p` (0–100). Returns 0 for an
|
|
3704
|
+
* empty input and sorts a copy so the caller's array is left untouched.
|
|
3705
|
+
*/
|
|
3706
|
+
function percentile(values, p) {
|
|
3707
|
+
if (values.length === 0) return 0;
|
|
3708
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
3709
|
+
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
3710
|
+
return sorted[Math.min(Math.max(rank, 0), sorted.length - 1)];
|
|
3711
|
+
}
|
|
3712
|
+
/**
|
|
3713
|
+
* One trace's total USD cost: its explicit root-usage cost when priced,
|
|
3714
|
+
* else the rollup of its subtree. Mirrors the dashboard's `traceCost` so the
|
|
3715
|
+
* stats panel and the trace rows agree to the cent.
|
|
3716
|
+
*/
|
|
3717
|
+
function traceCost(trace) {
|
|
3718
|
+
const own = costSumObj(trace.usage.cost);
|
|
3719
|
+
return own > 0 ? own : rollupCost(trace.root);
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Aggregate an (already filtered) trace list into one {@link TypeStat} row
|
|
3723
|
+
* per root type — count, failure rate, p50/p95 latency, total tokens, total
|
|
3724
|
+
* cost — preserving first-seen type order. Powers the dashboard's per-type
|
|
3725
|
+
* stats panel; computed over the same filtered list the trace view shows, so
|
|
3726
|
+
* the panel always reflects the active filters.
|
|
3727
|
+
*/
|
|
3728
|
+
function aggregateByType(traces) {
|
|
3729
|
+
const order = [];
|
|
3730
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3731
|
+
for (const trace of traces) {
|
|
3732
|
+
const key = trace.root.type || "(no type)";
|
|
3733
|
+
let bucket = byKey.get(key);
|
|
3734
|
+
if (bucket === void 0) {
|
|
3735
|
+
bucket = [];
|
|
3736
|
+
byKey.set(key, bucket);
|
|
3737
|
+
order.push(key);
|
|
3738
|
+
}
|
|
3739
|
+
bucket.push(trace);
|
|
3740
|
+
}
|
|
3741
|
+
return order.map((type) => {
|
|
3742
|
+
const bucket = byKey.get(type) ?? [];
|
|
3743
|
+
const durations = bucket.map((trace) => trace.duration);
|
|
3744
|
+
let failed = 0;
|
|
3745
|
+
let tokens = 0;
|
|
3746
|
+
let cost = 0;
|
|
3747
|
+
for (const trace of bucket) {
|
|
3748
|
+
if (ERROR_STATUSES.includes(trace.root.status)) failed += 1;
|
|
3749
|
+
tokens += trace.usage.total ?? 0;
|
|
3750
|
+
cost += traceCost(trace);
|
|
3751
|
+
}
|
|
3752
|
+
return {
|
|
3753
|
+
type,
|
|
3754
|
+
count: bucket.length,
|
|
3755
|
+
failed,
|
|
3756
|
+
failRate: bucket.length > 0 ? failed / bucket.length : 0,
|
|
3757
|
+
p50: percentile(durations, 50),
|
|
3758
|
+
p95: percentile(durations, 95),
|
|
3759
|
+
tokens,
|
|
3760
|
+
cost
|
|
3761
|
+
};
|
|
3762
|
+
});
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* Sum every priced lane of a usage cost object into one USD number.
|
|
3766
|
+
* Mirrors the dashboard's `costSumObj` so heatmap intensities computed
|
|
3767
|
+
* here match what the page renders.
|
|
3768
|
+
*/
|
|
3769
|
+
function costSumObj(cost) {
|
|
3770
|
+
if (cost === void 0) return 0;
|
|
3771
|
+
return (cost.input ?? 0) + (cost.output ?? 0) + (cost.cachedInput ?? 0) + (cost.cachedOutput ?? 0) + (cost.reasoning ?? 0);
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* Rollup-aware subtree cost: a node's own cost when it carries one
|
|
3775
|
+
* (it already rolls up its trips), otherwise the sum of its children.
|
|
3776
|
+
* Avoids double-counting wrapper nodes (workflow/supervisor roots carry
|
|
3777
|
+
* tokens but no cost). Mirrors the dashboard's `rollupCost`.
|
|
3778
|
+
*/
|
|
3779
|
+
function rollupCost(span) {
|
|
3780
|
+
const own = costSumObj(span.usage.cost);
|
|
3781
|
+
if (own > 0) return own;
|
|
3782
|
+
let sum = 0;
|
|
3783
|
+
for (const child of span.children) sum += rollupCost(child);
|
|
3784
|
+
return sum;
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* Heatmap intensity in `[0, 1]` for a node's `rollupCost` relative to the
|
|
3788
|
+
* trace's most-expensive node. The dashboard tints each tree node's left
|
|
3789
|
+
* accent by this. `max <= 0` (a free trace) yields `0` for every node so
|
|
3790
|
+
* the heatmap simply stays cold rather than dividing by zero.
|
|
3791
|
+
*/
|
|
3792
|
+
function heatIntensity(nodeCost, maxCost) {
|
|
3793
|
+
if (maxCost <= 0 || nodeCost <= 0) return 0;
|
|
3794
|
+
const ratio = nodeCost / maxCost;
|
|
3795
|
+
return ratio > 1 ? 1 : ratio;
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* The largest single-node {@link rollupCost} anywhere in a span tree —
|
|
3799
|
+
* the denominator for {@link heatIntensity}. Walks the whole subtree.
|
|
3800
|
+
*/
|
|
3801
|
+
function maxNodeCost(root) {
|
|
3802
|
+
let max = rollupCost(root);
|
|
3803
|
+
for (const child of root.children) {
|
|
3804
|
+
const childMax = maxNodeCost(child);
|
|
3805
|
+
if (childMax > max) max = childMax;
|
|
3806
|
+
}
|
|
3807
|
+
return max;
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
//#endregion
|
|
3811
|
+
//#region ../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts
|
|
3812
|
+
let applied;
|
|
3813
|
+
/**
|
|
3814
|
+
* Read a {@link PanopticConfig} and wire panoptic onto the core observe
|
|
3815
|
+
* seam — the bridge `ai.config({ panoptic })` resolves through. Builds a
|
|
3816
|
+
* collector via the EXISTING {@link panoptic} factory (reusing its
|
|
3817
|
+
* collection pipeline, not reinventing it), registers it once via core's
|
|
3818
|
+
* `registerObserver` (the subscriber's `collect(report)` structurally IS
|
|
3819
|
+
* an {@link Observer}, threaded through a thin wrapper), sets
|
|
3820
|
+
* `observeAll`, and — when `config.dashboard` is set — starts the
|
|
3821
|
+
* dashboard over the config's store (a store-shaped exporter, or a fresh
|
|
3822
|
+
* in-memory store panoptic registers when none was supplied).
|
|
3823
|
+
*
|
|
3824
|
+
* **Idempotent.** Safe to call on every `onConfigApplied` notification:
|
|
3825
|
+
* the observer is registered once, the dashboard started once. Repeat
|
|
3826
|
+
* calls update `observeAll` to the latest value but never double-register
|
|
3827
|
+
* or double-start. `undefined` config is a no-op (nothing wired).
|
|
3828
|
+
*
|
|
3829
|
+
* @returns the resolved store-shaped exporter when the dashboard needs
|
|
3830
|
+
* one and a fresh store was created — primarily for tests; callers can
|
|
3831
|
+
* ignore it.
|
|
3832
|
+
*/
|
|
3833
|
+
function applyPanopticConfig(config) {
|
|
3834
|
+
if (config === void 0) return;
|
|
3835
|
+
(0, _warlock_js_ai.setObserveAll)(Boolean(config.observeAll));
|
|
3836
|
+
if (applied === void 0) {
|
|
3837
|
+
const exporters = [...config.exporters ?? []];
|
|
3838
|
+
let store = findStore(exporters);
|
|
3839
|
+
let cacheStore;
|
|
3840
|
+
if (store === void 0 && config.dashboard) {
|
|
3841
|
+
if (config.cache !== void 0) {
|
|
3842
|
+
cacheStore = createCacheTraceStore(config.cache);
|
|
3843
|
+
store = cacheStore;
|
|
3844
|
+
} else store = createInMemoryTraceStore();
|
|
3845
|
+
exporters.push(store);
|
|
3846
|
+
}
|
|
3847
|
+
const panopticInstance = panoptic({
|
|
3848
|
+
exporters,
|
|
3849
|
+
captureContent: config.captureContent,
|
|
3850
|
+
fullHistory: config.fullHistory
|
|
3851
|
+
});
|
|
3852
|
+
(0, _warlock_js_ai.registerObserver)(toObserver(panopticInstance));
|
|
3853
|
+
applied = { panopticInstance };
|
|
3854
|
+
if (config.dashboard && store !== void 0) {
|
|
3855
|
+
const resolvedStore = store;
|
|
3856
|
+
(cacheStore !== void 0 ? cacheStore.ready() : Promise.resolve()).catch(() => {}).then(() => startDashboard(resolvedStore, config.dashboard ?? {})).then((handle) => {
|
|
3857
|
+
if (applied !== void 0) applied.dashboardHandle = handle;
|
|
3858
|
+
});
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
/**
|
|
3863
|
+
* Wrap a {@link Panoptic} subscriber as a core {@link Observer}. The
|
|
3864
|
+
* subscriber already exposes `collect(report)` with a matching signature,
|
|
3865
|
+
* but the thin wrapper makes the structural adaptation explicit and keeps
|
|
3866
|
+
* the registered object a minimal `Observer` rather than the whole
|
|
3867
|
+
* subscriber surface.
|
|
3868
|
+
*/
|
|
3869
|
+
function toObserver(instance) {
|
|
3870
|
+
return { collect(report) {
|
|
3871
|
+
return instance.collect(report);
|
|
3872
|
+
} };
|
|
3873
|
+
}
|
|
3874
|
+
/**
|
|
3875
|
+
* Find the first exporter that satisfies the {@link TraceStoreContract}
|
|
3876
|
+
* read surface (`query` / `get` / `aggregate`) — the in-memory store
|
|
3877
|
+
* doubles as an exporter, so a store passed via `exporters` is reused for
|
|
3878
|
+
* the dashboard rather than creating a second one.
|
|
3879
|
+
*/
|
|
3880
|
+
function findStore(exporters) {
|
|
3881
|
+
for (const exporter of exporters) {
|
|
3882
|
+
const candidate = exporter;
|
|
3883
|
+
if (typeof candidate.query === "function" && typeof candidate.get === "function" && typeof candidate.aggregate === "function") return candidate;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
/** Start the dashboard, normalizing the `true | DashboardOptions` switch. */
|
|
3887
|
+
function startDashboard(store, dashboardConfig) {
|
|
3888
|
+
return dashboard(store, typeof dashboardConfig === "object" ? dashboardConfig : {});
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
//#endregion
|
|
3892
|
+
//#region ../@warlock.js/ai-panoptic/src/register.ts
|
|
3893
|
+
(0, _warlock_js_ai.onConfigApplied)((config) => {
|
|
3894
|
+
applyPanopticConfig(config.panoptic);
|
|
3895
|
+
});
|
|
3896
|
+
applyPanopticConfig((0, _warlock_js_ai.getAIConfig)().panoptic);
|
|
3897
|
+
|
|
1348
3898
|
//#endregion
|
|
1349
3899
|
exports.GEN_AI_ATTRIBUTES = GEN_AI_ATTRIBUTES;
|
|
3900
|
+
exports.NO_PROMPT_KEY = NO_PROMPT_KEY;
|
|
3901
|
+
exports.NO_SESSION_KEY = NO_SESSION_KEY;
|
|
3902
|
+
exports.NO_TYPE_KEY = NO_TYPE_KEY;
|
|
1350
3903
|
exports.WARLOCK_ATTRIBUTES = WARLOCK_ATTRIBUTES;
|
|
3904
|
+
exports.aggregateByType = aggregateByType;
|
|
3905
|
+
exports.applyPanopticConfig = applyPanopticConfig;
|
|
1351
3906
|
exports.consoleExporter = consoleExporter;
|
|
3907
|
+
exports.createCacheTraceStore = createCacheTraceStore;
|
|
1352
3908
|
exports.createCollector = createCollector;
|
|
1353
3909
|
exports.createInMemoryTraceStore = createInMemoryTraceStore;
|
|
1354
3910
|
exports.createPanopticMiddleware = createPanopticMiddleware;
|
|
3911
|
+
exports.dashboard = dashboard;
|
|
1355
3912
|
exports.emptyUsage = emptyUsage;
|
|
1356
3913
|
exports.extractSpanAttributes = extractSpanAttributes;
|
|
1357
3914
|
exports.fileExporter = fileExporter;
|
|
3915
|
+
exports.filterTraces = filterTraces;
|
|
3916
|
+
exports.formatSpanIO = formatSpanIO;
|
|
1358
3917
|
exports.formatSpanLine = formatSpanLine;
|
|
3918
|
+
exports.groupByPrompt = groupByPrompt;
|
|
3919
|
+
exports.groupBySession = groupBySession;
|
|
3920
|
+
exports.groupByType = groupByType;
|
|
3921
|
+
exports.heatIntensity = heatIntensity;
|
|
1359
3922
|
exports.langfuseExporter = langfuseExporter;
|
|
1360
3923
|
exports.matchTrace = matchTrace;
|
|
3924
|
+
exports.matchesFilter = matchesFilter;
|
|
3925
|
+
exports.maxNodeCost = maxNodeCost;
|
|
1361
3926
|
exports.normalizeError = normalizeError;
|
|
1362
3927
|
exports.otelExporter = otelExporter;
|
|
1363
3928
|
exports.panoptic = panoptic;
|
|
3929
|
+
exports.percentile = percentile;
|
|
1364
3930
|
exports.reportToSpan = reportToSpan;
|
|
1365
3931
|
exports.reportToTrace = reportToTrace;
|
|
3932
|
+
exports.rollupCost = rollupCost;
|
|
1366
3933
|
exports.sumUsage = sumUsage;
|
|
1367
3934
|
exports.toGenAiAttributes = toGenAiAttributes;
|
|
1368
3935
|
exports.totalCostUsd = totalCostUsd;
|
|
3936
|
+
exports.traceCost = traceCost;
|
|
3937
|
+
exports.tracePromptKey = tracePromptKey;
|
|
1369
3938
|
exports.walkSpans = walkSpans;
|
|
1370
3939
|
//# sourceMappingURL=index.cjs.map
|