@credence/otel
v0.6.0
Published
OpenTelemetry bridge for Credence: epistemic events as spans, and a trace id as first-class provenance.
Readme
@credence/otel
Credence is not a second thing to instrument. Your observability stack — Langfuse, Dynatrace, Foundry, whatever — answers what ran. Credence answers what is known, on what evidence, and what is missing. This package makes those two answers the same trace.
Two directions, both optional, neither able to change what the ledger stores.
Emit — epistemic events as spans
import { createLedger } from "@credence/core";
import { createOtelObserver } from "@credence/otel";
const ledger = await createLedger({ observer: createOtelObserver() });That is the integration. Twelve event types become spans, parented to whatever span your agent was already inside:
| Span | When |
|---|---|
| credence.claim.recorded | a claim is written (idempotent re-writes included, flagged) |
| credence.claim.superseded | a correction supersedes an earlier claim |
| credence.finding.opened | a gap / conflict / risk opens, or reopens because evidence moved |
| credence.finding.resolved | the condition cleared |
| credence.finding.dismissed | somebody dismissed it |
| credence.obligation.blocked | a blocking obligation was opened |
| credence.obligation.fulfilled | an obligation reached done or waived |
| credence.judge.verdict | the judge decided — including abstained and deferred |
| credence.snapshot.taken | an epistemic snapshot was frozen |
| credence.decision.recorded | a decision is written, including the basis it was standing on |
| credence.decision.stale | assessment flipped to STALE (emitted on transition, never polled) |
| credence.decision.revalidated | a new decision superseded a stale one |
@opentelemetry/api is an optional peer dependency. Your application owns
the SDK, the exporter and the version; this package never installs one, and
installing any other Credence package never drags OpenTelemetry in. "Optional"
means optional to install, not optional to work: importing @credence/otel
without the api present fails loudly at module resolution rather than degrading
into a bridge that silently records nothing.
With the api present but no SDK registered, the API hands back a no-op tracer.
startSpan allocates a NonRecordingSpan and end() does nothing, so
installing the observer is a genuine zero-behaviour-change addition — not a
partially recorded trail.
A missing correlation is written down, not left blank
Every span carries credence.correlated. false means there was no valid
caller span to attach to, so this span is a trace root and the write cannot be
joined to whatever the agent was doing. Left implicit, that is indistinguishable
from a parent a sampler dropped. The same instinct as indeterminate in
verifyChain and unreported in the autonomy report: when the answer is "we
cannot say", say which kind of "cannot".
Parenting only survives an
awaitif aContextManageris registered.NodeSDK.register()does this for you. A hand-builtBasicTracerProviderdoes not — registerAsyncLocalStorageContextManageryourself, or every Credence span comes out a trace root.
Consume — a trace id is provenance
import { traceEvidence } from "@credence/otel";
await ledger.recordClaim({
caseId, key: "total_debt", value: 410_000, status: "verified",
evidence: traceEvidence({ locator: { page: 4, bbox: [120, 300, 480, 330] } }),
});This mints a source with the existing kind: "tool_receipt" — the source
kind Credence already had for "the agent called something and this came back" —
whose uri is otel://trace/<trace-id>/span/<span-id> and whose meta carries
the W3C traceparent. There is no second provenance concept, so every read
surface that already understood evidence understands this for free.
traceSourceId() returns the id that source will have, before the insert,
because source identity is content-addressed. Pass it as toolReceiptId in the
same recordClaim call and the join closes in both directions: the chain entry
names the receipt, and the span's credence.audit.entry.id names the entry.
Outside a recorded span traceEvidence() returns [] rather than a receipt
pointing at trace 0000…. A verified claim written that way fails with
EvidenceRequiredError, which is correct: a receipt nobody can open is not
evidence. When you need to know why there is no provenance,
traceProvenanceState() answers with traced, untraced (nothing was tracing)
or unrecorded (something was, but the span is non-recording — no SDK, or a
sampler dropped it). Those call for opposite fixes, and one undefined cannot
tell them apart.
Spans carry ids and hashes. Never values.
An observability backend is shared storage, read by more people than your
database and retained longer than anyone intends. So spans carry refs, keys,
statuses, content hashes and value hashes — enough to correlate and to
confirm, never enough to leak. Locators (page, bbox, selector text) are dropped
wholesale. Human actors are hashed; machine actors are emitted verbatim, because
those are role names, not people — the prefixes come from
DEFAULT_MACHINE_ACTOR_PREFIXES in @credence/core (judge:, agent:, bot:,
system:), never a second copy that could drift.
If you genuinely want value-level detail, the opt-ins are named to look wrong in a code review:
createOtelObserver({
dangerouslyRecordClaimValues: true,
dangerouslyRecordLocators: true,
});Attribute names
Credence-specific attributes are namespaced credence.*. Exactly one external
convention name is borrowed — gen_ai.request.model, set only when a verdict
actually came from a model — plus the W3C traceparent, which is a W3C
Recommendation rather than an experimental convention.
Checked against the live upstream spec on 2026-07-20, not assumed:
- The whole
gen_ai.*namespace has moved out ofopen-telemetry/semantic-conventions; every row there is now marked Deprecated with the note "Moved to the OpenTelemetry GenAI semantic conventions repository". - In its new home,
open-telemetry/semantic-conventions-genai,gen_ai.request.modelis marked Development — not stable. So aregen_ai.provider.name,gen_ai.operation.name,gen_ai.agent.*and the wholegen_ai.evaluation.*family, which would otherwise be the natural home for a judge verdict. Those are not used; the verdict lives undercredence.judge.*. error.typeis Stable, and would be safe to borrow — but nothing here has an error to name, so it is not in the registry either.
A name that has already relocated once and is still Development will move again.
A wrong convention name is indistinguishable from a right one on a dashboard
until it silently fails to join. src/attributes.ts records the reasoning per
name, and test/attributes.test.ts asserts the borrowed list has not grown.
Determinism
Spans carry wall-clock times and random ids; Credence's renders and ids must not.
The observer is void-returning, called after each write is decided, never
awaited, and its exceptions are swallowed (warned once). test/determinism.test.ts
runs the same scenario instrumented — with both dangerously* opt-ins on — and
uninstrumented, and asserts byte-identical renderCaseIndex output and identical
claim, finding and snapshot ids and snapshot payload hash.
test/entrypoints.test.ts holds the other half of the boundary: this package
reaches no OTel SDK, no exporter and none of the heavy Credence packages, and
@credence/core is imported for types and pure helpers only. Its mirror in
packages/core/test/observer.test.ts asserts no file under core/src mentions
@opentelemetry at all.
Testing
Everything runs against InMemorySpanExporter — no collector, no Docker, no
network. One optional suite round-trips through a real OTLP endpoint. A collector
is configured in this repo, on port 14318 so it never collides with one you are
already running on 4318:
docker compose up -d otel-collector
CREDENCE_OTEL_ENDPOINT=http://localhost:14318/v1/traces \
CREDENCE_OTEL_SPAN_FILE=.otel-out/spans.jsonl \
pnpm vitest run packages/otelThe endpoint alone proves the export was accepted. The span file is what
makes it an assertion: the collector writes every span it received to
.otel-out/spans.jsonl (see docker/otel-collector.yaml), and the suite reads
them back to check that the attribute names and value types survived
serialization — a boolean still a boolean, a count still an integer. That is the
one claim InMemorySpanExporter structurally cannot make, because it never
serializes anything.
Without those variables the suite skips and prints why; CREDENCE_OTEL_STRICT=1
turns the skip into a hard failure, so a skipped verification never reads as a
passing one.
Worked example
examples/otel-agent-run — pnpm --filter @credence/example-otel-agent-run start.
It prints the span tree and walks from a span to the claim, and from the claim to
the page and bounding box.
