Repository navigation
v0.23.0
[0.23.0] - 2026-08-26
The release where the production obligations land, and the first artifact to
carry them: everything between v0.22.1 and here ships for the first time,
including the [0.22.2] section below, whose tag was never cut. Generations and
checkpoints with hot backup and one-flag restore; erasure with a receipt that
re-checks every domain by name; tenant identity in the primary key; the eval
entity model; attribution that names the failing step; a compute deadline on
every query and a connection slot no panic can leak; and a six-demo tour that
CI holds green. The format pays for it once: segment formats 1–5 are spent,
and a pre-generation store migrates on first open, automatically.
Added
-
--query-deadline-ms(default 30000,0disables): a wall-clock budget
one query or aggregation may spend inside the engine, checked at segment
boundaries and every few thousand decoded records. Past it the request
answers400 {"error":"query deadline exceeded: ..."}with no rows — the
refusal that stops one runaway scan (an unindexable content search, a
whole-corpus fold) from holding a handler thread for minutes, and never a
partial aggregate, because a partial answer would look complete. A200
still means what it meant — every answer served is still the whole answer
— and durability is untouched: ingest, the tail, exports (bounded by the
socket timeout), and erasure work are all exempt. The worst measured
legitimate query in this repo is ~3 s, so the default never touches
measured use.traza_query_deadline_exceeded_totalcounts the HTTP
refusals; an MCP tool that exhausts the budget surfaces the refusal in its
tool result instead, uncounted.- Library surface:
Config::query_deadlineis a new field (None—
the unbounded behaviour every embedder had — by default; aConfig
built by struct literal must add it,..Config::default()need not),
andError::DeadlineExceededis a new variant of the exhaustive
Errorenum, so a downstream exhaustivematchmust add an arm.
- Library surface:
-
A demo tour in
examples/— six scripted proofs, one claim each. Five
new demos joinmcp-demo, andexamples/README.md
indexes the tour:swarmstreams a simulated agent platform into a live
dashboard in real time;crashSIGKILLs the server mid-ingest and proves
every acknowledged span survived, then corrupts a byte, watches
/v1/verifyname the file, and restores from a hot backup;needle
ingests a million spans over real HTTP and times the answers;incident
investigates a runaway session over MCP —diagnose_session, a defused
prompt injection,promote_failures_to_dataset, and an experiment diff
proving a fix;vanisherases one tenant's session next to an identical
twin tenant and walks the receipt, including the backup pin it refuses to
ignore. Every number each demo prints is measured during the run — a timed
request, a counter, a field from a live response — and each script asserts
its own claims and exits non-zero when one fails, which is whyci.shnow
runs all six at their smoke settings as merge gates.- Demos refuse to run against a port another server already holds:
readiness is the child's own startup line, not a probe that a squatter
can answer. This guard also landed inmcp-demo.
- Demos refuse to run against a port another server already holds:
-
Attribution: the server names the failing step, instead of the reader
doing it. A newdiagnose_sessionMCP tool answers why did this run
fail — the outcome, the step the failure is attributed to, and the
repetition behind it — with the evidence for each claim travelling beside
it. It replaces the two prompts that used to teach a model to read a trace
and judge its shape by eye ("repeated sibling names are a retry storm,
deep chains are a loop"), on the surface's own rule that a prompt wanting
a branch is a tool.- Every rule fires on telemetry that already exists. An earlier design
rested on asession.outcomeattribute and arelation: "retry-of"link
— both conventions Traza invented and nothing else emits — which would
have been a closed loop: seed the convention, detect it, pass CI, and
answercause: nullon every real store. A declared convention now
raises confidence and is never a precondition. The discriminators are
error density, serial fraction, self-similar depth and context growth. - Context growth is read from
context_tokens, not prompt tokens.
gen_ai.usage.cache_read_input_tokensandcache_creation_input_tokens
are recognized, because Anthropic reportsinput_tokensas the uncached
remainder: a growing conversation reports a prompt count that falls as
its cache warms, so reading the wrong field inverts the signal on the
configuration long-running agents actually use. Where the arithmetic is
unknown the trend is reported as unreadable rather than guessed. - It refuses to say more than the data supports. A shape is classified
only when a discriminator agrees; otherwise it is reported as
inconclusive with the missing signal named. Ordinary iteration is
reported as ordinary so a reader can see it was examined and set aside.
Absent token data can never satisfy a healthy classification. The
strongest test is silence: the analysis finds no fault anywhere in the
bundled corpus of healthy workloads. - Session outcome is first-class and provenance-tagged:
declaredwhen
a span said so (session.outcome,session.goal— Traza extensions,
labelled as honestly asllm.cost_usd),derivedfrom the run's own
spans otherwise, andunknown— never rendered as success — for a run
still in flight. A run that failed a call and recovered is a success that
reports its errors, not a failure.
- Every rule fires on telemetry that already exists. An earlier design
-
promote_failures_to_datasetcloses the loop into the M4 eval
entities: a session's attributed failures become a regression dataset
version, each example carrying its own copy plus provenance back to the
span. Re-promoting is idempotent. Gated by anrwtoken and a new
--mcp-promoteswitch, separate from--mcp-annotationsbecause an
annotation dies with its span while a promoted example deliberately
outlives its source. The caller names a session, never a span: the
promoted set is re-derived server-side from the diagnosis, so text injected
into the telemetry can change whether a promotion happens but never what it
copies — proven by a test that plants exactly that instruction. -
A runaway agent scenario in the seed corpus: an agent whose search tool
fails from the fourth turn on, so every later reflection appends the failure
to its context and asks again. Its steps are siblings under one root, the
shape real frameworks emit, and it carries no declared outcome and no retry
link — so finding it proves the analysis works on what a real pipeline
sends. -
Tenant identity in the primary key. Span identity is now
(tenant, trace_id, span_id)— everywhere: the write buffer's index, WAL
replay, segment supersede resolution, compaction's last-write-wins merge,
the tail ring's veils, cursors, the analytics key hashes, annotations and
erasure. Two tenants sharing a trace id can no longer silently upsert over
each other, which is the whole reason this ships now: keys cannot be
retrofitted after the format freeze. The default tenant is the empty
string and is never serialized, so a single-tenant store writes
byte-identical WAL frames, segment records and annotation lines to what it
wrote before tenancy existed — no segment or WAL format bump, proven by a
serialization-guard test. Tenant scoping reaches every surface the roadmap
names:- Credentials:
TRAZA_TOKENSentries take an optional binding,
scope@tenant:token. A bound credential ingests, queries, tails,
exports, annotates and erases exactly its own tenant; naming another
tenant is a 403, and the store-global operator surfaces (/v1/stats,
/v1/metrics,/v1/verify, checkpoint/flush/backups) refuse bound
tokens outright. OTLP exporters select a tenant with thetraza.tenant
resource attribute; MCP tools are scoped by the same binding. - Sessions are
(tenant, session_id)— the samesession.idunder
two tenants is two sessions, in the rollup sidecar (format v3,
self-healing rebuild), the session list, andgroup_by=sessionrows. - Retention takes per-tenant windows:
--tenant-ttl TENANT=SECONDS,
repeatable. A tenant's cutoff is its override, else the global TTL,
else never — and the segment retire-whole fast path only runs when a
global TTL covers everyone, because a whole-segment decision taken from
configured windows alone would delete an unswept tenant's data with the
segment. - Quota accounting:
GET /v1/tenantsreports per-tenant spans,
traces, serialized bytes and offloaded payload bytes from one exact
fold. Accounting, deliberately not enforcement. - Erasure: trace/span/session subjects carry a tenant (empty = the
default tenant, never "all"), and a newtenantsubject erases
everything a tenant owns — spans, annotations, scores, datasets,
experiments — with the same barrier, ordering, and receipt discipline
as M3. Reference-aware payload deletion now spans tenants: a blob two
tenants share survives one tenant's erasure and the receipt names why.
- Credentials:
-
The eval entity model — identity only, no workflow. The addressing the
product thesis requires, and nothing else: Dataset (stable id, name,
tenant), DatasetVersion (immutable, content-addressed manifest of
(example_id, digest)pairs with a parent version for lineage and the
promotion's provenance — re-POSTing identical content IS the same
version), Example (stable id across versions; input, optional
expected output, split label, provenance back to the source span; bodies
carry$payloadreferences that count as live for the TTL sweep and
reference-aware erasure, so a promoted copy is real for offloaded
content), Experiment (stable id, one dataset version, config
metadata), Run (the experiment→trace link, appended by the external
harness), and Score — an annotation whose addressing was generalized
to a typed subject (trace / span / session / experiment example), so a
score addresses the(experiment, example, span)tuple with every
existing annotation field preserved. All of it lives inevals.jsonl, a
new manifested append-only recovery domain with the annotation log's
torn-tail healing and pin-by-copy discipline. Deletion semantics were
settled up front and are enforced by test: erasing source traces never
corrupts a dataset version (the receipt's neweval-recordsdomain
REPORTS surviving copies and turns inconclusive — purging a curated copy
is a deliberate second act); erasing a payload leaves dangling addresses
in example bodies, reported retained-by-design without losing
conclusiveness; a dataset-version tombstone is logical deletion with
defined effects (410 with the tombstone, dependent experiments keep
working and say why, new experiments refused); a tenant erasure takes the
tenant's eval records inside the barrier, and ids are never reused past
the rewrite (a counter record floors the allocators). Score distributions
(/summary) and experiment-over-experiment diffs (/diff) come from
ordinary reads with per-(example, name)last-write-wins dedup. The
whole loop — promote failing traces, run externally, record runs and
scores, read distributions and diffs — runs end to end in CI against the
built binary, and survives kill -9. -
One recovery domain: generations and checkpoints. Query-visible state
lived in several independent recovery domains — the write-ahead log and
buffer, segments,annotations.jsonl,payloads/— each with its own
durability rule and its own idea of "now", and nothing named a state they
all agreed on. That is why backup, export, retention and deletion were four
mechanisms rather than one. A generation is that agreed state: a
manifest naming every load-bearing file with its SHA-256 digest, plus the
log position it folded through.CURRENTnames the live one, and moving it
— a staged rename made durable by a directory fsync — is the single commit
point for a checkpoint, a restore, or a published deletion.- Backup runs against a live server:
POST /v1/backups/{label}
checkpoints, hard-links the manifested files intopins/{label}, and
verifies every digest before reporting success. Hard links share inodes,
so a pin costs almost no disk and holds its bytes even after compaction
unlinks the originals — the copy proceeds at its own pace, and
POST /v1/backups/{label}/releasefrees it. The pinned set carries
spans, annotations and payload bytes together, which is what a span
export never could. - Restore is install:
traza-server --restore DIR(orStore::restore)
verifies the backup before swapping anything, and commits at one
CURRENTrename, so a failed or interrupted restore leaves the prior
store rather than a blend. GET /v1/verifyre-digests the live generation and names each
discrepancy, so recovery can distinguish damage it may ignore from damage
that changes what the store contains by asking rather than inferring it
from whether parsing happened to succeed.POST /v1/checkpoint, plus a checkpoint every five minutes from the
maintenance thread. Cheap by construction: segments are immutable, so a
checkpoint carries their digests over from the previous manifest and
hashes only what was written since.
- Backup runs against a live server:
-
Log frames carry the generation they belong to. The framing gains an
eight-byte magic and a(epoch, sequence)stamp — a 24-byte header, up from
8.CURRENTand the log are separate filesystem objects, so no rename can
make "the new generation is live" and "the frames folded into it are gone"
one event; a crash between them meets a log still holding folded frames, and
for a checkpoint that published a deletion, replaying them resurrects
exactly what was deleted. Recovery now replays a frame only when its stamp
is strictly after the live generation'sfolded_through, which demotes log
reclamation from correctness to housekeeping. Sequences are monotonic for
the life of the store — a rewrite keeps counting rather than restarting —
because a stamp landing at or before a recorded fold point would be
discarded by the next replay as though it had been folded.tests/generations.rsproves it against the crash state itself: the log's
pre-checkpoint bytes are written back over a committed generation, and the
test fails when the stamp rule is removed. -
Existing data directories are adopted at first open. Nothing moves —
segments stay exactly where they are, because their paths are load-bearing —
and generation one is published over the files already there, converting a
pre-generation log to stamped framing on the way. One-way, resumable, and
committed by theCURRENTrename, so a crash mid-adoption leaves a
directory the next open finishes. -
Targeted deletion, with a receipt.
POST /v1/erasureserases a
subject — a trace, a span, a session (resolved across every recognized
session key), or one offloaded payload — and the new
traza-server verify --erasure(orGET /v1/erasures/{id}/verify) then
proves it: every domain the subject's bytes could inhabit, checked by name,
with the result of each. TTL removes by age; this removes because someone
is entitled to have it gone, and the receipt is the difference.- The tombstone log (
tombstones.jsonl) is a new manifested,
append-only recovery domain beside the annotation log. The intent record
is fsynced before anything is removed, so from that moment the subject
is invisible to every read path — search, lookups, sessions, analytics,
annotations, payload fetches, export, the live tail — even before the
rewrites run, and a crash mid-purge leaves a pending erasure the next
open masks and the maintenance tick finishes. Idempotent to resume: the
purge re-verifies rather than re-damages. (The settle record's counts
are therefore the settling pass's counts; the receipt, not the tallies,
is the authority on absence.) - A pending erasure is an admission barrier, not just a veil. Covered
spans are dropped at ingest — acknowledged, never stored, counted in
traza_erasure_spans_suppressed_total— and the barrier is total: it
runs BEFORE payload offloading, so a suppressed span leaves no orphan
payload bytes behind (a review probe planted exactly that orphan — the
file of a span that never entered the store, invisible to record and
receipt alike); an oversized value whose content hash is itself under
erasure offloads directly to its redacted marker instead of recreating
the file being deleted; and annotations addressed to a covered subject
are dropped at admission the same way. The barrier holds at the BEGIN
transition, not just in the steady pending state: admissions hold an
erasure gate in read mode from their mask load through their store
mutation — the offload's file writes included — andbegin/settle move
the mask only under its write mode, so a batch or annotation is wholly
before an erasure or wholly after it, never astride it. A one-shot mask
check could not say that, and review was right to test the transition.
The cut is exact: every span acknowledged beforesettled_unix_nsis
erased or was never stored, under concurrent writers hammering the
subject (a review probe demonstrated 92 pre-settle survivors before the
barrier existed;tests/erasure.rskeeps the probes as regression
tests, plus a transition stress test that erases in a loop under
payload-writing and annotating threads and then audits the payload
directory itself for orphans). The ingest
response tells the truth about it —acceptedcounts what was stored,
suppressedappears when nonzero, and the OTLP surfaces answer with
partialSuccess.rejectedSpans(hand-encoded for the protobuf path,
because an empty response is a claim of full success). - Nothing rewrites a manifested file after the checkpoint the settle
cites. The confirm purge and annotation drops moved BEFORE the
checkpoint; the settle append itself rides the append-only allowance
every manifest grants the tombstone log. A review probe caught the old
order making the settle's own generation fail verification
(annotations.jsonl: digest mismatch) the moment a concurrent
annotation landed; the ordering is now barrier → purge → confirm →
checkpoint → settle → lift, and the suite asserts the cited generation
verifies clean, including under concurrent writers. - The purge reaches every domain. Buffer and write-ahead log rewritten
to the survivors (the TTL discipline: a deletion a restart undoes is not
a deletion); segments rewritten in place, superseded versions of an
erased key included — the purge tests every physical record, not the
visible one; annotations addressed to erased spans dropped; payload
files deleted reference-aware, because content addressing means one
file can back spans outside the subject — those bytes are retained and
the receipt names the reason. A payload subject additionally rewrites
every referencing span to drop its inline preview: the preview is
content too. Publication is a checkpoint — the deletion is durable when
CURRENTmoves, andtests/erasure.rsproves it by killing the server
after its 200. - The receipt is a verification, not a claim. Its result is computed
from the walk, never from the settle record. Matches are classified
against the erase record's resolved keys under one rule for every
domain: an erased key found live again is a re-delivery and fails the
receipt; a fresh key under the same identifiers is new activity,
reported without failing it — an erasure is a barrier, not a ban. Pins
are checked and named: a backup pinned before the erasure still holds
the bytes in its hard-link farm, and the receipt says which pin to
release. What remains afterwards is stated rather than hidden: the
tombstone record itself keeps the subject's identifiers and content
hashes — never the erased text — as the record the receipt verifies
against. Where a check is over-approximate by construction (the
byte-level occurrence scans), its findings never blend into the
verdict: the receipt carries a separateconclusiveflag, and the
subcommand exits0erased-and-conclusive,3erased-but-inconclusive,
2not erased. - Pins no longer share the append-only logs' inodes. A pin hard-links
the immutable files and copiesannotations.jsonland
tombstones.jsonlat their manifested length. Hard-linking them let
every later append edit the "backup" in place — a pin taken before an
erasure inherited the erasure's settle record through the shared inode,
so restoring it produced a store that recorded a deletion it did not
contain. - Erasure requires a new
admintoken scope.TRAZA_TOKENSgrows
admin:alongsiderw:/ro:;POST /v1/erasuresrefuses plainrw
with a 403. Every collector holds a write token, and a credential minted
to produce telemetry must not be the credential that destroys it.
Payload subjects are canonicalized to lowercase hex before anything is
resolved or recorded — an uppercase hash previously matched no stored
reference and produced a green receipt over untouched content. Redaction
markers are no longer counted as live payload references anywhere
(protection sets, receipts): a marker records that content is gone. - No MCP tool for any of it, deliberately. Deletion is an HTTP verb
behind theadminscope; the agent-facing surface stays read-only, so
stored adversarial text has no destructive tool to actuate.
- The tombstone log (
-
Derived LLM cost from a configured pricing table,
--pricing FILE.
OpenTelemetry defines no cost attribute, so a span carries one only if its
pipeline metered it — and most do not, which left stores that knew the model
and both token counts reporting$0.00on every cost surface. Rates are USD
per million tokens, keyed by exact model name or aprefix*pattern
(longest match wins; an exact name beats every pattern; a bare"*"is a
default). There is no built-in table and will not be one: prices move on the
vendor's schedule, and self-hosted models have no public rate.- A metered
llm.cost_usdalways wins, and a span reporting only a
total token count stays unpriced rather than being split by an assumed
input/output ratio. - Estimates are reported as estimates, by count rather than by amount.
Every cost-bearing row —/v1/stats/llm,/v1/sessions, and each bucket
of/v1/stats/series— carriescost_derived_usdbesidecost_usdplus
cost_metered_calls,cost_derived_callsandcost_unpriced_calls. The
dollars cannot carry provenance on their own: a zero-rate model is priced
and adds nothing, a call nothing could price also adds nothing, and
neither is distinguishable from a genuine measurement of zero.
cost_derived_calls > 0means the total is an estimate;
cost_unpriced_calls > 0means it is an undercount. The dashboard and MCP
both mark an estimate~, show—rather than0.0000when nothing
could be priced, and state the breakdown — andanalyze_costno longer
claims cost is exact when a rate table contributed to it. - Rollup sidecars record the fingerprint of the table they were folded
under (format v4), so editing the rates invalidates exactly the cached
counters that would now be wrong instead of reporting last month's prices
from a sealed segment forever. An empty table fingerprints to zero, so a
store that prices nothing binds its sidecars exactly as before. A
malformed pricing file refuses startup rather than being ignored.
- A metered
Changed
-
The erasure receipt's failing verdict is
not-erased, notincomplete.
A receipt over a pinned backup reportedresult: "incomplete"beside
conclusive: true— semantically right (the walk conclusively found the
pin holding the bytes) but reading as a contradiction, because
"incomplete" invites attaching to the verification when it describes the
erasure. The value now names its subject: conclusivelynot-erasedand
erased-but-inconclusive both read as the claims they are, the wire value
matches the prose the docs and theverifyusage text already used
(2 not erased), and the exit codes are unchanged. -
Ingest rejects an inadmissible tenant with 400, on both surfaces. A
tenant is identity: lowercase[a-z0-9][a-z0-9._-], at most 64 bytes, or
empty for the default. A misconfiguredtraza.tenantresource attribute
fails the whole OTLP export loudly rather than being silently dropped —
partialSuccess is for data the server chose to suppress, not for a defect
the client must fix. -
Cursor tokens carry a version byte. The ordering key changed shape
(tenant joined it), and a pre-tenancy token must parse as invalid, never
as a plausible wrong position. Live cursors from before an upgrade get a
400, which is what a stale cursor always deserved. -
The span identity key on the wire and on disk is
$tenant, not
tenant. A span's top-level namespace is open — unknown fields survive
in the round trip — so a baretenantis client data and must stay client
data. The$sigil (as in$payload) is the format discriminator: bytes
written before tenancy cannot carry$tenant, so a store read after an
upgrade keeps its baretenantvalues as data rather than promoting one to
an identity no query selects and no erasure names. This replaces an earlier
decode-time normalization that tried to tell legacy data from identity by a
value's shape — which a valid-looking legacy value ("acme") slipped
through. The?tenant=read filter and the erasure subject'stenant
field are closed namespaces and keep the plain name — but they, along with
an annotation's and a dataset'stenant, now also accept$tenantas an
alias, so a client that learned the span's spelling cannot silently
misroute a score, a dataset, or — worst of all — an erasure to the default
tenant. The span alone rejects a baretenantas identity, because the span
alone has the open namespace that makes it data. -
Append-only files are digested and verified over their recorded prefix,
exactly.digest_enginerecorded an append-only log's length from one
instant and its hash from another (sha256over the live file), and
verify_againsthashed the whole file whenever the lengths happened to
match — so an annotation or settle record appended mid-walk made a
freshly published generation fail its own verification with a digest
mismatch that was growth, not damage. Both sides now hash exactly the
recorded length, which is what the checkpoint's concurrency story always
claimed. Found by the erasure transition stress test; the race predates
erasure and was reachable by any annotation landing inside a checkpoint's
digest walk. -
wal_bytescounts replayable work, not file bytes. The log's constant
preamble is excluded, so a reclaimed log measures zero. Both the
flush_wal_bytesbound and the statistic mean the same thing — what a
restart would redo — and a constant is neither replayed nor reclaimable. -
The checkpoint's commit point is proven by SIGKILL, not only
deterministically.tests/durability.rskills a real server mid-checkpoint
at three named on-disk signals — the manifest directory appearing,
.CURRENT.tmpexisting between the staged write and its rename, and
CURRENTitself changing — because those windows are single fsyncs wide
and a stagger alone lands wherever the machine favours. Recovery must then
answer with exactly one state: every acknowledged span present, the hot key
at the newest acknowledged version, the live generation verifying clean
overGET /v1/verify, a complete manifest behind whateverCURRENTnames,
and nothing staged surviving the sweep. PublishingCURRENTbefore the
manifest is durable fails it.The staged-
CURRENTsignal exists because a slower CI runner reached that
window when a fast machine never did — and a test that only covers a state
when the machine happens to be slow is a test that passes for the wrong
reason. Aiming at it beats depending on the weather.Recorded because the opposite would be a claim rather than evidence: the
other ordering rule — reclaiming folded frames only afterCURRENTis
durable — was mutated too and did not fail. A checkpoint seals the
buffer first, so the frames it reclaims are already in a durable segment,
and recovery loads segments by walking the directory rather than by reading
the manifest. That rule is defensive under today's engine and becomes
load-bearing the moment segment loading is manifest-driven, which is what a
replicated snapshot install wants. Invariant 12 now says so. -
Checkpointing is never a side effect of a primitive. It seals the write
buffer, and expiry must not decide when to seal:expire_beforedeletes
from every domain and stops, exactly as before. The deletion is durable when
its domains are durable and published by the next checkpoint — one
maintenance interval away, or immediately when a backup asks.
Fixed
-
The bound-token payload probe now heals the sidecar it rebuilds. The
reachability check behind scopedGET /v1/payloads/{ref}, MCP
get_payload, and dataset-version promotion read rollup sidecars directly,
so a segment missing one — a crash, a copied data directory, a pricing
change invalidating every binding — was decoded again on every probe,
forever. The probe now reads rollups through the same
cache-then-sidecar-then-rebuild path every aggregation uses: the decode is
paid once, the sidecar is written back, and the new
traza_rollup_builds_totalcounter says whether heals are landing. -
A panicking handler permanently consumed a
--max-connectionsslot.
The live-connection count was decremented at the end of the handler
closure — a line a panic never reaches — so each handler panic leaked one
slot, and enough of them walked the server to a permanent503with
nothing left in the logs but old panics. The slot is now claimed by an RAII
guard whoseDropreleases it on return, unwind, and spawn failure alike,
and each unwind is counted in the newtraza_http_handler_panics_total
(any nonzero value is a bug report waiting to be filed). -
Two documentation claims that were not true of the code. The OTLP mapping
table said "other resource and scope attributes" were merged beneath span
attributes. Scope attributes are; resource attributes are discarded —
onlyservice.nameandtraza.tenantare read — so
deployment.environment,service.version,host.nameandk8s.*never
reach the store and cannot be filtered on. The table now says so, names the
workaround (copy them onto spans in an SDK processor or a Collector
transform), and an OTLP conformance assertion pins the behaviour so the
page cannot drift from it again. Separately, the MCP tool table billed
record_annotationas "Score this trace"; it writes a trace/span annotation
and cannot address an(experiment, example)pair, which is what a score is.
The MCP guide now states that and that the eval entities are HTTP-only. -
A promotion could read across a tenant boundary.
promote_failures_to_dataset
pinned the tenant it wrote but not the tenants it read: an unbound credential
resolves a session across every tenant, so naming one tenant's session copied
its span attributes and provenance into a default-tenant dataset — and a
promoted example deliberately outlives its source, so erasing that tenant
afterwards left the copy standing, permanently outside the reach of the
erasure that should own it. Every read the tool makes is now scoped to the
tenant being written (Some("")being the default tenant named, not the
absence of a scope). Diagnosis is unchanged: an operator may still read any
session, and simply cannot copy one out of its tenant. -
Prompt-cache counters were recognized under one spelling. OpenLLMetry
moved them to dotted segment names (gen_ai.usage.cache_read.input_tokens,
gen_ai.usage.cache_creation.input_tokens) to match OTel GenAI, and only the
older underscore forms were read — so on a cached Anthropic agent, the exact
configuration this field exists for, the detector fell back to the uncached
remainder and reported a growing context as ordinary iteration. Both
spellings are accepted now, dotted first. -
Link attributes were outside every payload walker. A
$payload
reference copied into a span'slinks[].attributes— an ordinary thing for
an SDK to do, since the wire contract stores what a client sends — was
counted by no reference sweep, redacted by no erasure, and invisible to the
verify predicate. So a blob a surviving span still referenced could be
deleted, andverify --erasurecould report erased and conclusive over
content still on disk and still readable. All five walkers now share one
iterator naming every attribute map a span carries; the duplicate collector
that made the gap exist twice is gone. Links are also bounded at ingest for
the first time, with its own test. The two erasure guards are
mutation-proven: with the link arm removed from the iterator, one test finds
the whole secret sitting inline in the link and the other finds the payload
deleted while a live reference remained. -
top_failuresandslowest_spansadvertised a row limit whose maximum
(100) and default (20) were both wrong — they apply 50 and 10 — so a caller
obeying the schema was silently given a fraction of what it asked for. -
The claim in the data-model and LLM-semantics guides that a link's
relationattribute "keeps link semantics queryable" was false — no query
path reaches link attributes. The docs now say what is true: links are
traversed inside a diagnosis, and are not filterable. -
A pending whole-tenant erasure withholds its payload bytes from a scoped
fetch. A tenant erasure discovers its span-held references only as its
purge walks them, so the mask'spayload_filesset fills after the mask
already hides the tenant. AGET /v1/payloadsunder a bound credential now
returns nothing the moment its tenant is masked — whether or not that exact
reference has been enumerated — closing a window in which a planted crash
state served the bytes an erasure was seconds from deleting. Freshly
discovered references are also folded into the live mask as they are noted,
so the operator path stops serving them mid-purge too. -
The MCP
describe_store"nothing to search yet" note follows the
caller's own usage, not the store total. For a tenant-bound caller the
note was keyed on the store'stotal_records, so it appeared only on a
globally empty store and vanished the instant any other tenant ingested a
span — a co-tenant presence oracle across the isolation boundary the rest
of the overview respects. It now reads the bound tenant's own row. -
An explicit default-tenant scope reaches its own sealed payload. The
default (empty) tenant carries no attribute-index posting — that is what
keeps single-tenant stores byte-identical — so a payload-reachability probe
that trusted the posting found nothing for aSome("")scope once the span
sealed. The probe now scans the records and lets the decoded tenant decide
for the default tenant, exactly asselect_probealready did. -
Resolving last-write-wins no longer re-reads the store to prove keys were
never replaced. Every query-side supersede probe — the limited merge, the
unlimited scan, and the fold behind the aggregation routes — now consults
each newer segment's own key-hash set before paying an exact probe, the
prefilter the analytics fold's exact path already ran; that fold now gates
per segment too, so a key rewritten eleven segments later costs one probe
rather than a walk across the ten between. On a store carrying superseded
versions — a crash-recovered store before its first compaction is the
canonical case — queries cost matches × segments × trace-width decodes,
which is how a recovery query blew a 30-second deadline in CI. The new
traza_supersede_probes_totalcounter is the observable: roughly one probe
per superseded version actually held. -
A pre-
$tenantrollup sidecar is rebuilt, never believed. Reserving
$tenantchanged what a baretenantfield decodes to, and a sidecar's
key hashes are evidence only under the decoding they were computed with —
trusting one across that boundary treated a stale membership miss as proof
of absence and resurrected a superseded span. The rollupSCHEMA_VERSION
is bumped, so the first read after upgrading rebuilds each segment's
sidecar once;tests/fixtures/pr50-tenant-identity, sealed by the
pre-reservation build, is the corpus that fails if a future decoding change
forgets the bump. -
Native ingest accepts an event's timestamp under the OTLP name. A span's
timestamps acceptedstart_time_unix_nano; its events accepted only
timestamp_ns, so a client spelling both the way OTLP spells them had its
entire batch rejected with a 400 naming a field it had supplied. Events
now taketime_unix_nano,timestamp_unix_nano,time_nsandtimeas
aliases, andattributesdefaults, so a named instant no longer needs an
empty map to be legal. The failure mode this closes is quiet rather than
loud: telemetry clients are conventionally fail-open, so the spans simply
never arrived. -
The span search's token column reads the current OpenTelemetry names.
It carried its own third copy of the semantic-convention precedence, which
had drifted fromsrc/semconv.rs: it resolved only the deprecated
gen_ai.usage.{prompt,completion}_tokensand allm.usage.prompt_tokens
key Traza has never recognized. A span using the currentinput/output
names — resolved correctly by the server and by the trace detail — showed a
blank cell. It now uses the sharedllmUsagehelper, as everything else
does. -
Two toolbar actions on the span search worked in name only. A local
namedwindowshadowed the global for the whole component, so
window.promptandwindow.locationreadundefinedthrough the optional
chains guarding them: saving a view never asked for a name and silently
numbered every oneview N, and "Copy as curl" emitted a hostless URL that
curl refuses.