Export over OpenTelemetry

asz push sends what the storage root holds to an OpenTelemetry logs receiver: every landed file and every round, as OTLP logs over gRPC, or over HTTP with a protobuf body. The SkyWalking OAP accepts both, on its gRPC port and on its REST port, and so does an OpenTelemetry Collector.

# asz.yaml
export:
  otlp:
    protocol: grpc              # the default; http posts to the receiver's /v1/logs instead
    endpoint: 127.0.0.1:11800   # the OAP's gRPC port; with http, http://127.0.0.1:12800
./bin/asz push            # send everything not yet sent, then exit
./bin/asz collect         # land, parse and send, once every collector interval

One log record per file

A file is sent whole: one log record whose body is the file’s bytes, unchanged. A receiver stores it as it was landed, checks its digest at once, and has nothing to put back together. Every {seq, row, block} reference in a round still means what it meant, because a row is a line of the body it names.

This is the second design. The first sent one record per line, and it was measured on one conversation of 306 files: 39,683 records, attributes 16% on top of the body even after trimming, and a receiver that had to track which lines had arrived before it could verify anything. A landed file is cut at a budget and a round is cut at the same budget, so a whole file is a small record, and the same conversation is 306 records.

Landed files and rounds are both write-once, so each is sent once. push.state in the storage root lists what was sent, with the digest each file had; a file is recorded only after the request carrying it succeeded, so a failed request leaves it for the next pass.

push.state also names the receivers the files went to, with one endpoint line each: grpc://host:port, grpcs://host:port with tls: true, or the HTTP base URL. A rejected line names each file of a request a receiver took while it rejected some of its records, as Transport describes. Every save writes all three kinds, so neither new line is lost at the next request. An older asz passes over both, and drops them when it saves the file.

schema 1
updated_at 2026-09-11T10:00:00Z
endpoint grpc://127.0.0.1:11800
pushed <session-id>/streams/main/transcript-20260911T095959.000000000Z-000001.sd 9f2c…
rejected <session-id>/streams/main/transcript-20260911T095959.000000000Z-000001.sd

Removing a scenario session depends on these lines. A session goes only when push.state names exactly one receiver, the one the pipeline sends to, and no file of the session has a rejected line. A push.state that lists files and names no receiver was written before these lines existed, or saved by an older asz. It then names the receiver unknown, and no scenario session on that root is removed after that.

Transport

The request is the ExportLogsServiceRequest of the OpenTelemetry protocol, built from the protocol’s own Go definitions and sent by the gRPC client of the official module, or posted over HTTP with a protobuf body. Nothing about the encoding is the project’s own, so any OTLP logs receiver reads it.

protocol Endpoint How a request travels
grpc, the default host:port, such as 127.0.0.1:11800 for the OAP One connection is opened for the run and every request of a pass is one Export call on it. OTLP defines Export as a single call and answer, not a stream, so a pass is a sequence of calls on one HTTP/2 connection. tls: true makes the connection a TLS one, verified against the system’s roots
http The receiver’s base URL, such as http://127.0.0.1:12800 for the OAP; /v1/logs is appended Each request is one POST with Content-Type: application/x-protobuf. The scheme decides whether the connection is TLS

headers travel with every request on both transports, as gRPC metadata or as HTTP headers, which is where an authorization token goes. A receiver that answers with a partial success, saying it rejected some records, has taken the request, and the protocol says not to send it again: the count is reported on the pass line as rejected, and the files are marked sent. Each file of that request also gets a rejected line in push.state, because the answer does not say which records were rejected. A scenario session with such a file is never removed.

Over HTTP, a request counts as taken only when the answer has a 2xx status and a body that is empty or protobuf: application/x-protobuf, as OTLP names it, or application/protobuf, whatever the parameters. A 2xx answer with a body of any other type is an error. Nothing in the request is recorded as sent, so it goes again on the next pass:

otlp: http://127.0.0.1:12800/v1/logs answered 200 OK with text/html, not an OTLP response; nothing is recorded as sent

A redirect is not followed. It is an error like any other answer outside 2xx, and a receiver that moved is configured again. Before this rule, an HTML page answered with 200, and a redirect to a login page, both left files recorded as sent that no receiver stored. The rule is the same for logs and metrics. Over gRPC, a call that returns without an error always carries the receiver’s answer.

What every record carries

The resource, which names the service a record belongs to:

Attribute Value
service.name export.otlp.service_name, or when empty the runtime that produced each session, read off its landed header’s adapter: Claude Code for claude-code-local, Mock Agent for mock. One service per kind of agent, and a root that holds both is pushed as both
service.instance.id export.otlp.instance_id: who is pushing, in words the people reading the receiver recognise. A receiver lists it under the service as the instance, so put your mailbox, your name, or the machine there. Empty means user@host of the machine running asz push, which is stable across restarts. The session a record belongs to is on the record as asz.session
service.layer export.otlp.layer, AI_AGENT by default, the layer the receiver places the service in. The OAP selects its rules by layer, and a layer name is upper case with underscores
telemetry.sdk.name asz, so a receiver can tell these records apart from any other source
telemetry.sdk.version the version of asz that sent them
telemetry.sdk.language go

