Trace export: hangar.trace/v1¶
hangar exports what an agent did as OpenTelemetry traces in OTLP/JSON, so tools outside hangar can read runs without
knowing hangar's internals: the sister tool's trace timelines and behavior specs, an OTLP collector, Jaeger, Tempo or
Phoenix. This page is the contract. It is versioned: a change that removes or renames anything below is a new version
(hangar.trace/v2); adding a kind, an attribute or an event is not.
Canonical. OTLP on the OpenTelemetry GenAI conventions is hangar's canonical trajectory format: what it stores and exports for every run. Other formats (ATIF, Harbor's) are adapters in flightdeck, built on this export; hangar emits no ATIF (spec decision 16).
The boundary and the other two interfaces (the effective harness manifest and experiments) are in spec 0014.
Getting a trace¶
| What | REST | CLI | MCP resource |
|---|---|---|---|
| One run (one turn of a session) | GET /api/sessions/{name}/trace?turn=N |
hangar trace export <session> --turn N |
|
| A whole session | GET /api/sessions/{name}/trace |
hangar trace export <session> |
hangar://sessions/{name}/trace |
| A job: its turns, best-of-N attempts, judge, verify, git, PR and CI | GET /api/jobs/{id}/trace |
hangar trace export <job id> |
hangar://jobs/{id}/trace |
| An experiment trial (stored when the trial ended) | GET /api/experiments/{id}/trials/{key}/trace |
hangar experiment export <id> |
-o FILE writes the export to a file. Every export is one OTLP ExportTraceServiceRequest in the
OTLP/JSON encoding
(opentelemetry-proto 1.11.1): ids are hex strings, kind and status codes are integers, 64-bit integers and
timestamps are decimal strings. It can be POSTed as is to a collector's /v1/traces with
Content-Type: application/json.
Access. Exports are owner-scoped like the records they come from (#88): a member gets their own sessions and jobs,
an admin everyone's, a jobs token only the jobs it submitted. A record the caller may not see answers 404, as a
missing one does.
Secrets. Every string in an export passes through the broker's redaction (broker.redact) when it is exported,
on top of the redaction applied when events are recorded. Credential values never appear: hangar never records them
in events, and a stored secret that turns up in a command, a tool's output or a message is masked (***).
Tool inputs, outputs and messages are clipped (500, 500 and 2,000 characters), as the event log keeps them.
Live export. Separately from these files, a control plane with tracing on (OTEL_EXPORTER_OTLP_ENDPOINT,
observability.md) already sends each turn's invoke_agent span to the collector. With
HANGAR_TRACE_LIVE_TOOLS=true it also sends the turn's tool.call, subagent and approval spans, as children of
that span, when the turn ends. They carry the attributes below; the session, job and event records are only in the
exported files.
Conventions followed¶
- OpenTelemetry GenAI semantic conventions for agent and tool spans:
invoke_agent,execute_toolandinvoke_workflowoperations,gen_ai.*attributes. Target: the semantic-conventions-genai registry at commitf3c6d0c9bb66ebd474668a3a0be25c61226f02c2(2026-10-14; the repository has no release tags yet), schemahttps://opentelemetry.io/schemas/gen-ai-dev/1.42.0-dev, built on core semantic conventions 1.44.0. Stability: development. Everygen_ai.*attribute and GenAI span used here is marked Development in that registry, so upstream may still rename them; hangar pins this version and will move to a later one only with a newhangar.traceversion.error.typeis stable;process.exit.codeis release candidate. - The GenAI conventions moved out of the core repository in 2026: opentelemetry.io's GenAI pages now point to the separate registry above.
- Everything GenAI has no name for is under
hangar.*, listed below. - Each export's scope is
{"name": "hangar.trace", "version": "1"}withschemaUrlhttps://opentelemetry.io/schemas/1.44.0; the resource carrieshangar.trace.schema = "hangar.trace/v1"andhangar.semconv.genai = "gen-ai-dev/1.42.0-dev".
The taxonomy¶
Behavior specs match on this. Every span has a hangar.kind attribute; every span event's name is its kind.
Spans have a duration; events are points in time. Kinds are stable within hangar.trace/v1.
Spans¶
hangar.kind |
Span name | gen_ai.operation.name |
Parent | What it is |
|---|---|---|---|---|
session |
invoke_workflow hangar.session |
invoke_workflow |
root | A session export's root: first turn's start to last turn's end. |
job |
invoke_workflow hangar.job |
invoke_workflow |
root | A job export's root, from submission to its last event. |
turn |
invoke_agent {harness} |
invoke_agent |
session or job (or root, in a one-run export) |
One agent run: a turn of a session or job. |
tool.call |
execute_tool {tool}, or execute_tool {tool} {executable} for a one-process shell command and execute_tool {tool} {skill} for a skill (GenAI's command and skill refinements) |
execute_tool |
turn |
One tool call, from the call to its result. |
subagent |
invoke_agent {subagent type} |
invoke_agent |
turn |
A subagent the agent started (Claude Code's Task/Agent tool). Its start is the subagent's start. |
approval |
approval {tool} |
— | turn |
A tool permission asked during a live turn, from the request to the answer. |
Normalized tool kinds¶
tool.call spans carry hangar.tool.kind, the same for every harness:
hangar.tool.kind |
Claude Code | Codex | pi | Antigravity |
|---|---|---|---|---|
shell |
Bash, BashOutput |
shell (command_execution) |
bash |
run_command |
edit |
Write, Edit, MultiEdit, NotebookEdit |
apply_patch (file_change) |
write, edit |
write_to_file, replace_file_content, multi_replace_file_content |
read |
Read, NotebookRead, LS |
read, ls |
view_file, view_file_outline, list_dir |
|
search |
Grep, Glob |
grep, find |
grep_search, find_by_name, codebase_search |
|
web |
WebFetch, WebSearch |
web_search |
read_url_content, search_web |
|
mcp |
mcp__{server}__{tool} |
{server}.{tool} (mcp_tool_call) |
mcp_* |
|
skill |
Skill |
|||
plan |
TodoWrite, ExitPlanMode |
update_plan |
todo_write |
|
other |
anything else |
A subagent call is a subagent span, not a tool.call.
What a shell command does. shell tool calls (and approvals) carry hangar.shell.ops, read from the command's
words in each &&, ||, ; or | segment, in order, deduplicated:
| Op | Commands |
|---|---|
test |
pytest, python -m pytest/unittest, tox, nox, jest, vitest, mocha, rspec, phpunit, ctest, gotestsum, playwright; go test, cargo test/nextest, npm/pnpm/yarn/bun test and run test*, mvn test/verify, gradle/./gradlew test/check, make test/check, dotnet test, bazel test, deno test, mix test, swift test; behind uv run, poetry run, npx, bundle exec, timeout, env, sudo and VAR=value |
git.commit, git.push |
git [-C dir] [-c k=v] commit / push |
pr.open |
gh pr create |
delete |
rm, rmdir, unlink, shred, git rm, find … -delete |
They are read from the whole command, not the clipped hangar.tool.input. A clipped input has hangar.tool.input_clipped=true.
Order. Tool calls and events are timestamped when hangar read them from the run log, so several can share a
time. hangar.seq is each one's position in the session's event log: a total order within a session. On
tool.call and subagent spans it is the call's position, and hangar.seq.end its result's; on an approval, its
request's (when the log has it: not under hangar-owned settings, #69). Spans and events with the same time are
exported in that order. Order across sessions (best-of-N attempts) is by time only.
Events¶
| Name (= kind) | On | Attributes |
|---|---|---|
message |
turn |
hangar.text (the assistant's text, clipped) |
progress |
turn |
hangar.text, hangar.source (harness or hangar) |
error |
turn |
hangar.text, hangar.fatal |
file.change |
turn |
hangar.file.path (as the harness gave it: often absolute), hangar.file.repo_path (relative to the repo's checkout; absent when the file is outside it), hangar.file.in_repo, hangar.file.op (write, create, update, add, delete...), gen_ai.tool.name. One per file an edit tool changed; a shell command's writes are not here (see hangar.diff.files and hangar.shell.ops) |
steer |
turn |
hangar.text, hangar.requester, hangar.steer.status, hangar.steer.id, hangar.steer.class (correction, redirect, new_info, or unclassified: steering.md), hangar.steer.classified_by, hangar.seq (its place in the session's event log; unset for steers recorded before #162) |
interrupt |
turn |
as steer |
steer.classified |
turn |
hangar.steer.class, hangar.steer.ref (the steer's id, or the job's follow-up turn number), hangar.steer.classified_by, hangar.seq: someone said what a steer or follow-up was |
verify |
turn (the in-turn gate) or job (hangar's check after the turn) |
hangar.verify.phase (in_turn, after_turn), hangar.verify.passed, hangar.verify.exit_code, hangar.verify.round, hangar.verify.command (job only), hangar.verify.new_failures (job only) |
question |
turn |
hangar.text: the agent asked a person (the job waits for an answer) |
taint |
turn |
hangar.taint.ref, hangar.seq: the session first read untrusted content here (security/taint.md) |
git.commit |
job |
vcs.ref.head.revision (the commit), hangar.git.subject. Each commit of the delivered branch, stamped when hangar delivered it (after its check): when the agent committed is its shell call with hangar.shell.ops git.commit |
git.push |
job |
vcs.ref.head.name (the branch), vcs.ref.head.revision, hangar.git.pushed |
pr.open |
job |
hangar.pr.number, hangar.pr.url, hangar.pr.draft |
ci.check |
job |
hangar.ci.name, hangar.ci.conclusion, vcs.ref.head.revision |
pr.merge |
job |
hangar.pr.number, hangar.pr.merged_by |
job.state |
job |
hangar.job.state, hangar.text: each entry of the job's timeline |
Test results¶
Structured results of every check hangar runs, as events on job in a job export, and on each turn in a session
export (the runs that checked that turn: the in-turn gate's, hangar's check after it, an attempt's; the base check
belongs to no turn):
| Name | Attributes |
|---|---|
test.run |
One run of a check: test.suite.name (the command), test.suite.run.status (success, failure, timed_out, aborted), hangar.test.run.id, hangar.test.run.kind (base, verify, rerun, attempt, in_turn, autoinstall, acceptance, grader), hangar.test.run.trusted (false for the agent's own in-turn runs, and a grader's below trust: isolated), hangar.test.run.counted (below), hangar.run.id (the agent run it checked, when known), hangar.test.exit_code, hangar.test.total, .passed, .failed, .errors, .skipped, hangar.test.complete, hangar.test.flaky (how many), hangar.test.coverage.line_rate, hangar.test.attempt, vcs.ref.head.revision |
test.case |
A failing or flaky test: test.case.name (its id), test.case.result.status (pass, fail), hangar.test.outcome, hangar.test.flaky, hangar.test.message (clipped, redacted), hangar.test.run.id |
test.suite.* and test.case.* are OpenTelemetry's test semantic conventions (core registry, Development). Passing
tests are counted on test.run, not listed. GET /api/jobs/{id}/tests?format=otel returns the same events.
hangar.test.run.counted = hangar.test.run.trusted and hangar.test.total > 0 and hangar.test.complete.
Behavior predicates must use it to decide that tests ran: an untrusted run is the agent's word, a run with no
tests (pytest --collect-only, a runner that found nothing) proves nothing, and an incomplete report (a runner run
twice into one file, a report over the size cap) can hide failures. counted, hangar.run.id and the grader kind
were added within hangar.trace/v1 (#164); exports made before them lack the attributes, which a reader treats as
not counted. The TestRun records in the outbox's tests.json and grade.json carry the same counted.
Attributes¶
Resource¶
| Attribute | Value |
|---|---|
service.name |
hangar |
service.version |
hangar's version |
hangar.trace.schema |
hangar.trace/v1 |
hangar.semconv.genai |
gen-ai-dev/1.42.0-dev |
hangar.export.scope |
run, session or job |
hangar.session.id, hangar.session.name |
session exports |
hangar.job.id |
job exports, and sessions that carry a job |
hangar.environment |
the environment |
hangar.person |
whose work it is |
turn¶
GenAI: gen_ai.operation.name=invoke_agent, gen_ai.agent.name (the harness), gen_ai.provider.name (anthropic
for Claude Code, openai for Codex, gcp.gemini for Antigravity; unset for pi, which routes to many),
gen_ai.request.model, gen_ai.response.model, gen_ai.conversation.id (the harness's conversation id),
gen_ai.usage.input_tokens (all input, cached included, as the convention says), gen_ai.usage.output_tokens,
gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_write.input_tokens, error.type when it failed.
hangar: hangar.turn.number (1-based, in the session), hangar.run.id, hangar.session.id, hangar.turn.mode (new,
resume, handoff, fork), hangar.turn.outcome (exited, timed_out, cancelled, over_budget, gone,
error, needs_input, interrupted), hangar.turn.exit_code, hangar.harness, hangar.harness.version,
hangar.turn.live, hangar.approvals (auto, ask, deny-risky), hangar.cost_usd, hangar.cost_source
(harness, prices, mixed), hangar.manifest.digest (the effective harness manifest it ran with: fetch it with
GET /api/sessions/{name}/manifest?turn=N), hangar.harness.digest and hangar.environment.digest (its
harness_digest and environment_digest: a harness change and an environment change apart; unset for turns recorded
before them), hangar.attempt (best-of-N attempt number, job exports), hangar.steer.class and
hangar.steer.classified_by (a job's follow-up turn started by a person: what the follow-up was, unclassified when
nobody said; not on the first turn or on turns hangar started itself, such as verify-fix and auto-fix rounds),
hangar.taint (whether the turn read untrusted content, or started tainted: security/taint.md),
hangar.taint.first_seq (the event that first tainted it; 0 when it started tainted, from its context or an earlier
turn of the session) and hangar.taint.sources (kind:ref, e.g. web:example.com, mcp:github,
context:issue text jira-ABC-12.md), hangar.outcome.class (agent, infra_error, cancelled, human: whether
the outcome is the agent's or infrastructure's, jobs/reference.md; unset for turns recorded
before #166, which means unknown, not agent) and hangar.outcome.infra_reason (preempted, power_down, rate_limited,
provider_5xx, sandbox_create, checkpoint, control_plane_restart, drift (#178), no_login (#293); only with infra_error). Both are on the live
span sent to the collector too. Leave infra_error turns out of yield and comparisons.
Repo state (#165, added within hangar.trace/v1): hangar.git.tree.start and hangar.git.tree.end (the working
tree's git tree id, uncommitted and untracked changes included, .gitignore honoured, when the turn started and
ended: what git add -A && git write-tree through a scratch index gives on that checkpoint),
hangar.git.tree.skipped (slow: one hash took over 2 s and hashing stopped for the turn) and
hangar.git.tree.trusted (false when any of the turn's trees was recorded where the agent could have forged it: a
driver without the uid split, or a one-shot turn's exec). Unset for turns recorded
before #165 and for turns outside a git checkout (steering.md).
Status: ERROR when the outcome is anything but exited/needs_input/interrupted with exit code 0.
tool.call¶
GenAI: gen_ai.operation.name=execute_tool, gen_ai.tool.name (as the harness names it), gen_ai.tool.call.id,
gen_ai.tool.type (extension for mcp and web, function for the rest: the harness runs them),
gen_ai.agent.name, gen_ai.conversation.id; for shell, process.exit.code when the harness reports it and
process.executable.name when the command is one process (not a list or a pipeline); for skill,
gen_ai.skill.name.
hangar: hangar.tool.kind, hangar.tool.input (a one-line summary: the command, the path, the pattern or the URL;
clipped, redacted), hangar.tool.output (the start of the result; clipped, redacted), hangar.tool.ok,
hangar.seq and hangar.seq.end, hangar.file.path and hangar.file.repo_path (edit and read; a Codex
apply_patch naming several files has only hangar.file.path: its file.change events have one each),
hangar.shell.ops (shell), hangar.mcp.server and hangar.mcp.tool (mcp), hangar.tool.incomplete=true
when the turn ended before the result (the span then ends with the turn), and hangar.git.tree with
hangar.git.tree.trusted (#165, added within v1): the working tree's git tree id the call left, on edit and
shell calls of a live turn that changed the tree (absent when the tree didn't change: a step's state is then the
last tree before it, or the turn's start tree). Status ERROR with error.type=tool_error when the tool failed.
subagent¶
gen_ai.operation.name=invoke_agent, gen_ai.agent.name (the subagent type), gen_ai.tool.call.id,
hangar.tool.input (its task description), hangar.tool.output, hangar.tool.ok.
approval¶
hangar.approval.id, hangar.approval.tool, hangar.approval.detail (what it asked to run; clipped, redacted),
hangar.approval.status (allowed, allowed_always, denied, cancelled, pending), hangar.approval.by (who
answered: a person, policy, timeout or harness), hangar.approval.message, hangar.seq, hangar.shell.ops
(of the command it asked to run).
session and job¶
gen_ai.operation.name=invoke_workflow, gen_ai.workflow.name (hangar.session or hangar.job),
hangar.environment, hangar.harness, gen_ai.request.model, totals over all turns (gen_ai.usage.*,
hangar.cost_usd), and for jobs hangar.job.state, hangar.job.reason, hangar.job.strategy, hangar.repo,
hangar.diff.stat (the delivered diff's stat), hangar.diff.files and hangar.diff.deleted (its files,
repo-relative, at most 1,000: what the change touched however it was made, shell commands included),
hangar.pr.url. Both carry hangar.taint: whether any of its turns read untrusted content (a job whose own input is
untrusted is tainted from the start).
A job labelled in the Friday harness review (runbook, #169, added within
hangar.trace/v1) also carries hangar.failure.category (env, tooling, instructions, skill, model,
ticket, flaky, other), hangar.failure.fix (agents_md, skill, env, ticket_template, none),
hangar.failure.seq (the hangar.seq of the first failure), hangar.failure.turn, hangar.failure.session_id,
hangar.failure.note, hangar.failure.candidate_task, hangar.failure.labelled_by and hangar.failure.at.
Writing behavior specs¶
Each of these reads the export alone (tests: tests/test_interfaces.py::_behavior):
| Spec | From the export |
|---|---|
| Tests run before the commit | the least hangar.seq of a tool.call whose hangar.shell.ops has test is below the least with git.commit (in a job, hangar's own verify event always precedes its git.commit events) |
| The change was tested | a test.run with hangar.test.run.counted=true (never trusted or the exit code alone) |
No edits outside src/ |
every file.change has a hangar.file.repo_path under src/, and every hangar.diff.files entry is |
| Asked before deleting | every tool.call with delete in hangar.shell.ops (or a file.change with hangar.file.op=delete) has an approval with delete in its ops, allowed, and a lower hangar.seq; or, in a job, a question event ended an earlier turn |
| Plan, edit, test, PR | the first plan tool call, edit tool call, test op and pr.open op (or the job's pr.open event) are in that hangar.seq order |
Ids¶
Ids are derived, not random, so the same record exports the same ids every time: the trace id is the first 16 bytes
of sha256("hangar:" + job id or session id), and span ids come from the same hash over the span's own key (the run
id, the tool call id). A per-run export uses its session's trace id. Live export uses the SDK's own ids.
Limits¶
An export stops at 20,000 spans and events; hangar.export.truncated=true on the resource says it did. A session's
event log keeps the last HANGAR_EVENTS_MAX_MB (20 MB, two files) of events: older turns of a very long session have
no tool spans left.
Span status is set only on failure (code: 2, ERROR); a span that succeeded leaves it unset, as the conventions
recommend. Event and tool-call times are when hangar read the line from the agent's run log: within a second when the
log is followed live, else up to the poll interval (HANGAR_SESSION_POLL_SECONDS), so tool durations are close, not
exact. Turn start and end times are exact.
Example¶
{"resourceSpans": [{
"resource": {"attributes": [
{"key": "service.name", "value": {"stringValue": "hangar"}},
{"key": "hangar.trace.schema", "value": {"stringValue": "hangar.trace/v1"}},
{"key": "hangar.export.scope", "value": {"stringValue": "run"}}]},
"scopeSpans": [{
"scope": {"name": "hangar.trace", "version": "1"},
"schemaUrl": "https://opentelemetry.io/schemas/1.44.0",
"spans": [
{"traceId": "5b8efff798038103d269b633813fc60c", "spanId": "eee19b7ec3c1b174", "name": "invoke_agent claude-code",
"kind": 1, "startTimeUnixNano": "1760530000000000000", "endTimeUnixNano": "1760530090000000000",
"attributes": [{"key": "hangar.kind", "value": {"stringValue": "turn"}},
{"key": "gen_ai.operation.name", "value": {"stringValue": "invoke_agent"}},
{"key": "gen_ai.usage.output_tokens", "value": {"intValue": "1830"}}],
"events": [{"timeUnixNano": "1760530080000000000", "name": "file.change",
"attributes": [{"key": "hangar.file.path", "value": {"stringValue": "src/app.py"}}]}]},
{"traceId": "5b8efff798038103d269b633813fc60c", "spanId": "aa19b7ec3c1b1740",
"parentSpanId": "eee19b7ec3c1b174", "name": "execute_tool Bash", "kind": 1,
"startTimeUnixNano": "1760530010000000000", "endTimeUnixNano": "1760530012000000000",
"attributes": [{"key": "hangar.kind", "value": {"stringValue": "tool.call"}},
{"key": "hangar.tool.kind", "value": {"stringValue": "shell"}},
{"key": "gen_ai.tool.name", "value": {"stringValue": "Bash"}},
{"key": "hangar.tool.input", "value": {"stringValue": "pytest -q"}}]}]}]}]}