Skip to content
Warlock.js v4.4.0

What Panoptic traces

Panoptic does not instrument your code. It projects the report tree @warlock.js/ai already produces on every run. A Trace is a 1:1 projection of that tree into the span vocabulary shared by OpenTelemetry, Langfuse, and similar backends — nothing is sampled, summarized, or re-derived. If a node exists in result.report, it exists in the trace; if it doesn’t, Panoptic never invents it.

This page describes the model: what a Trace and a TraceSpan are, the span tree you get for each primitive, and the complete attribute catalog.

Every executable primitive returns a BaseReport tree. Each node carries identity, timing, status, a rolled-up usage, and its children in invocation order. Panoptic’s collector walks that tree once and maps each node to a TraceSpan:

BaseReport fieldTraceSpan fieldNotes
runIdspanIdone span per execution node
parentRunIdparentSpanIdabsent on the root span
rootRunIdtraceIdequals spanId on the root
sessionIdsessionIdabsent when the caller supplied none
namenametool / agent / workflow / supervisor name
versionversiondev-curated, free-form, absent when undeclared
typetypethe ReportType discriminator
statusstatuscompleted / failed / cancelled / max-iterations / awaiting-input
startedAt / endedAt / durationsameISO-8601 timestamps + ms duration
usageusagethis node’s own cost plus the sum of its children
errorerrornormalized to the JSON-safe TraceSpanError shape
childrenchildrenrecursed in invocation order

Primitive-specific detail that has no first-class span field — trip counts, step counts, the model an agent ran against, a tool’s originating trip index — is routed into the optional attributes bag (the attribute catalog below). Only populated keys are emitted; an empty bag stays absent.

The mapping is lossless on the fields exporters care about — identity, timing, outcome, cost — so the collector flattens a report into spans without consulting any other source. One outermost BaseReport (a single .execute() / .invoke() call) becomes one Trace:

type Trace = {
traceId: string; // = root span's traceId / rootRunId
sessionId?: string; // when the run carried one
root: TraceSpan; // the root span — its subtree is the whole run
startedAt: string;
endedAt: string;
duration: number; // ms
usage: Usage; // trace-wide rollup (= root span usage)
reportSchemaVersion?: number;
};

Trace.usage, timing, and traceId all read off the root span the projection already built, so the trace envelope never disagrees with its own root.

A trace is one root span per run, with child spans for each LLM trip, tool call, workflow step, and supervisor iteration — mirroring BaseReport.children exactly. You get a different shape depending on which primitive you ran.

The agent is the root. Each tool the agent dispatched is a leaf child span (type: "tool"), tagged with the trip it was called on:

agent "support-agent"
├─ tool "searchKnowledgeBase" (tool.tripIndex = 0)
├─ tool "lookupOrder" (tool.tripIndex = 0)
└─ tool "escalateToHuman" (tool.tripIndex = 1)

The agent span carries agent.trips, agent.model.name, and agent.model.provider. Tools contribute zero own-cost; the LLM spend lives on the agent span’s usage.

The workflow is the root; each step it ran nests beneath it. A step that is itself an agent expands into its own agent-plus-tools subtree:

workflow "onboarding"
├─ agent "classify-intent"
│ └─ tool "fetchUserProfile" (tool.tripIndex = 0)
├─ agent "draft-reply"
└─ tool "sendEmail" (tool.tripIndex = 0)

The workflow span carries workflow.steps (the step count), and workflow.name / workflow.signature when present.

The supervisor is the root; each delegated agent run is a child span. Iterations are reported on the supervisor span as a count, not as separate nesting levels:

supervisor "research-lead" (supervisor.iterations = 3, supervisor.terminatedBy = "tool")
├─ agent "researcher"
│ └─ tool "webSearch" (tool.tripIndex = 0)
├─ agent "researcher"
└─ agent "summarizer"

The supervisor span carries supervisor.iterations and supervisor.terminatedBy (and supervisor.name when present).

(d) An orchestrator turn — collected via collect()

Section titled “(d) An orchestrator turn — collected via collect()”

The orchestrator’s orchestrator.turn.* events carry only session identity, not a result-bearing report, so it is not covered by attach(). Feed the turn report in directly with observe.collect(result.report). The orchestrator is the root; the primitive it routed the turn to nests beneath it:

const result = await orchestrator.execute(input, { sessionId });
await observe.collect(result.report);
orchestrator "concierge" (orchestrator.turnIndex = 4, orchestrator.turns = 1)
└─ agent "billing-agent"
├─ tool "getInvoice" (tool.tripIndex = 0)
└─ tool "issueRefund" (tool.tripIndex = 1)

The orchestrator span carries orchestrator.turnIndex, orchestrator.turns, and orchestrator.signature when present.

