@warlock.js/ai-panoptic 5.30.0 → 5.32.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 +25 -3
- package/README.md +2 -2
- package/cjs/index.cjs +38 -84
- package/cjs/index.cjs.map +1 -1
- package/esm/collector/collector.d.mts +3 -5
- package/esm/collector/collector.d.mts.map +1 -1
- package/esm/collector/content-capture.type.d.mts +4 -5
- package/esm/collector/content-capture.type.d.mts.map +1 -1
- package/esm/collector/extract-span-attributes.d.mts +1 -3
- package/esm/collector/extract-span-attributes.d.mts.map +1 -1
- package/esm/collector/extract-span-attributes.mjs +2 -4
- package/esm/collector/extract-span-attributes.mjs.map +1 -1
- package/esm/collector/index.d.mts +2 -1
- package/esm/collector/index.mjs +1 -1
- package/esm/collector/normalize-error.d.mts +1 -3
- package/esm/collector/normalize-error.d.mts.map +1 -1
- package/esm/collector/report-to-span.d.mts +1 -3
- package/esm/collector/report-to-span.d.mts.map +1 -1
- package/esm/collector/report-to-span.mjs.map +1 -1
- package/esm/collector/report-to-trace.d.mts +1 -3
- package/esm/collector/report-to-trace.d.mts.map +1 -1
- package/esm/config/apply-panoptic-config.d.mts +2 -3
- package/esm/config/apply-panoptic-config.d.mts.map +1 -1
- package/esm/config/apply-panoptic-config.mjs.map +1 -1
- package/esm/config/index.d.mts +2 -1
- package/esm/config/index.mjs +1 -1
- package/esm/config/panoptic-config.type.d.mts +2 -4
- package/esm/config/panoptic-config.type.d.mts.map +1 -1
- package/esm/contracts/collector.contract.d.mts +1 -3
- package/esm/contracts/collector.contract.d.mts.map +1 -1
- package/esm/contracts/exporter.contract.d.mts +1 -3
- package/esm/contracts/exporter.contract.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +2 -1
- package/esm/contracts/trace.type.d.mts +35 -20
- package/esm/contracts/trace.type.d.mts.map +1 -1
- package/esm/dashboard/dashboard.d.mts +1 -3
- package/esm/dashboard/dashboard.d.mts.map +1 -1
- package/esm/dashboard/dashboard.mjs +3 -2
- package/esm/dashboard/dashboard.mjs.map +1 -1
- package/esm/dashboard/dashboard.type.d.mts +9 -7
- package/esm/dashboard/dashboard.type.d.mts.map +1 -1
- package/esm/dashboard/index.d.mts +4 -1
- package/esm/dashboard/index.mjs +1 -1
- package/esm/dashboard/parse-query.d.mts +1 -1
- package/esm/dashboard/parse-query.mjs +1 -0
- package/esm/dashboard/parse-query.mjs.map +1 -1
- package/esm/dashboard/serve.d.mts +2 -1
- package/esm/dashboard/serve.mjs +1 -1
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/trace-filter.d.mts +44 -32
- package/esm/dashboard/trace-filter.d.mts.map +1 -1
- package/esm/dashboard/trace-filter.mjs +1 -1
- package/esm/dashboard/trace-filter.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs +5 -4
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/esm/evaluate/evaluate-system-prompt.d.mts +1 -3
- package/esm/evaluate/evaluate-system-prompt.d.mts.map +1 -1
- package/esm/evaluate/evaluate-system-prompt.mjs +3 -1
- package/esm/evaluate/evaluate-system-prompt.mjs.map +1 -1
- package/esm/evaluate/evaluate.type.d.mts +2 -4
- package/esm/evaluate/evaluate.type.d.mts.map +1 -1
- package/esm/evaluate/extract-last-system-prompt.d.mts +1 -3
- package/esm/evaluate/extract-last-system-prompt.d.mts.map +1 -1
- package/esm/evaluate/find-span-by-id.d.mts +1 -3
- package/esm/evaluate/find-span-by-id.d.mts.map +1 -1
- package/esm/evaluate/index.d.mts +2 -1
- package/esm/evaluate/index.mjs +1 -1
- package/esm/exporters/console/console-exporter.d.mts +2 -3
- package/esm/exporters/console/console-exporter.d.mts.map +1 -1
- package/esm/exporters/console/console-exporter.mjs +4 -3
- package/esm/exporters/console/console-exporter.mjs.map +1 -1
- package/esm/exporters/console/console-exporter.type.d.mts +2 -3
- package/esm/exporters/console/console-exporter.type.d.mts.map +1 -1
- package/esm/exporters/console/format-span-io.d.mts +2 -2
- package/esm/exporters/console/format-span-io.d.mts.map +1 -1
- package/esm/exporters/console/format-span-io.mjs.map +1 -1
- package/esm/exporters/console/format-span-line.d.mts +2 -2
- package/esm/exporters/console/format-span-line.d.mts.map +1 -1
- package/esm/exporters/console/format-span-line.mjs +2 -0
- package/esm/exporters/console/format-span-line.mjs.map +1 -1
- package/esm/exporters/console/index.d.mts +2 -1
- package/esm/exporters/console/index.mjs +5 -0
- package/esm/exporters/file/file-exporter.d.mts +2 -3
- package/esm/exporters/file/file-exporter.d.mts.map +1 -1
- package/esm/exporters/file/file-exporter.mjs.map +1 -1
- package/esm/exporters/file/file-exporter.type.d.mts +8 -5
- package/esm/exporters/file/file-exporter.type.d.mts.map +1 -1
- package/esm/exporters/file/index.d.mts +2 -1
- package/esm/exporters/index.d.mts +7 -1
- package/esm/exporters/index.mjs +3 -1
- package/esm/exporters/langfuse/index.d.mts +2 -1
- package/esm/exporters/langfuse/index.mjs +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.d.mts +2 -3
- package/esm/exporters/langfuse/langfuse-exporter.d.mts.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.mjs +4 -3
- package/esm/exporters/langfuse/langfuse-exporter.mjs.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.type.d.mts +23 -17
- package/esm/exporters/langfuse/langfuse-exporter.type.d.mts.map +1 -1
- package/esm/exporters/otel/index.d.mts +2 -1
- package/esm/exporters/otel/index.mjs +1 -1
- package/esm/exporters/otel/otel-api.shim.type.d.mts +6 -7
- package/esm/exporters/otel/otel-api.shim.type.d.mts.map +1 -1
- package/esm/exporters/otel/otel-exporter.d.mts +2 -3
- package/esm/exporters/otel/otel-exporter.d.mts.map +1 -1
- package/esm/exporters/otel/otel-exporter.mjs +2 -1
- package/esm/exporters/otel/otel-exporter.mjs.map +1 -1
- package/esm/exporters/otel/otel-exporter.type.d.mts +1 -3
- package/esm/exporters/otel/otel-exporter.type.d.mts.map +1 -1
- package/esm/exporters/utils/gen-ai-attributes.d.mts +9 -7
- package/esm/exporters/utils/gen-ai-attributes.d.mts.map +1 -1
- package/esm/exporters/utils/index.d.mts +2 -1
- package/esm/exporters/utils/index.mjs +5 -0
- package/esm/exporters/utils/total-cost.d.mts +3 -7
- package/esm/exporters/utils/total-cost.d.mts.map +1 -1
- package/esm/exporters/utils/total-cost.mjs +5 -7
- package/esm/exporters/utils/total-cost.mjs.map +1 -1
- package/esm/exporters/utils/walk-spans.d.mts +2 -2
- package/esm/exporters/utils/walk-spans.d.mts.map +1 -1
- package/esm/index.d.mts +8 -0
- package/esm/panoptic/index.d.mts +2 -1
- package/esm/panoptic/index.mjs +1 -1
- package/esm/panoptic/panoptic-middleware.d.mts +1 -3
- package/esm/panoptic/panoptic-middleware.d.mts.map +1 -1
- package/esm/panoptic/panoptic-middleware.mjs.map +1 -1
- package/esm/panoptic/panoptic-target.type.d.mts +4 -5
- package/esm/panoptic/panoptic-target.type.d.mts.map +1 -1
- package/esm/panoptic/panoptic.d.mts +1 -3
- package/esm/panoptic/panoptic.d.mts.map +1 -1
- package/esm/panoptic/panoptic.mjs.map +1 -1
- package/esm/panoptic/panoptic.type.d.mts +8 -7
- package/esm/panoptic/panoptic.type.d.mts.map +1 -1
- package/esm/store/cache-trace-store.d.mts +4 -6
- package/esm/store/cache-trace-store.d.mts.map +1 -1
- package/esm/store/cache-trace-store.mjs +2 -4
- package/esm/store/cache-trace-store.mjs.map +1 -1
- package/esm/store/in-memory-trace-store.d.mts +2 -4
- package/esm/store/in-memory-trace-store.d.mts.map +1 -1
- package/esm/store/in-memory-trace-store.mjs +4 -5
- package/esm/store/in-memory-trace-store.mjs.map +1 -1
- package/esm/store/index.d.mts +2 -1
- package/esm/store/index.mjs +1 -1
- package/esm/store/match-trace.d.mts +1 -3
- package/esm/store/match-trace.d.mts.map +1 -1
- package/esm/store/sum-usage.d.mts +4 -30
- package/esm/store/sum-usage.d.mts.map +1 -1
- package/esm/store/sum-usage.mjs +6 -49
- package/esm/store/sum-usage.mjs.map +1 -1
- package/esm/store/trace-aggregate.type.d.mts +13 -14
- package/esm/store/trace-aggregate.type.d.mts.map +1 -1
- package/esm/store/trace-query.type.d.mts +5 -5
- package/esm/store/trace-query.type.d.mts.map +1 -1
- package/esm/store/trace-store.contract.d.mts +1 -3
- package/esm/store/trace-store.contract.d.mts.map +1 -1
- package/llms-full.txt +148 -117
- package/package.json +4 -4
- package/skills/evaluate-system-prompt/SKILL.md +21 -17
- package/skills/export-traces/SKILL.md +32 -24
- package/skills/index.md +3 -0
- package/skills/observe-with-panoptic/SKILL.md +18 -18
- package/skills/query-traces/SKILL.md +45 -33
- package/skills/use-local-dashboard/SKILL.md +33 -26
|
@@ -31,10 +31,10 @@ Absent `evaluate` ⇒ the drawer never renders the button and a `POST` to its ro
|
|
|
31
31
|
|
|
32
32
|
### `EvaluateConfig`
|
|
33
33
|
|
|
34
|
-
| Field
|
|
35
|
-
|
|
36
|
-
| `model`
|
|
37
|
-
| `instructions` | no
|
|
34
|
+
| Field | Required | Notes |
|
|
35
|
+
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `model` | yes | A `ModelContract`, or a `() => ModelContract \| Promise<ModelContract>` factory — mirrors `panoptic.cache`'s literal-or-factory ergonomics. Resolved fresh on every evaluate click (a deliberate, infrequent, human-triggered action — not the collector's hot path — so no memoization, unlike the cache store's driver). |
|
|
37
|
+
| `instructions` | no | Default grading rubric/criteria, verbatim — the same shape `ai.prompts().validate({ criteria })` accepts. Seeds the dashboard's editable textarea (baked into the served page at boot, so the operator sees and can extend the actual default, not a blank box); the built-in prompt-quality rubric is used when neither this nor a per-run override is supplied. |
|
|
38
38
|
|
|
39
39
|
## The UI
|
|
40
40
|
|
|
@@ -45,10 +45,14 @@ Click it to open an inline panel: a textarea pre-filled with the configured `ins
|
|
|
45
45
|
## The building blocks (for embedding elsewhere)
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
import {
|
|
49
|
+
evaluateSystemPrompt,
|
|
50
|
+
extractLastSystemPrompt,
|
|
51
|
+
findSpanById,
|
|
52
|
+
} from "@warlock.js/ai-panoptic";
|
|
53
|
+
|
|
54
|
+
const span = findSpanById(trace.root, spanId); // walk the span tree
|
|
55
|
+
const prompt = extractLastSystemPrompt(span); // undefined if none captured
|
|
52
56
|
if (prompt) {
|
|
53
57
|
const verdict = await evaluateSystemPrompt(prompt, evaluateConfig, "Per-run override, optional");
|
|
54
58
|
// verdict: { score?: number; issues: string[] }
|
|
@@ -63,15 +67,15 @@ if (prompt) {
|
|
|
63
67
|
|
|
64
68
|
`POST {basePath}api/traces/:traceId/spans/:spanId/evaluate`, body `{ instructions?: string }` (all optional — an empty body is valid). Gated by the same `authToken` / `allowedHosts` checks as every other route (S4) — off-loopback still requires a token, exactly like the rest of the dashboard. Body reads are capped at 64 KB (`413` past that) so a hostile/oversized payload can't hold the connection open.
|
|
65
69
|
|
|
66
|
-
| Status | Meaning
|
|
67
|
-
|
|
68
|
-
| `200`
|
|
69
|
-
| `404`
|
|
70
|
-
| `422`
|
|
71
|
-
| `413`
|
|
72
|
-
| `400`
|
|
73
|
-
| `502`
|
|
74
|
-
| `405`
|
|
70
|
+
| Status | Meaning |
|
|
71
|
+
| ------ | ---------------------------------------------------------- |
|
|
72
|
+
| `200` | `EvaluateVerdict` — `{ score?: number; issues: string[] }` |
|
|
73
|
+
| `404` | trace or span not found |
|
|
74
|
+
| `422` | the span carried no captured system prompt to grade |
|
|
75
|
+
| `413` | request body over 64 KB |
|
|
76
|
+
| `400` | request body isn't valid JSON |
|
|
77
|
+
| `502` | `config.model` (a factory) threw resolving the judge model |
|
|
78
|
+
| `405` | POST hit this path but `evaluate` isn't configured |
|
|
75
79
|
|
|
76
80
|
```sh
|
|
77
81
|
curl -X POST 'http://127.0.0.1:4319/api/traces/tr_1/spans/sp_1/evaluate' \
|
|
@@ -21,35 +21,35 @@ Four exporters ship today. Each is a **factory** returning an `ExporterContract`
|
|
|
21
21
|
|
|
22
22
|
## Pick an exporter
|
|
23
23
|
|
|
24
|
-
| Exporter
|
|
25
|
-
|
|
26
|
-
| `consoleExporter()`
|
|
27
|
-
| `fileExporter()`
|
|
28
|
-
| `otelExporter()`
|
|
29
|
-
| `langfuseExporter()` | Langfuse
|
|
24
|
+
| Exporter | Backend | Deps | Use it for |
|
|
25
|
+
| -------------------- | --------------- | ------------------------------------ | ------------------------------------------------ |
|
|
26
|
+
| `consoleExporter()` | stdout/stderr | none | local dev, CI logs |
|
|
27
|
+
| `fileExporter()` | JSON-Lines file | none | durable local sink, replay, out-of-band shipping |
|
|
28
|
+
| `otelExporter()` | OpenTelemetry | `@opentelemetry/api` (optional peer) | OTel-native stacks, GenAI semantic conventions |
|
|
29
|
+
| `langfuseExporter()` | Langfuse | `langfuse` (optional peer) | LLM-specific dashboards, generations |
|
|
30
30
|
|
|
31
|
-
> `@opentelemetry/api` and `langfuse` are **OPTIONAL peer dependencies**, lazily imported by their exporter. Importing `@warlock.js/ai-panoptic` never pulls them in; a missing SDK surfaces as a curated
|
|
31
|
+
> `@opentelemetry/api` and `langfuse` are **OPTIONAL peer dependencies**, lazily imported by their exporter. Importing `@warlock.js/ai-panoptic` never pulls them in; a missing SDK surfaces as a curated _"install this"_ error on first export, never a boot-time stack trace.
|
|
32
32
|
|
|
33
33
|
## Console
|
|
34
34
|
|
|
35
35
|
```ts
|
|
36
36
|
import { consoleExporter } from "@warlock.js/ai-panoptic";
|
|
37
37
|
|
|
38
|
-
collector.use(consoleExporter());
|
|
39
|
-
collector.use(consoleExporter({ tree: true }));
|
|
38
|
+
collector.use(consoleExporter()); // one summary line per trace
|
|
39
|
+
collector.use(consoleExporter({ tree: true })); // full indented span tree
|
|
40
40
|
collector.use(consoleExporter({ streaming: true })); // also print each span as it finalizes
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Failed/cancelled spans route to `console.error`; everything else to `console.log`. Inject a sink (`{ console: mySink }`) to redirect or to capture in tests.
|
|
43
|
+
Failed/cancelled/incomplete spans route to `console.error`; everything else to `console.log`. Inject a sink (`{ console: mySink }`) to redirect or to capture in tests.
|
|
44
44
|
|
|
45
45
|
## File
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
48
|
import { fileExporter } from "@warlock.js/ai-panoptic";
|
|
49
49
|
|
|
50
|
-
collector.use(fileExporter({ path: "storage/traces.jsonl" }));
|
|
51
|
-
collector.use(fileExporter({ path: "...", flushEvery: 50 }));
|
|
52
|
-
collector.use(fileExporter({ path: "...", pretty: true }));
|
|
50
|
+
collector.use(fileExporter({ path: "storage/traces.jsonl" })); // write every trace
|
|
51
|
+
collector.use(fileExporter({ path: "...", flushEvery: 50 })); // batch 50 traces per write
|
|
52
|
+
collector.use(fileExporter({ path: "...", pretty: true })); // multi-line records
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Appends one JSON record per line (a `TraceRecord` = `{ type: "trace", exportedAt, trace }`). Buffers in memory and drains on `flush()` / `shutdown()` — call `collector.shutdown()` on teardown so the last batch lands. The parent directory is created on first write.
|
|
@@ -75,9 +75,9 @@ Each `TraceSpan` becomes one OTel span with the source start/end times and paren
|
|
|
75
75
|
- `gen_ai.conversation.id` (from the span's `sessionId`)
|
|
76
76
|
- `gen_ai.system` / `gen_ai.request.model` / `gen_ai.operation.name` — **never invented**: these appear only when the span's free-form `attributes` bag already carried that exact key. `otelExporter`'s `system` option backfills `gen_ai.system` when the span supplied none (and never overrides one it did).
|
|
77
77
|
- `warlock.report.type` / `warlock.version` / `warlock.duration_ms` / `warlock.cost.usd`
|
|
78
|
-
- the collector's own per-primitive keys ride along verbatim — `agent.model.name` / `agent.model.provider` / `agent.trips`, `workflow.steps`, `supervisor.iterations` / `supervisor.terminatedBy`, `tool.tripIndex`, `retries`, … — since `toGenAiAttributes` copies every scalar entry of the span's `attributes` bag.
|
|
78
|
+
- the collector's own per-primitive keys ride along verbatim — `agent.model.name` / `agent.model.provider` / `agent.trips`, `workflow.steps`, `supervisor.iterations` / `supervisor.terminatedBy`, `tool.tripIndex`, `tool.callId`, `retries`, … — since `toGenAiAttributes` copies every scalar entry of the span's `attributes` bag.
|
|
79
79
|
|
|
80
|
-
Failed/cancelled spans get OTel `ERROR` status plus a recorded exception.
|
|
80
|
+
Failed/cancelled/incomplete spans get OTel `ERROR` status plus a recorded exception.
|
|
81
81
|
|
|
82
82
|
## Langfuse
|
|
83
83
|
|
|
@@ -100,11 +100,12 @@ Exporters surface `span.input` / `span.output` (console `in:` / `out:`, file JSO
|
|
|
100
100
|
|
|
101
101
|
```ts
|
|
102
102
|
panoptic({
|
|
103
|
-
exporters: [langfuseExporter({
|
|
104
|
-
captureContent: true,
|
|
105
|
-
redactContent: (value, { field }) =>
|
|
103
|
+
exporters: [langfuseExporter({/* ... */})],
|
|
104
|
+
captureContent: true, // copy prompts/responses + tool args/results onto spans
|
|
105
|
+
redactContent: (value, { field }) =>
|
|
106
|
+
// optional masker, per value
|
|
106
107
|
field === "input" ? "[redacted]" : value,
|
|
107
|
-
fullHistory: true,
|
|
108
|
+
fullHistory: true, // emit the WHOLE conversation as span.input (see below)
|
|
108
109
|
});
|
|
109
110
|
```
|
|
110
111
|
|
|
@@ -131,11 +132,18 @@ Reach for `fullHistory` when a backend (Langfuse) should show the full multi-tur
|
|
|
131
132
|
Reusable when authoring a **custom exporter**:
|
|
132
133
|
|
|
133
134
|
```ts
|
|
134
|
-
import {
|
|
135
|
+
import {
|
|
136
|
+
walkSpans,
|
|
137
|
+
toGenAiAttributes,
|
|
138
|
+
totalCostUsd,
|
|
139
|
+
GEN_AI_ATTRIBUTES,
|
|
140
|
+
WARLOCK_ATTRIBUTES,
|
|
141
|
+
} from "@warlock.js/ai-panoptic";
|
|
135
142
|
|
|
136
|
-
for (const span of walkSpans(trace.root)) {
|
|
137
|
-
|
|
138
|
-
const
|
|
143
|
+
for (const span of walkSpans(trace.root)) {
|
|
144
|
+
// depth-first pre-order
|
|
145
|
+
const attributes = toGenAiAttributes(span); // gen_ai.* / warlock.* attribute map
|
|
146
|
+
const cost = totalCostUsd(span.usage); // single USD scalar, or undefined
|
|
139
147
|
}
|
|
140
148
|
```
|
|
141
149
|
|
|
@@ -180,5 +188,5 @@ Reach for `reportToTrace` when you already hold a finished `result.report` and w
|
|
|
180
188
|
## See also
|
|
181
189
|
|
|
182
190
|
- The `observe-with-panoptic` topic — the source end: wiring the `panoptic(...)` subscriber into a run (`attach` / `middleware` / `collect`). The `collector` these exporters register on comes from there.
|
|
183
|
-
- The `query-traces` topic — the in-memory trace store: a sibling sink that
|
|
191
|
+
- The `query-traces` topic — the in-memory trace store: a sibling sink that _retains_ traces (instead of shipping them out) so you can `query` / `aggregate` them after the fact.
|
|
184
192
|
- The `run-ai-agent` topic of the `warlock-js-ai` skill — running the agent and the `BaseReport` shape Panoptic projects into a `Trace`.
|
package/skills/index.md
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Observability for @warlock.js/ai: turns agent, workflow and team run reports into traces and ships them to OpenTelemetry, Langfuse, file, console or a local dashboard. Exports `panoptic`, `createCollector`, `createInMemoryTraceStore`, `otelExporter`, `langfuseExporter`, `consoleExporter`, `fileExporter`, `dashboard`. Use for: trace an agent run, see token cost and latency per span, send traces to Langfuse or OTel, query past traces, open a local trace viewer, evaluate a system prompt. Not this package: plain logging → @warlock.js/logger; building agents → @warlock.js/ai; provider adapters → ai-<provider> packages."
|
|
3
3
|
---
|
|
4
|
+
|
|
4
5
|
# @warlock.js/ai-panoptic
|
|
5
6
|
|
|
6
7
|
Panoptic subscribes to the execution reports every `@warlock.js/ai` primitive already produces, normalizes them into vendor-neutral traces (a trace holds spans with usage, cost and errors), stores them, and fans them out to exporters. Backend SDKs are optional peers, lazily imported.
|
|
7
8
|
|
|
8
9
|
## The 80% path
|
|
10
|
+
|
|
9
11
|
1. Call `panoptic(...)` with exporters and attach it to a run or globally (`observe-with-panoptic.md`).
|
|
10
12
|
2. Pick sinks (`export-traces.md`); start with console or file locally.
|
|
11
13
|
3. Query stored traces (`query-traces.md`).
|
|
12
14
|
4. Browse them with zero setup (`use-local-dashboard.md`), and grade a captured system prompt from there (`evaluate-system-prompt.md`).
|
|
13
15
|
|
|
14
16
|
## Conventions and pitfalls
|
|
17
|
+
|
|
15
18
|
- Importing the package activates `ai.config({ panoptic })`; a bare import is enough for declarative setup.
|
|
16
19
|
- Each run reaches every exporter once, however it is fed; do not register the same collector twice.
|
|
17
20
|
- Prompt and response content capture can hold sensitive data: use the content-capture and redactor options before exporting.
|
|
@@ -17,9 +17,9 @@ import "@warlock.js/ai-panoptic"; // side-effect wiring — do this once at boot
|
|
|
17
17
|
|
|
18
18
|
ai.config({
|
|
19
19
|
panoptic: {
|
|
20
|
-
exporters: [consoleExporter()],
|
|
21
|
-
observeAll: true,
|
|
22
|
-
dashboard: true,
|
|
20
|
+
exporters: [consoleExporter()], // optional; a store-shaped exporter also feeds the dashboard
|
|
21
|
+
observeAll: true, // observe every flow by default
|
|
22
|
+
dashboard: true, // optional local viewer — see use-local-dashboard
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
```
|
|
@@ -30,13 +30,13 @@ ai.config({
|
|
|
30
30
|
|
|
31
31
|
Once a collector is registered, which flows actually route to it is controlled by core's `observe` seam:
|
|
32
32
|
|
|
33
|
-
| Setting
|
|
34
|
-
|
|
35
|
-
| `observeAll: true` (config)
|
|
36
|
-
| `observe: true` (per-flow)
|
|
37
|
-
| `observe: false` (per-flow)
|
|
33
|
+
| Setting | Effect |
|
|
34
|
+
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `observeAll: true` (config) | Every `ai.agent` / `ai.workflow` / `ai.supervisor` / `ai.team` that left `observe` unset routes its completed report to the registered observers. |
|
|
36
|
+
| `observe: true` (per-flow) | Force-observe **this** flow even when `observeAll` is off → the globally registered observers. |
|
|
37
|
+
| `observe: false` (per-flow) | Opt **this** flow out entirely, even when `observeAll` is on. |
|
|
38
38
|
| `observe: <Observer>` (per-flow) | A **flow-local** collector — only this flow's report, routed only to it (the global observers are skipped). A panoptic flow-local collector implements `Observer`, so pass it directly. |
|
|
39
|
-
| (unset, `observeAll: false`)
|
|
39
|
+
| (unset, `observeAll: false`) | Not observed — opt-in default, identical to legacy behavior. |
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
42
|
ai.agent({ model, observe: true }); // always traced
|
|
@@ -64,11 +64,11 @@ const observe = panoptic({
|
|
|
64
64
|
|
|
65
65
|
## Three feed paths
|
|
66
66
|
|
|
67
|
-
| Path
|
|
68
|
-
|
|
69
|
-
| **Events**
|
|
70
|
-
| **Middleware** | The app already composes cross-cutting concerns through the agent middleware pipeline.
|
|
71
|
-
| **Direct**
|
|
67
|
+
| Path | Use when | API |
|
|
68
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
|
69
|
+
| **Events** | You want every run captured automatically and the primitive emits a `*.completed` event (agent / workflow / supervisor). | `observe.attach(target)` |
|
|
70
|
+
| **Middleware** | The app already composes cross-cutting concerns through the agent middleware pipeline. | `panoptic().middleware()` |
|
|
71
|
+
| **Direct** | The primitive carries no result-bearing event (the orchestrator), or you captured a report out of band. | `observe.collect(report)` |
|
|
72
72
|
|
|
73
73
|
### 1. Attach to the event stream
|
|
74
74
|
|
|
@@ -96,7 +96,7 @@ panoptic({ exporters, completedEvents: ["agent.completed"] }); // agents only
|
|
|
96
96
|
### 2. Wire it as agent middleware
|
|
97
97
|
|
|
98
98
|
```ts
|
|
99
|
-
const observe = panoptic({ exporters: [langfuseExporter({
|
|
99
|
+
const observe = panoptic({ exporters: [langfuseExporter({/* ... */})] });
|
|
100
100
|
|
|
101
101
|
const agent = ai.agent({
|
|
102
102
|
model,
|
|
@@ -128,12 +128,12 @@ An observability fault never escapes onto the AI run's hot path:
|
|
|
128
128
|
- The collector wraps every exporter invocation in `Promise.allSettled`, so a throwing exporter can't crash the run or block its siblings.
|
|
129
129
|
- The event handler and the middleware additionally swallow the `collect` rejection, so even a collector-level fault stays contained.
|
|
130
130
|
|
|
131
|
-
A broken backend degrades to
|
|
131
|
+
A broken backend degrades to _missing telemetry_, never a failed agent run.
|
|
132
132
|
|
|
133
133
|
## Lifecycle
|
|
134
134
|
|
|
135
135
|
```ts
|
|
136
|
-
await observe.flush();
|
|
136
|
+
await observe.flush(); // drain buffered exporters (between batches / checkpoints)
|
|
137
137
|
await observe.shutdown(); // flush + release every exporter (process teardown)
|
|
138
138
|
```
|
|
139
139
|
|
|
@@ -146,7 +146,7 @@ The in-memory trace store doubles as an `ExporterContract`, so the simplest way
|
|
|
146
146
|
```ts
|
|
147
147
|
import { panoptic, createInMemoryTraceStore } from "@warlock.js/ai-panoptic";
|
|
148
148
|
|
|
149
|
-
const store = createInMemoryTraceStore();
|
|
149
|
+
const store = createInMemoryTraceStore(); // an ExporterContract + queryable store
|
|
150
150
|
const observe = panoptic({ exporters: [store, consoleExporter()] });
|
|
151
151
|
|
|
152
152
|
// store now receives every trace — query/aggregate it later:
|
|
@@ -5,7 +5,7 @@ description: 'Retain @warlock.js/ai-panoptic traces in a queryable store — in-
|
|
|
5
5
|
|
|
6
6
|
# Query traces — the in-memory queryable store
|
|
7
7
|
|
|
8
|
-
The collector fans every completed `Trace` out to its exporters. A **trace store** is one such sink that
|
|
8
|
+
The collector fans every completed `Trace` out to its exporters. A **trace store** is one such sink that _retains_ traces so you can answer observability questions after the fact: pull one run by id, list every run for a session, slice failed runs in a time window, or roll usage + cost across any of those slices.
|
|
9
9
|
|
|
10
10
|
`createInMemoryTraceStore()` returns an object that satisfies **both** `TraceStoreContract` and `ExporterContract` — so wiring it into a collector is a single `collector.use(store)` and the store fills as traces complete.
|
|
11
11
|
|
|
@@ -35,12 +35,12 @@ const spend = store.aggregate({ sessionId: "session-42" });
|
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
37
|
interface TraceStoreContract {
|
|
38
|
-
add(trace: Trace): void;
|
|
38
|
+
add(trace: Trace): void; // ingest; overwrites same traceId
|
|
39
39
|
get(traceId: string): Trace | undefined; // one run by id
|
|
40
|
-
query(filter?: TraceQuery): Trace[];
|
|
40
|
+
query(filter?: TraceQuery): Trace[]; // matching traces, newest-started first
|
|
41
41
|
aggregate(filter?: TraceQuery): TraceAggregate; // usage + cost + status rollup
|
|
42
|
-
readonly size: number;
|
|
43
|
-
clear(): void;
|
|
42
|
+
readonly size: number; // retained count
|
|
43
|
+
clear(): void; // drop everything
|
|
44
44
|
}
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -54,11 +54,11 @@ Every field is optional and **ANDed** together — an absent field is "don't car
|
|
|
54
54
|
|
|
55
55
|
```ts
|
|
56
56
|
type TraceQuery = {
|
|
57
|
-
traceId?: string;
|
|
58
|
-
sessionId?: string;
|
|
59
|
-
status?: ReportStatus | ReportStatus[];
|
|
60
|
-
startedAfter?: string | Date;
|
|
61
|
-
startedBefore?: string | Date;
|
|
57
|
+
traceId?: string; // exact one run
|
|
58
|
+
sessionId?: string; // every run for a session
|
|
59
|
+
status?: ReportStatus | ReportStatus[]; // single status or membership set
|
|
60
|
+
startedAfter?: string | Date; // inclusive lower bound on root startedAt
|
|
61
|
+
startedBefore?: string | Date; // inclusive upper bound on root startedAt
|
|
62
62
|
};
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -87,12 +87,12 @@ Time bounds compare against each trace's root `startedAt`, **inclusive on both e
|
|
|
87
87
|
|
|
88
88
|
```ts
|
|
89
89
|
type TraceAggregate = {
|
|
90
|
-
traces: number;
|
|
91
|
-
completed: number;
|
|
92
|
-
failed: number;
|
|
93
|
-
cancelled: number;
|
|
94
|
-
usage: Usage;
|
|
95
|
-
cost?: ModelPricing;
|
|
90
|
+
traces: number; // matched count
|
|
91
|
+
completed: number; // root status === "completed"
|
|
92
|
+
failed: number; // root status === "failed" or "incomplete"
|
|
93
|
+
cancelled: number; // root status === "cancelled"
|
|
94
|
+
usage: Usage; // summed token usage (input/output/total always present)
|
|
95
|
+
cost?: ModelPricing; // merged per-channel USD, or undefined if no trace was priced
|
|
96
96
|
totalDuration: number; // summed wall-clock ms
|
|
97
97
|
};
|
|
98
98
|
```
|
|
@@ -102,17 +102,14 @@ const stats = store.aggregate({ sessionId: "session-42" });
|
|
|
102
102
|
|
|
103
103
|
console.log(`${stats.traces} runs, ${stats.usage.total} tokens, ${stats.failed} failed`);
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
(stats.cost?.output ?? 0) +
|
|
108
|
-
(stats.cost?.cachedInput ?? 0) +
|
|
109
|
-
(stats.cost?.cachedOutput ?? 0);
|
|
105
|
+
// totalCost from @warlock.js/ai sums every channel (cache reads + writes included).
|
|
106
|
+
const totalUsd = totalCost(stats.cost) ?? 0;
|
|
110
107
|
```
|
|
111
108
|
|
|
112
109
|
Notes that matter when reading the numbers:
|
|
113
110
|
|
|
114
|
-
- **`usage`** sums each trace's
|
|
115
|
-
- **`cost`** is a per-channel `ModelPricing` breakdown (not one opaque number), merged with the framework's own cost-rollup logic — an unpriced trace never erases the cost of priced ones. It stays `undefined` only when **no** matched trace carried pricing.
|
|
111
|
+
- **`usage`** sums each trace's _root_ `Usage` (already a rollup of own cost + children), so the totals reflect the whole run tree without re-walking spans. `input` / `output` / `total` are always present (zero when nothing matched); the optional `cachedTokens` / `cacheWriteTokens` / `reasoningTokens` channels are summed only when at least one matched trace reported them.
|
|
112
|
+
- **`cost`** is a per-channel `ModelPricing` breakdown (not one opaque number; `cacheWrite` holds cache-write spend), merged with the framework's own cost-rollup logic — an unpriced trace never erases the cost of priced ones. It stays `undefined` only when **no** matched trace carried pricing.
|
|
116
113
|
- **`completed` + `failed` + `cancelled` need not equal `traces`** — non-terminal statuses (`awaiting-input`, `max-iterations`) count toward `traces` but none of the three headline counters.
|
|
117
114
|
- An empty match returns a zeroed aggregate (`usage` all `0`, `cost` `undefined`, `totalDuration` `0`).
|
|
118
115
|
|
|
@@ -147,13 +144,13 @@ const store = createCacheTraceStore(async () => {
|
|
|
147
144
|
return driver;
|
|
148
145
|
});
|
|
149
146
|
|
|
150
|
-
await store.ready();
|
|
151
|
-
collector.use(store);
|
|
147
|
+
await store.ready(); // hydrate the in-memory mirror from a prior run
|
|
148
|
+
collector.use(store); // fills as traces complete; reads stay synchronous
|
|
152
149
|
|
|
153
150
|
const failed = store.query({ status: "failed" }); // queryable like any store
|
|
154
151
|
```
|
|
155
152
|
|
|
156
|
-
**Write-through over a sync contract.** The store keeps the same insertion-ordered in-memory **mirror** the in-memory store uses and serves every read (`get` / `query` / `aggregate` / `size`) from it synchronously — the dashboard polls these and can't await a cache. Writes go **through**: `add` updates the mirror immediately, then
|
|
153
|
+
**Write-through over a sync contract.** The store keeps the same insertion-ordered in-memory **mirror** the in-memory store uses and serves every read (`get` / `query` / `aggregate` / `size`) from it synchronously — the dashboard polls these and can't await a cache. Writes go **through**: `add` updates the mirror immediately, then _fire-and-forget_ persists the trace + a newest-first index to the cache. So:
|
|
157
154
|
|
|
158
155
|
- **Reads never wait on the cache** — the mirror is the runtime source of truth; the cache is the durable backing store.
|
|
159
156
|
- **A flaky cache never throws into the hot path** — a rejected write is routed to the optional `onError` hook and swallowed; the trace stays visible in the mirror for the life of the process, it just won't survive a restart.
|
|
@@ -161,20 +158,21 @@ const failed = store.query({ status: "failed" }); // queryable like any store
|
|
|
161
158
|
|
|
162
159
|
```ts
|
|
163
160
|
type CacheDriverInput =
|
|
164
|
-
| CacheDriver
|
|
161
|
+
| CacheDriver // a live driver
|
|
165
162
|
| (() => CacheDriver | Promise<CacheDriver>); // lazy (sync or async) factory
|
|
166
163
|
|
|
167
164
|
type CacheTraceStoreOptions = {
|
|
168
|
-
prefix?: string;
|
|
169
|
-
|
|
165
|
+
prefix?: string; // key namespace; per-trace keys `${prefix}:trace:${id}`,
|
|
166
|
+
// index at `${prefix}:index`. Default "panoptic".
|
|
170
167
|
capacity?: number; // FIFO cap (oldest-ingested evicted from cache + mirror);
|
|
171
|
-
|
|
168
|
+
// absent / 0 = unbounded.
|
|
172
169
|
onError?: (error: unknown) => void; // swallowed cache-write failures land here.
|
|
173
170
|
};
|
|
174
171
|
|
|
175
|
-
type CacheTraceStoreHandle = TraceStoreContract &
|
|
176
|
-
|
|
177
|
-
|
|
172
|
+
type CacheTraceStoreHandle = TraceStoreContract &
|
|
173
|
+
ExporterContract & {
|
|
174
|
+
ready(): Promise<void>; // hydrate the mirror from the cache on startup
|
|
175
|
+
};
|
|
178
176
|
```
|
|
179
177
|
|
|
180
178
|
Ordering uses an **internal monotonic counter**, not `Date.now()`, so the persisted index stays a stable total order even when many traces land in the same millisecond — FIFO eviction stays honest across a restart.
|
|
@@ -201,3 +199,17 @@ for (const trace of store.query({ sessionId })) {
|
|
|
201
199
|
- The `observe-with-panoptic` topic — wiring the `panoptic(...)` subscriber and bring-your-own-collector so the store fills as runs complete.
|
|
202
200
|
- The `export-traces` topic — sending traces to an external backend instead of (or alongside) retaining them in memory; writing a custom sink.
|
|
203
201
|
- The `run-ai-agent` topic of the `warlock-js-ai` skill — the `BaseReport` the collector projects into the `Trace` this store retains.
|
|
202
|
+
|
|
203
|
+
## Hosted-tool rollups
|
|
204
|
+
|
|
205
|
+
`sumUsage` uses the framework's all-channel `mergeUsage`, so traces retain
|
|
206
|
+
`usage.serverToolUse` and `usage.cost.serverTools` alongside cache lifetime
|
|
207
|
+
counts and mixed-provider cache semantics. Request counts remain absent when
|
|
208
|
+
unreported; unpriced requests do not erase priced siblings.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const stats = store.aggregate({ sessionId });
|
|
212
|
+
console.log(stats.usage.serverToolUse?.webSearchRequests);
|
|
213
|
+
console.log(stats.cost?.serverTools?.webSearch); // historical USD
|
|
214
|
+
console.log(totalCost(stats.cost)); // includes hosted fees
|
|
215
|
+
```
|
|
@@ -17,8 +17,8 @@ import "@warlock.js/ai-panoptic"; // side-effect: subscribes panoptic to the con
|
|
|
17
17
|
|
|
18
18
|
ai.config({
|
|
19
19
|
panoptic: {
|
|
20
|
-
dashboard: true,
|
|
21
|
-
observeAll: true,
|
|
20
|
+
dashboard: true, // serve on http://127.0.0.1:4319/
|
|
21
|
+
observeAll: true, // route every workflow/supervisor/team run to the collector
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
24
|
```
|
|
@@ -49,7 +49,7 @@ So `dashboard: true` with no exporters Just Works — you get a store for free.
|
|
|
49
49
|
The page is one self-contained, dependency-free vanilla-JS file (no bundler, no framework, works offline). It polls the JSON API every 2s and renders:
|
|
50
50
|
|
|
51
51
|
- **Header** — the Warlock logo + your `title`, the `TraceAggregate` headline counts (traces / completed / failed / cancelled), tokens as **`↓input · ↑output · total`**, total cost, an **errors-only** toggle, a **search** box, and a **light / dark / system** theme switch (persisted to `localStorage`, so the choice survives a reload; `system` follows the OS `prefers-color-scheme`).
|
|
52
|
-
- **Trace list** — newest-first clickable master rows, each led by a **colour-coded Title-case type label** (`Supervisor` / `Team` / `Workflow` / `Agent` / `Tool` / …; `ai.team` reports its own `team` type rather than `supervisor`, so it groups, filters, and labels separately). An optional **group-by-session** view collapses runs under collapsible session headers; a **group-by-prompt** view does the same over the `name@version` prompt key (see
|
|
52
|
+
- **Trace list** — newest-first clickable master rows, each led by a **colour-coded Title-case type label** (`Supervisor` / `Team` / `Workflow` / `Agent` / `Tool` / …; `ai.team` reports its own `team` type rather than `supervisor`, so it groups, filters, and labels separately). An optional **group-by-session** view collapses runs under collapsible session headers; a **group-by-prompt** view does the same over the `name@version` prompt key (see _Prompt-version linkage_ below); a **group-by-type** view buckets the list by root type under collapsible `Type (N)` headers (`Agent (12)`, `Team (3)`, …). The three groupings are mutually exclusive.
|
|
53
53
|
- **Two-pane drawer** (click a row) — a collapsible **nested call tree** on the left and the **selected node's detail** on the right: rich structured input/output (chat bubbles / key-value / Markdown), a per-node **token breakdown** (`↓in · ↑out · total`), the node's **rollup cost** (its own cost, else the sum of its children — so wrapper nodes that carry tokens but no cost don't double-count), and a **metadata panel** with humanized keys (`Supervisor Terminated By`, `Span ID` — not raw `supervisor.terminatedBy`), and **started** / **ended** wall-clock timestamps (`28 Jun 2026 03:16 PM` — named month, 12-hour, minute precision) alongside the elapsed duration. Zero-valued token/cost rows are hidden so the panel stays readable.
|
|
54
54
|
- **Cost heatmap** — every tree node and trace row carries a left accent tinted by its rollup cost relative to the trace's most expensive node, with a small legend, so the spend hot-spots jump out at a glance.
|
|
55
55
|
- **Timeline view** — the drawer's left pane toggles between the nested call **Tree** and a Gantt **Timeline** (each span drawn at its offset from the root start, width = duration, critical path highlighted).
|
|
@@ -71,8 +71,14 @@ The pure matching/grouping rules are unit-tested in `trace-filter.ts` and mirror
|
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
73
|
import {
|
|
74
|
-
filterTraces,
|
|
75
|
-
|
|
74
|
+
filterTraces,
|
|
75
|
+
matchesFilter,
|
|
76
|
+
groupBySession,
|
|
77
|
+
rollupCost,
|
|
78
|
+
maxNodeCost,
|
|
79
|
+
heatIntensity,
|
|
80
|
+
type TraceFilter,
|
|
81
|
+
type SessionGroup,
|
|
76
82
|
} from "@warlock.js/ai-panoptic"; // dashboard helpers, re-exported
|
|
77
83
|
|
|
78
84
|
const failed = filterTraces(traces, { errorsOnly: true });
|
|
@@ -87,7 +93,7 @@ The open trace + span are reflected in the URL hash (`#trace=<id>&span=<id>`) an
|
|
|
87
93
|
|
|
88
94
|
### Prompt-version linkage
|
|
89
95
|
|
|
90
|
-
When an agent ran against a
|
|
96
|
+
When an agent ran against a _named_ `ai.prompts` builder, the collector stamps `agent.promptName` / `agent.promptVersion` onto the root span. The dashboard derives a `name@version` key from them (`tracePromptKey(trace)`) and offers **filter-by-prompt** and **group-by-prompt** beside session — so a reviewer sees every run of `support@2` together and compares its cost / failure rate against `support@3`. Runs with no named prompt bucket under `"(no prompt)"`.
|
|
91
97
|
|
|
92
98
|
```ts
|
|
93
99
|
import { tracePromptKey, groupByPrompt, type PromptGroup } from "@warlock.js/ai-panoptic";
|
|
@@ -131,7 +137,7 @@ import { dashboard, createInMemoryTraceStore } from "@warlock.js/ai-panoptic";
|
|
|
131
137
|
const store = createInMemoryTraceStore();
|
|
132
138
|
const handle = await dashboard(store, { port: 4319, open: true });
|
|
133
139
|
|
|
134
|
-
console.log(handle.url);
|
|
140
|
+
console.log(handle.url); // http://127.0.0.1:4319/
|
|
135
141
|
console.log(handle.port); // 4319 (the REAL OS port when port: 0 was passed)
|
|
136
142
|
|
|
137
143
|
// ...on teardown:
|
|
@@ -144,23 +150,23 @@ The store is the same object the collector writes to — wire the collector to i
|
|
|
144
150
|
|
|
145
151
|
Every field optional, every default safe:
|
|
146
152
|
|
|
147
|
-
| Option
|
|
148
|
-
|
|
149
|
-
| `port`
|
|
150
|
-
| `host`
|
|
151
|
-
| `basePath`
|
|
152
|
-
| `open`
|
|
153
|
-
| `title`
|
|
154
|
-
| `authToken`
|
|
155
|
-
| `allowedHosts` | loopback names | `Host`-header allowlist (DNS-rebind guard); an unlisted `Host` → `403`. Defaults to `localhost` / `127.0.0.1` / `[::1]` for a loopback bind.
|
|
153
|
+
| Option | Default | Notes |
|
|
154
|
+
| -------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| `port` | `4319` | Pass `0` for an ephemeral port — the resolved port comes back on `handle.port`. A busy port rejects with a clear `Error` (`port N in use; pass { port: 0 }`), not raw `EADDRINUSE`. |
|
|
156
|
+
| `host` | `"127.0.0.1"` | **Loopback only** by default, so prompt content is never exposed to the LAN. Binding a non-loopback interface is gated — it requires `authToken` (see below) or `dashboard()` rejects. |
|
|
157
|
+
| `basePath` | `"/"` | Mount-path prefix for both the page and every `/api/...` route, e.g. `"/panoptic"`. Normalized to leading + trailing slash. |
|
|
158
|
+
| `open` | `false` | Open the default browser at the resolved URL once listening. Best-effort and fully swallowed — failing to open a browser never rejects the start. |
|
|
159
|
+
| `title` | `"Panoptic"` | Header title baked into the served page. |
|
|
160
|
+
| `authToken` | _unset_ | Bearer token required on every request. Compared with `crypto.timingSafeEqual` (constant-time, length-checked first) against both the header and query-string forms — never a plain `===` — so a network-reachable dashboard doesn't leak the token to a byte-at-a-time timing attack. `Authorization: Bearer <token>` is accepted on every route; `?token=` is accepted **only on the HTML page route** (the JSON API routes are header-only), since the page load is the one request that structurally can't carry a header — this narrows the token's footprint in access/proxy logs and browser history. Missing/wrong → `401`. **Required** when `host` is non-loopback. |
|
|
161
|
+
| `allowedHosts` | loopback names | `Host`-header allowlist (DNS-rebind guard); an unlisted `Host` → `403`. Defaults to `localhost` / `127.0.0.1` / `[::1]` for a loopback bind. |
|
|
156
162
|
|
|
157
163
|
### `DashboardHandle`
|
|
158
164
|
|
|
159
165
|
```ts
|
|
160
166
|
type DashboardHandle = {
|
|
161
|
-
readonly url: string;
|
|
162
|
-
readonly port: number;
|
|
163
|
-
close(): Promise<void>;
|
|
167
|
+
readonly url: string; // http://host:port{basePath}
|
|
168
|
+
readonly port: number; // real OS port (when port: 0 was requested)
|
|
169
|
+
close(): Promise<void>; // stop the server, release the port
|
|
164
170
|
};
|
|
165
171
|
```
|
|
166
172
|
|
|
@@ -168,13 +174,13 @@ type DashboardHandle = {
|
|
|
168
174
|
|
|
169
175
|
Every route is **`GET`-only and read-only** (non-`GET` → `405`), mounted under `basePath` — EXCEPT the one opt-in `POST .../spans/:spanId/evaluate` route `DashboardOptions.evaluate` enables (see the `evaluate-system-prompt` topic); absent that config, it 405s like everything else. The page polls the `GET` routes; you can curl them too. Store shapes are already JSON-safe, so responses are a plain `JSON.stringify`.
|
|
170
176
|
|
|
171
|
-
| Route
|
|
172
|
-
|
|
173
|
-
| `GET /api/traces`
|
|
174
|
-
| `GET /api/traces/:id`
|
|
175
|
-
| `GET /api/aggregate`
|
|
176
|
-
| `GET /` (basePath)
|
|
177
|
-
| `POST /api/traces/:traceId/spans/:spanId/evaluate` | grades the span's last captured system prompt — only when `evaluate` is configured.
|
|
177
|
+
| Route | Returns |
|
|
178
|
+
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
179
|
+
| `GET /api/traces` | `store.query(...)` — the trace list, filtered by the query string (`id` / `sessionId` / `status` / time window, parsed by `parseQuery`). |
|
|
180
|
+
| `GET /api/traces/:id` | `store.get(id)` — one full trace, or `404 { error: "trace_not_found" }`. |
|
|
181
|
+
| `GET /api/aggregate` | `store.aggregate(...)` — the usage + cost + status rollup over the same query filter. |
|
|
182
|
+
| `GET /` (basePath) | the self-contained HTML page. |
|
|
183
|
+
| `POST /api/traces/:traceId/spans/:spanId/evaluate` | grades the span's last captured system prompt — only when `evaluate` is configured. |
|
|
178
184
|
|
|
179
185
|
Query-string filters map 1:1 onto `TraceQuery`:
|
|
180
186
|
|
|
@@ -208,6 +214,7 @@ createServer(handler).listen(4319, "127.0.0.1");
|
|
|
208
214
|
The dashboard surfaces whatever the store holds — including captured prompt/response content when content capture is on (`captureContent`). It binds **loopback-only** by default precisely so that content never leaves the machine. Binding a non-loopback host is gated — it requires an `authToken` (and checks a `Host` allowlist, sending `nosniff` / CSP / `X-Frame-Options: DENY` on every response) — but still prefer loopback for dev, and for production ship to a real backend via an exporter rather than exposing the dashboard. The dashboard has exactly one write-capable route (`evaluate`, off by default) — everything else stays read-only regardless of configuration.
|
|
209
215
|
|
|
210
216
|
**Trace content is untrusted input to the dashboard's own UI, and is treated that way:**
|
|
217
|
+
|
|
211
218
|
- **Markdown links in captured content are sanitized.** `span.input`/`span.output` can contain model output or attacker-controlled tool results — i.e. prompt-injected content — and the drawer renders it as Markdown. Link URLs go through `sanitizeHref` (a scheme allowlist: `http:`, `https:`, `mailto:`, plus relative/anchor URLs) after normalizing the way a browser will — attribute-entity decode, strip of ignored control chars/whitespace, lowercase — so `javascript:`/`data:`/`vbscript:` payloads (including case/whitespace/entity-obfuscated variants, and scheme-relative `//host` links) never become a live `href`; a rejected link renders its label as plain text instead. This closes a stored-XSS path that could otherwise reach the in-page bearer token.
|
|
212
219
|
- **The bearer token is no longer a page-global.** The client script's `TOKEN` is sealed inside its own inner IIFE that exposes only the `fetchAuthed` helper, so other code in the page's closure can't read the raw token by name.
|
|
213
220
|
|