The scope is github.com/apache/skywalking-ai-sessionizer with the same version. Each record then says what its file is, so a receiver can route it, index it and verify it without decoding the body:

Attribute Value
asz.format sd for a landed file, sf for a round
asz.format.version the version in the file’s first line: sd/1 or sf/1
asz.file the file’s path relative to the storage root
asz.file.kind for sd, the header’s kind: transcript, agent_meta, journal, workflow_manifest, workflow_script; for sf, round
asz.file.digest the file’s SHA-256, the digest of the body as received
asz.lines how many lines the body has, the header and the closing line included
asz.session the session the file belongs to; for sf, the session the round was assembled from
asz.from_time, asz.through_time the earliest and the latest record time in the file, as the runtime wrote them, in UTC; for sf, the round header’s own pair, the range of the files that round consumed. Absent when no record carries a time, as in a child’s meta file
asz.session.from_time, asz.session.through_time for sf only: the session’s own range as of that round, when it began and its last activity so far. A landed file never carries it: it can travel before any round exists, and the last activity keeps moving, so the value there would be missing or stale
asz.conversation.title, asz.conversation.talks, asz.conversation.steps, asz.conversation.streams, asz.conversation.segments, asz.conversation.unresolved for sf only: what a list of conversations shows, as of that round, copied off the round’s header. A receiver lists conversations off its newest round per conversation and never folds
asz.seq for sd: the landed sequence. With the session it names the file a round’s {seq, row} reference points at, and the row is a line of the body
asz.stream, asz.run for sd: the stream or workflow run the file belongs to
asz.conversation, asz.round for sf: the conversation and the round number

The record’s time is chosen so a receiver can bound a read by a range it already holds. A landed file is stamped with its last record time, and a file whose records carry no time, such as a child’s meta file, with the latest record time of the session as known at push, which is always inside the session’s range and cannot go stale. A round is stamped with the session’s last activity as of that round, so a receiver’s newest row per conversation is the head. Every record also carries the time it was observed.

Every scenario in the test suite is pushed to a receiver over both transports and checked against the two tables above, in both formats, so a change to the wire that this page does not describe fails the build. See Scenarios.

Checking what a receiver gets

An OpenTelemetry Collector with a file exporter writes back what it decoded, which is the easiest way to see the records before pointing at a backend:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 32   # the default is 4 MiB, below the 8 MiB batches; see Size
      http:
        endpoint: 0.0.0.0:4318      # the default limit is 20 MiB, enough
exporters:
  file:
    path: /out/logs.json
service:
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [file]

Point export.otlp.endpoint at 127.0.0.1:4317, or with protocol: http at http://127.0.0.1:4318, run asz push, and read logs.json: one JSON line per request, with the resource, the scope and the records as the Collector understood them. make e2e-collector does exactly this with a generated session and a Collector container, once over each transport, then checks every record against the root and rebuilds the root from what the Collector wrote; CI runs it on every change. Writing each record’s body to asz.file under a new root gives a root that asz verify and asz view read like the original.

Metrics

asz push sends metrics as well, when an adapter produces them, under the name Claude Code’s own OpenTelemetry exporter uses, so a receiver holds one metric name whichever produced the points. What the local adapter derives is a reconstructed subset of the exporter’s family, not a copy of it, and this table says exactly which part:

The runtime’s exporter Derived from the transcripts
metric claude_code.token.usage, Number of tokens used, unit tokens, a monotonic delta sum the same name, description, unit and kind
type input, output, cacheRead, cacheCreation, a point for each even when zero the same four, zero included
query_source main, subagent, auxiliary main and subagent, from the stream the call was made on; the auxiliary calls, such as the Haiku call that names a session, never reach a transcript
model, session.id yes yes
user.id, user.email, user.account_uuid, user.account_id, organization.id, terminal.type, effort, speed, and the agent, skill, plugin and MCP attribution yes no; a transcript does not carry them
the other seven metrics: cost, active time, lines of code, commits, pull requests, sessions started, edit decisions yes no, and never estimated
resource service.name claude-code, service.version, host.arch, os.type, os.version service.name claude-code; asz normalises both on the way out
instrumentation scope com.anthropic.claude_code, versioned as the runtime asz’s own, so a receiver that keys on the scope sees two streams of one name; the OAP keys on the name and the labels
a point’s window the exporter’s export interval, wall clock the minute the call’s last fragment ended, see below
value a double a double

The right-hand column is held to a capture of what Claude Code 2.1.260 sent to asz’s receiver from one short session, kept under internal/metrics/testdata with every identifying value replaced, by a test that fails when the exporter sends a label or a metric this table does not account for. go run ./tools/otlpdump -redact FILE.pb prints any spooled request the same way.

With metrics: true on the claude-code-local adapter, the collector derives the points from the landed files by the assembler’s own rule: the usage of a call is its last fragment’s in line order, never a sum, and only a call that finished counts. A main transcript repeats the final usage on every fragment. A child’s carries streaming partials on all but the last. A call has finished when one of its fragments reports a stop reason. A call that never does is left out, because its usage block is a streaming stub, not a count. That is about 10% of all calls in a corpus of 2,970 files and 365,825 records written by Claude Code 2.1.220 to 2.1.251. It is 14.5% of the calls on child streams and 0.02% of those on main transcripts, as Provider calls reports.