Two namespaces of attributes ride on each span. The token counts come from the span’s typed usage rollup; everything else is forwarded from the collector’s free-form attributes bag. toGenAiAttributes(span) folds both into a single flat map ready for an OTel span or a Langfuse generation.

gen_ai.* — OpenTelemetry GenAI semantic conventions

Section titled “gen_ai.* — OpenTelemetry GenAI semantic conventions”
AttributeSource
gen_ai.usage.input_tokensspan.usage.input
gen_ai.usage.output_tokensspan.usage.output
gen_ai.usage.total_tokensspan.usage.total
gen_ai.usage.cached_tokensspan.usage.cachedTokens (when reported)
gen_ai.usage.reasoning_tokensspan.usage.reasoningTokens (when reported)
gen_ai.conversation.idspan.sessionId (when present)
gen_ai.systemforwarded from the bag; never inventedotelExporter’s system option can backfill it when the span supplied none
gen_ai.request.modelforwarded from the bag — never invented
gen_ai.operation.nameforwarded from the bag — never invented

The token keys are emitted under their gen_ai.usage.* convention names regardless of which internal constant table (GEN_AI_ATTRIBUTES or WARLOCK_ATTRIBUTES) defines them. gen_ai.system / gen_ai.request.model / gen_ai.operation.name appear only when the span’s attributes bag already carried that exact key — Panoptic never fabricates a model name or operation it didn’t observe.

Namespaced under warlock.* so they never collide with a future gen_ai.* key:

AttributeSource
warlock.report.typespan.type
warlock.versionspan.version (when declared)
warlock.duration_msspan.duration
warlock.cost.usdtotalCostUsd(span.usage) — a single USD scalar, omitted when no pricing was attached

The collector populates the attributes bag with detail specific to each primitive. Every scalar entry rides along verbatim onto the backend span:

KeyOn span typeMeaning
agent.model.nameagentmodel the agent ran against
agent.model.provideragentprovider of that model
agent.tripsagentnumber of LLM round-trips
workflow.nameworkflowworkflow identity (when present)
workflow.signatureworkflowworkflow signature (when present)
workflow.stepsworkflownumber of steps
supervisor.namesupervisorsupervisor identity (when present)
supervisor.iterationssupervisoriteration count
supervisor.terminatedBysupervisorwhat ended the loop
orchestrator.turnIndexorchestratorwhich turn this report is
orchestrator.signatureorchestratororchestrator signature (when present)
orchestrator.turnsorchestratornumber of turns in the report
tool.tripIndextoolthe agent trip that dispatched the tool
tool.recoveredFromtoolrecovery origin (when present)
retriesanyretry count, from BaseReport.attempts

Every span carries the identity needed to reconstruct the tree without re-walking children — and to slice flat trace tables back into per-run and per-session groupings:

FieldMirrorsRole
traceIdrootRunIdtop-level trace this span belongs to; equals spanId on the root
parentSpanIdparentRunIdimmediate parent; absent on the root
spanIdrunIdthis node’s stable id
sessionIdsessionIdcaller-supplied conversation/request grouping

Putting the model together — an agent with two tools, exported as the console tree (consoleExporter({ tree: true })) with the attributes each span carries annotated alongside:

ok agent "support-agent" — 1840ms, 1320 tok, $0.0094
│ ├─ spanId = 7f3a… (mirrors report.runId)
│ ├─ traceId = 7f3a… (= rootRunId; equals spanId on the root)
│ ├─ sessionId = session-42 → gen_ai.conversation.id
│ ├─ warlock.report.type = "agent"
│ ├─ warlock.duration_ms = 1840
│ ├─ warlock.cost.usd = 0.0094
│ ├─ gen_ai.usage.input_tokens = 910
│ ├─ gen_ai.usage.output_tokens = 410
│ ├─ gen_ai.usage.total_tokens = 1320
│ ├─ agent.trips = 2
│ ├─ agent.model.name = "gpt-4o"
│ └─ agent.model.provider = "openai"
├─ ok tool "lookupOrder" — 32ms, 0 tok
│ ├─ parentSpanId = 7f3a… (mirrors parentRunId → the agent)
│ ├─ traceId = 7f3a… (same trace as the root)
│ ├─ warlock.report.type = "tool"
│ └─ tool.tripIndex = 0 (dispatched on the agent's first trip)
└─ ok tool "escalateToHuman" — 11ms, 0 tok
├─ parentSpanId = 7f3a…
├─ warlock.report.type = "tool"
└─ tool.tripIndex = 1 (dispatched on the second trip)

The tool spans report 0 tok and no cost because all LLM spend rolls up onto the agent span — exactly as it does on the source BaseReport. The traceId is shared by every span; parentSpanId reconstructs the nesting; tool.tripIndex ties each tool back to the agent trip that called it.