If you already instrument your agents with OpenTelemetry, you should not have to rewrite anything to get business-level accountability on top of it. Point your existing OTLP exporter at our collector endpoint and your traces show up — spans, tokens, costs, tool calls and all. No SDK swap, no decorator sprinkled through your codebase, no vendor lock on the way in.
This post covers what we accept, how OTLP concepts map onto ours, and the two conventions problems that bite everyone who tries this.
The endpoint
Standard OTLP over HTTP with a JSON payload, at the path the specification names.
# Any OTLP-compatible exporter. No application change.
export OTEL_EXPORTER_OTLP_ENDPOINT="https://your-lense-host/otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer $LENSE_TOKEN"
export OTEL_SERVICE_NAME="claims-agent"
# Traces land at POST /otlp/v1/traces and are queryable within seconds.That is the whole integration for teams already on OTel. If you use OpenLLMetry, OpenInference, the Vercel AI SDK's telemetry, LangChain's OTel callback or a hand-rolled tracer, all of them export OTLP and all of them work.
The mapping that matters
OpenTelemetry has two levels: a trace, and the spans inside it. Agent systems have two levels too, but people name them inconsistently, which is why traces from one tool look wrong in another.
Our mapping is deliberately boring:
| OTLP concept | Becomes | Meaning |
|---|---|---|
| traceId | session_id | One agent run from start to finish — the whole conversation or task |
| spanId | trace_id | One step inside that run — a single model call, tool call or retrieval |
| parentSpanId | parent / causation | What directly caused this step, so the run renders as a tree |
| span.name | step name | What the step was, shown in the run view |
| resource attributes | service, environment | Which deployment produced it |
An OTLP trace is a run. An OTLP span is a step. Everything else follows from holding that line consistently.
The naming collision — their spanId becoming our trace_id — is
unfortunate but honest. It reflects a real difference: in generic distributed tracing a "trace" is
one request; in agent work the unit people care about is the whole run, which may span many
requests and several minutes.
The conventions problem
Here is the part that trips people up. The OpenTelemetry GenAI semantic conventions have changed, and most production code in the world is emitting the older attribute names. A collector that only accepts the current spelling will silently drop the token counts of nearly every real exporter.
So we accept both, plus the two vendor prefixes that are widespread in practice.
# Token counts -- all four of these are read
gen_ai.usage.input_tokens # current convention
gen_ai.usage.prompt_tokens # earlier convention
llm.usage.prompt_tokens # OpenLLMetry style
traceloop.usage.prompt_tokens # Traceloop style
# Provider / model -- all of these are read
gen_ai.provider.name # current convention
gen_ai.system # earlier convention
gen_ai.request.model
gen_ai.response.model
llm.model_nameA dropped attribute does not raise an error. Your traces arrive, the UI shows them, and the token column reads zero. Teams discover this weeks later when a cost report is obviously wrong. If you are evaluating any tracing backend, send it one span using the older attribute names and check the tokens land. It is a thirty-second test that tells you a lot.
Where the extra layers attach
OTLP gives you the run. It does not have a concept of authority or of a business record changing, because it was designed for distributed systems generally, not for software that is allowed to move money. Those attach alongside, keyed on the same thread.
flowchart LR
subgraph yours["Your application, unchanged"]
APP["Agent code"] --> SDK["OTel SDK
any flavour"]
end
SDK -->|OTLP/HTTP| ING["/otlp/v1/traces"]
subgraph lense["InsightLense"]
ING --> RUN["Runs and steps
from OTLP"]
AUTH["Authority decisions
from your permission layer"]
BIZ["Business events
from your commit hook"]
RUN --- TH(("correlation_id"))
AUTH --- TH
BIZ --- TH
TH --> Q["One query answers
what happened to this account"]
end
style ING fill:#efecfe,stroke:#4b34e0
style RUN fill:#efecfe,stroke:#4b34e0
style AUTH fill:#fbede3,stroke:#b4531b
style BIZ fill:#e2f5f9,stroke:#0e97b0
style TH fill:#14162b,stroke:#14162b,color:#fff
Practically, that means the adoption path is incremental. Repoint your exporter this afternoon and
you have run tracing, token accounting and cost. Add a header to your outbound calls and your runs
start threading to business references. Add an after_commit hook on the two or three
tables that matter and you have a ledger.
Worked example: an existing LangChain service
Say you have a retrieval agent already exporting OTel to a generic backend, and you want business threading without touching agent code.
# 1. Repoint the exporter (env only, no code)
OTEL_EXPORTER_OTLP_ENDPOINT=https://lense.internal/otlp
# 2. Put the business reference on the root span as a resource or span attribute.
# One line, wherever you already start the run.
with tracer.start_as_current_span("handle_submission") as span:
span.set_attribute("correlation.id", submission_ref) # VS-2027-0832950
result = agent.invoke(payload)
# 3. That is it. Runs now group under the submission reference,
# so an underwriter searching VS-2027-0832950 finds the agent work
# next to the documents and the queue item.Step 2 is the only code change, it is one line, and it is the line that turns a tracing tool into something an underwriter can use.
What we do not do
Two deliberate non-features, because the alternative is worse.
We do not require our SDK. A proprietary SDK is a migration cost on the way in and a hostage situation on the way out. If you can export OTLP you can leave whenever you like, and that is the point.
We do not silently normalise away your attribute names. Unrecognised attributes are kept on the span rather than dropped, so a custom attribute your team relies on survives the round trip even though we do not interpret it.
What this looks like in InsightLense
After repointing the exporter, runs appear in the run view with the step tree intact and tokens, latency and cost on every step. InsightLense also shows which attribute spelling it matched, which is how you confirm the ingest read your convention rather than silently zeroing it.
| Step | Name | Model | In | Out | Cost | Attribute source |
|---|---|---|---|---|---|---|
| 1 | plan | claude-sonnet-4-5 | 1,240 | 380 | $0.019 | gen_ai.usage.input_tokens |
| 2 | retrieve_policy | — tool — | — | — | — | — |
| 3 | assess | claude-sonnet-4-5 | 3,110 | 640 | $0.047 | llm.usage.prompt_tokens |
| 4 | post_reserve | — tool — | — | — | — | — |
Once the correlation attribute is on the root span, the same runs also appear under the business reference, which is where the other InsightLense layers attach. That is the whole adoption path: OTLP gives you this view today, and the authority and ledger layers join onto it later without any of it being rebuilt.
Checking it worked
Three things to verify after repointing, in order:
- Spans arrive and nest correctly. If everything is flat, your exporter is not
propagating
parentSpanId— usually a context-propagation bug in your own code, not in transport. - Token counts are non-zero. If they are zero, you are on an older convention and the backend is only reading the new one. We read both; many do not.
- Cost is populated. Cost is derived from tokens and model, so a missing model attribute produces zero cost even with correct tokens. See why we derive cost rather than store it.
If all three pass, you have run-level observability with no application rewrite — and the two layers that make it accountable are additive from there.
Where do your agents already act on real records?
Tell us that, and what you would need to prove about those actions to an auditor. We will set up a hands-on walkthrough within two weeks.
Request a demo →