A call cut at a landed file boundary is read through following transcript files of its stream when the file’s last record is a fragment of that call. There is no one-file limit. Metadata and change files do not interrupt the reading; the first unrelated transcript record does. A tool result carries no call id, and in the same corpus a call’s own tool results sit between its fragments on 24% of multi-fragment calls. Such a result ends the read-ahead. An unfinished child call remains eligible when its final fragment arrives later. On a main transcript every fragment repeats the final usage, so the call counts in full, in the minute of the last fragment read.

Under a watching collector, a file whose last record is a fragment of a call not yet counted waits a short grace for the next file of its stream, then is derived with what it has. When a continuation is found, its last file starts the grace. A deferred session is retried even while other sessions keep changing. An unfinished call is not marked counted when the grace expires. A file that ends with a tool result does not wait. A single pass, the backfill over history that already exists, derives with what is there and never waits. A call is never counted twice, however many files its records reach, and a record the runtime re-emitted before a context reset is the same call again.

Points are summed per minute and attribute set, and the windows of one series never overlap: a point takes its minute unless the series already has a point at or past it, as when two children ran in the same minute or a child’s file landed later, in which case it follows the series’ last point. No point of a series is ever thrown away for another. The first derivation over a root with history is bounded by metrics_lookback, 24 hours unless set, and by the newest request the receiver adapter landed, so switching the flag on sends neither a year of tokens nor what the runtime’s exporter already sent. A session the first pass did not finish keeps that look-back across retries and restarts, whether a file waited for its grace or an error stopped the session before its later files. Once every file of the session is derived, a later new file is derived whole. Before a request enters the spool, an immutable receipt under the session’s metrics/ records its exact bytes and the calls it counted. If saving progress fails, the retry uses that receipt even when more source fragments have arrived, so its accounting still matches the request already written. The retry applies every such receipt before it derives any file again, and a receipt only moves a series forward. So a file that waited for its grace in the failed pass takes a window after the ones already in the spool, and no two windows of a series overlap.

The other source of the same family is the runtime’s exporter itself: the claude-code-otlp adapter receives what Claude Code sends and lands each metrics request in the same spool, bytes as received. The receiver does not check whether it has landed a request before. A request the exporter sends twice lands twice, and asz push sends both copies. One root sends one source: metrics may be on for the local adapter or for the receiver, and the configuration refuses both.

The points wait in the storage root’s _metrics/ spool, one write-once file per landed file with points or per request received, and go out in order under the same budget and the same once-only rule as the files. export.otlp.logs and export.otlp.metrics switch the two things a push sends, the files and rounds as logs and the spool as metrics, so a receiver that takes one and not the other is sent what it takes. On the way out the resource is normalised to asz’s identity, the service, the layer, the sender, so the OAP holds one service for the runtime. A receiver that answers with a partial success has taken the request, and the protocol says not to send it again: the rejected records or points are counted on the pass line as rejected, the file is marked sent, and push.state gets a rejected line for it.

Rate

A first push sends the whole history of the storage root, which can be hundreds of megabytes, as fast as the receiver takes it. export.otlp.max_bytes_per_minute caps what goes on the wire: a pass waits before a request until a minute’s budget, refilled continuously and never holding more than a minute’s worth, covers the request’s encoded size. The budget starts full, so a pass that sends a few new files never waits, and a request larger than a minute’s budget waits for a full one and leaves it empty. Zero, the default, is no limit. asz push still sends everything before it exits, and the pass line’s paused= field says how long it waited.

A pass goes session by session, the session landed first going first: its files, then its rounds. A receiver rebuilds a session once it holds both, so during a long first push the sessions become complete one after another rather than all at the end.

A receiver that answers 429 over HTTP or ResourceExhausted over gRPC is asking the sender to slow down. The pass stops there, leaves the rest for the next pass, and empties the budget, so the next request waits a whole minute’s worth when a rate is set. In watch mode a Retry-After longer than the interval is honored.

Size

A request carries at most export.otlp.batch_bytes of file bytes, 8 MiB by default, which keeps a request under the 10 MiB the OAP’s HTTP server accepts. A file larger than that is sent alone, in a request of its own. A landed file is cut at max_delta_bytes, 2 MiB by default, and a round is cut at parse.max_round_bytes, also 2 MiB, so a request normally carries several files. The exception on both sides is a single unit larger than the budget: a source record is landed whole, and a round covering one landed file is published whole. The largest source record in the measured corpus is 4.5 MB.

The receiver’s limit must cover the largest single request. The OAP accepts 50 MB over gRPC and 10 MiB over HTTP by default. An OpenTelemetry Collector accepts 4 MiB over gRPC and 20 MiB over HTTP unless its receiver is configured otherwise, so its gRPC receiver needs max_recv_msg_size_mib raised, as the example above does.