OpenTelemetry log receiver#
Sigilbase accepts OpenTelemetry log records over OTLP/HTTP and seals each record as an ordinary ledger event. Nothing about the ledger changes: a record becomes an event through the same ingestion path as a hand-written event, chained, checkpointed, signed and anchored like every other. What differs is the stream class it lands on and the meter that counts it.
The first documented source is Claude Code, which exports every tool decision, tool result, API request, permission change and hook execution as OTLP log records once three environment variables are set. The configuration, the privacy flags and the events you will see are on Claude Code, Cowork and Codex CLI. This page is the receiver itself.
Endpoint#
POST https://app.sigilbase.io/otlp/v1/logs
The same receiver is also mounted at https://app.sigilbase.io/api/v1/otlp/v1/logs, so an exporter given the generic endpoint https://app.sigilbase.io/api/v1/otlp through OTEL_EXPORTER_OTLP_ENDPOINT works as well: OTLP appends /v1/logs to a generic endpoint itself.
Logs only. POST .../v1/metrics and .../v1/traces answer 404 with a JSON message saying so, so a misconfigured exporter fails loudly rather than silently. Set OTEL_METRICS_EXPORTER=none and leave traces unset.
Protocols#
| Setting | Accepted |
|---|---|
OTEL_EXPORTER_OTLP_PROTOCOL |
http/json (Content-Type: application/json) or http/protobuf (Content-Type: application/x-protobuf). gRPC is not accepted. |
Content-Encoding |
gzip, or none (deflate is read too). |
| Schema | opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest, decoded from the generated classes of opentelemetry-proto v1.8.0. Older and newer exporters interoperate as the protobuf rules allow. |
A JSON batch and the same batch sent as protobuf produce byte-identical events. The documented snippets use http/json, because a first batch that fails is easier to read in JSON; http/protobuf is supported throughout.
Authentication#
A telemetry key is an API key bound to exactly one telemetry-class stream. Creating a telemetry stream in the app offers one, and more can be issued and revoked from the stream's page or from API keys. The key decides the destination stream, so the exporter needs no other header. Telemetry keys start sgt_.
Send it in whichever of these forms the source can produce:
| Header | Who uses it |
|---|---|
Authorization: Bearer <key> |
Claude Code, Cowork |
Authorization: Basic <base64(key:)> or Basic <base64(:key)> |
Codex CLI, which documents a Basic token |
x-api-key: <key> |
Collectors and relays that cannot set Authorization |
An ordinary API key (sgb_) presented to the receiver answers 403 with the fix named. A telemetry key presented to the events API answers 403 the same way. A revoked or unknown key answers 401.
Responses#
The receiver follows the OTLP/HTTP specification.
| Status | Meaning |
|---|---|
200 |
The batch was taken. The body is an ExportLogsServiceResponse: empty when every record was stored, or carrying partialSuccess.rejectedLogRecords and partialSuccess.errorMessage when some records were rejected per record (see Rejection below). A resend of a batch already stored also answers 200 with nothing rejected, and appends nothing. |
400 |
The body could not be decoded as an ExportLogsServiceRequest. |
401 |
No key, a revoked key, or an unknown key. |
403 |
An ordinary API key, a suspended workspace, or a key not bound to a telemetry stream. |
410 |
The stream is archived. Create a new telemetry stream and key. |
413 |
The body is larger than the receiver limit after decompression, or one request carries more records than the key's burst can ever hold. Send smaller batches; nothing from the refused request was stored. |
415 |
A Content-Type that is neither JSON nor protobuf. |
429 |
The per-key rate limit. Retry-After says how many seconds to wait; nothing from the refused request was stored. Exporters retry on 429. |
503 |
The stream is paused. Retry-After is set; resume the stream to accept records again. |
Error bodies are JSON whatever the request's form, so an exporter's log line says what went wrong. Decoding, mapping and the append happen in the request; nothing else does.
Telemetry-class streams#
A stream has a class, standard or telemetry, chosen when it is created and fixed afterwards so that a month's metering reads consistently. Telemetry streams are ordinary streams in every ledger sense: the same chain, checkpoints, signatures, anchors, exports, auditor grants, redaction, dashboards and alerts. They accept events from the receiver and from the ordinary events API alike. They are metered on a separate counter, telemetry_events, never on events, at a lower price per event, and the receiver only ever writes to them. Allowances and prices are on the billing page.
Mapping#
Each OTLP log record becomes exactly one event. The mapping is deterministic, so anyone holding the original OTLP payload can recompute the event and check it against the ledger. mapping_version in the payload names the rules; it is 1 and is bumped only if the mapping ever changes.
Source identity#
From the resource attributes: service.name (Claude Code sends claude-code), service.version, and any os.* and host.* present. From the record attributes: user.id, organization.id, user.account_uuid, session.id, prompt.id, event.name, event.sequence, event.timestamp.
Envelope#
| Field | Value | Fallback |
|---|---|---|
actor |
<service.name>:<user identifier>, the identifier being the first present of user.account_uuid, user.id (record attributes), enduser.id, process.owner (resource attributes). For example claude-code:3f9a... or codex_cli_rs:lwalpole. |
<service.name> alone when none is present; otel:unknown when there is no service.name. |
action |
event.name with its service prefix as the source sends it, such as claude_code.tool_result or codex.tool_result. Claude Code puts the bare name in the event.name attribute (tool_result) and the namespaced form in the record body (claude_code.tool_result); when the body is the namespaced form of that same name, the body is the action. Otherwise the attribute, else the record's own event_name field. |
otel.log |
resource |
session:<session identifier>, the identifier being the first present of session.id, conversation.id. |
service:<service.name>, or none. |
occurred_at |
event.timestamp, else the record's timeUnixNano, else observedTimeUnixNano, at microsecond precision. |
The receipt time, with occurred_at_source: "received" added to the payload. |
Envelope fields carry at most 255 characters; a longer identifier is cut at that length. These choices let completeness queries answer "everything this user's agent did", "every event of this type" and "everything in this session" from the envelope alone.
Payload#
{
"otlp": {
"severity_number": 9,
"severity_text": "INFO",
"body": "claude_code.tool_result",
"attributes": { "...every record attribute, verbatim..." : "..." },
"resource": { "...every resource attribute, verbatim..." : "..." },
"scope": { "name": "com.anthropic.claude_code.events", "version": "2.0.76" },
"time_unix_nano": "1759840442000000000",
"observed_time_unix_nano": "1759840442100000000",
"trace_id": null,
"span_id": null,
"dropped_attributes_count": 0
},
"mapping_version": 1
}
Keys are not renamed or flattened. Values keep their OTLP types, with the two adjustments the ledger's canonical JSON requires, since it carries no floating-point numbers and only integers within plus or minus 2^53 - 1:
| OTLP type | Stored as |
|---|---|
| string, bool | as they are |
| int within the canonical range | a JSON integer |
| int beyond it | {"$int64": "<decimal>"} |
| double | {"$double": "<shortest decimal that round-trips>"}, so a double 1 is {"$double": "1.0"}; NaN, Infinity and -Infinity spelled as such |
| bytes | base64 |
| array | a list |
| kvlist | a map; a repeated key keeps its last value |
time_unix_nano and observed_time_unix_nano are decimal strings of nanoseconds, or null when the record carries none. trace_id and span_id are lowercase hex, or null. A record with the newer top-level event_name field carries it as event_name inside otlp.
Size#
The existing per-event payload limit applies: 32 KiB of canonical JSON. Claude Code's attribute values are bounded (tool inputs are truncated at 512 characters per value and about 4K per payload; raw API bodies at 60 KB when that flag is on), so ordinary records fit. A record over the limit is rejected per record, not by failing the batch.
Duplicate suppression#
OTLP exporters retry failed exports, and a timeout after a successful append produces a resend. The receiver computes an idempotency key per record. When a session identifier (session.id or conversation.id), event.sequence and event.name are all present:
idem = SHA-256( stream_id || 0x00 || session.id || 0x00 || event.sequence || 0x00 || event.name )
Otherwise (Codex CLI carries no sequence counter, so its records always take this path):
idem = SHA-256( stream_id || 0x00 || canonical_json(otlp) )
where canonical_json(otlp) is the canonical JSON of the payload's otlp object, and stream_id is the stream's id as the API shows it. The key, as lowercase hex, is the event's idempotency_key. A record whose key already exists in the stream is reported as accepted and not appended, for as long as the stream exists: a late resend is never stored twice. Two genuinely identical records with no sequence counter also collapse into one, which is the trade a sequence-free source makes.
Rejection#
A record is rejected, and counted in partialSuccess, when it is undecodable, over the payload limit, or carries a time more than 24 hours in the future. The rest of the batch is stored. A record is never rejected for an unknown event.name or a missing attribute: unknown sources map through the fallbacks above and are stored.
Limits and the rate limit#
| Limit | Value |
|---|---|
| Request body, after decompression | 16 MiB. The default exporter batch of 512 records at the per-event cap cannot exceed it; a 413 names the exporter setting (OTEL_BLRP_MAX_EXPORT_BATCH_SIZE) that bounds a batch. |
| Per event | 32 KiB of canonical payload. |
| Per telemetry key | 2,000 records a second sustained, with a burst of 10,000, as a token bucket. Above it the receiver answers 429 with Retry-After and stores nothing from the refused request. A single request carrying more records than the burst is 413, not 429: it could never be paid for, so send smaller batches. A fleet behind one key that needs more can be issued several keys; Enterprise workspaces can have the figures raised. |
The rate limit is a scale control and nothing else. It never reads billing state: a workspace over its telemetry allowance is limited exactly as one under it, and ingestion never stops for billing.
One stream is one hash chain, so its appends are serialised: every batch for a stream waits its turn behind the batch before it. A fleet of a few hundred agents exporting every few seconds onto one stream will see its tail latency climb; give each team or project its own telemetry stream and key rather than one for the whole estate.
A first batch by hand#
curl -s -X POST https://app.sigilbase.io/otlp/v1/logs \
-H "Authorization: Bearer sgt_example.xxxx" \
-H "Content-Type: application/json" \
-d '{"resourceLogs":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"claude-code"}}]},"scopeLogs":[{"scope":{"name":"com.anthropic.claude_code.events"},"logRecords":[{"timeUnixNano":"1759840442000000000","severityNumber":9,"body":{"stringValue":"claude_code.tool_result"},"attributes":[{"key":"event.name","value":{"stringValue":"claude_code.tool_result"}},{"key":"event.sequence","value":{"intValue":"1"}},{"key":"session.id","value":{"stringValue":"sess-example"}},{"key":"tool_name","value":{"stringValue":"Bash"}}]}]}]}]}'
The response is {}. The event is on the stream within the request, and the stream page shows it with actor claude-code, action claude_code.tool_result and resource session:sess-example.
What a telemetry stream proves#
- That each record was received by Sigilbase at the stated time, from a holder of the stream's telemetry key, and has not been modified, deleted or reordered since.
- The order in which records were received, and the order the source claimed through its own sequence numbers.
What it does not prove#
- That the source emitted every event it could have. Telemetry is produced by a client on the user's machine. A client with export disabled, misconfigured or offline produces no records, and the ledger cannot see the gap. Managed settings make this harder to do by accident; they do not make it impossible.
- That the content of a record is true. Sigilbase stores what the client sent.
- The source's claimed time. Sigilbase vouches for receipt time only, and export is batched, so the two differ by seconds in normal operation.