Feat: Lineage telemetry plugin — two facts-only spans per exchange - #761
Draft
JoshSag wants to merge 2 commits into
Draft
Feat: Lineage telemetry plugin — two facts-only spans per exchange#761JoshSag wants to merge 2 commits into
JoshSag wants to merge 2 commits into
Conversation
Emits two facts-only OTel spans per HTTP exchange crossing the sidecar: a
request span when the request is seen, a response span at stream end, joined by
lineage.exchange.id (the request span's own id). Span names are
"{self_id} {protocol} {operation}", with the response span appending
" response".
The facts are lineage.role / direction / self.id / peer.host / protocol /
principal.{sub,client} / outcome / denied_by / parent.source, plus url.scheme
and url.path. With capture_io the parsed message content rides along as
input.value and output.value, so a trace viewer shows the actual A2A message,
MCP tool arguments or LLM prompt inline. capture_io is off by default —
payloads may carry user messages and model output.
The producer records facts, not meaning: no hop classification, no trust
vocabulary, no identity guessing. Interpretation belongs to whatever consumes
the spans, which is what keeps this package small and lets the vocabulary change
without touching Go.
Cross-pod parenting rides a single tracestate member: parent from dg-parent when
present, else the wire parent, then re-stamp that member with this span's id.
The forwarded traceparent is never modified, so an app with its own tracing
keeps its chain intact toward its own backend. Nothing guesses a parent —
missing data degrades to an explicit unknown.
Config decodes with DisallowUnknownFields so a typo'd knob is a boot error
rather than a silent default. self_id falls back to self_id_file, defaulting to
the operator-mounted /shared/client-id.txt. bypass_paths and bypass_hosts keep
agent-card discovery, health probes and telemetry backends out of the graph.
Known limit, documented at plugin.go:22: this plugin orders itself after the
gate plugins and the pipeline short-circuits on a request-phase reject, so an
exchange denied by a gate before OnRequest ran emits no spans at all. Denials
after that point are captured as outcome=denied with denied_by.
Signed-off-by: YehoshuaSagron <ysagron@gmail.com>
Follows the one-tag-file-per-plugin convention: five lines per binary in plugins_lineage.go, gated by //go:build !exclude_plugin_lineage, so main.go imports no plugin package directly. A build carrying the exclude tags links neither the plugin nor its OTel dependency subtree. go.mod changes are go mod tidy output. Four direct dependencies, three of them promotions of modules already present as indirect (otel, otel/sdk, otel/trace); the fourth is the OTLP/gRPC trace exporter. Five new indirect. Licences are Apache-2.0 for the OpenTelemetry modules and genproto, MIT for backoff/v5, BSD-3-Clause for grpc-gateway/v2. No go.sum change is needed — the existing sums already cover these modules. Signed-off-by: YehoshuaSagron <ysagron@gmail.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What it does
Adds a
lineage-telemetryplugin that emits two facts-only OTel spans perHTTP exchange crossing the sidecar:
lineage.exchange.id(the request span's own id).Span names are
{self_id} {protocol} {operation}, with the response spanappending
response. The facts arelineage.role,lineage.direction,lineage.self.id,lineage.peer.host,lineage.protocol,lineage.principal.{sub,client},lineage.outcome,lineage.denied_by,lineage.parent.source, plusurl.schemeandurl.path. Withcapture_io: truethe parsed message content rides along asinput.value/output.value, so a trace viewer shows the actual A2A message, MCP toolarguments or LLM prompt inline.
The producer records facts, not meaning. No hop classification, no trust
vocabulary, no identity guessing. Anything interpretive — what kind of hop this
is, which entity it belongs to — lives in whatever consumes the spans. That
separation is the design, and it is why the plugin stays small and the
vocabulary can change without touching Go.
Configuration
Six keys, decoded with
DisallowUnknownFieldsso a typo is a boot errorrather than a silent default:
capture_iois off by default — payloads may contain user messages andmodel output.
self_idfalls back toself_id_file, defaulting to/shared/client-id.txt, the operator-mounted credential.bypass_pathsandbypass_hostskeep agent-card discovery, health probes and telemetry backendsout of the graph by default.
Cross-pod parenting rides one tracestate member
Each sidecar parents an exchange from the
dg-parenttracestate member whenpresent (else the wire parent), and re-stamps that member with its own request
span id. The forwarded
traceparentis never modified — an app with its owntracing keeps its chain intact toward its own backend. No mechanism guesses a
parent: missing data degrades to an explicit unknown or fails loudly.
The wire format is specified at v1.5.3 in a document we maintain alongside
the consumer, with a consumer test suite pinned to it. Every attribute name,
its conditional emission, and the parenting rule are contract.
Why lane 1 matters
The plugin writes its tracestate stamp into
pctx.Headers. Inextprocandforwardproxyas they stand today, that write never reaches the wire —only
Authorizationis forwarded. The stamp dies in the pipeline context, thenext hop sees no
dg-parent, and the reconstructed graph degrades intophantom-root forests: an exchange that should derive as 2 interactions under 1
root came out as 3 interactions under 2 roots when measured.
So: lane 1 is a prerequisite for this plugin to be useful, not for it to
build. The diffs never collide — only review order matters. If lane 1 is not
wanted, this plugin still works correctly in
reverseproxymode, which alreadyhas the header sync.
Opt-out is a build tag you control
The plugin registers through your one-tag-file-per-plugin convention
(
cmd/authbridge-{envoy,proxy}/plugins_lineage.go, 5 lines each). A build withexclude_plugin_*tags links none of the plugin and none of its OTeldependency subtree.
Verified rather than asserted: the lite variant (
authbridge-proxybuilt withthe seven
exclude_plugin_*tags CI uses) builds and passesgo test -raceon this branch.
Dependencies
Four direct, three of which are promotions of modules already in your graph
as indirect dependencies:
go.opentelemetry.io/otelgo.opentelemetry.io/otel/sdkgo.opentelemetry.io/otel/tracego.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpcPlus five new indirect:
otlptrace,proto/otlp,cenkalti/backoff/v5,grpc-ecosystem/grpc-gateway/v2,genproto/googleapis/api.Licences, checked at the module proxy: Apache-2.0 for every OTel module and
genproto, MIT forbackoff/v5, BSD-3-Clause forgrpc-gateway/v2.All permissive; none on your dependency-review deny list (GPL / AGPL-3.0).
No
go.sumchange is needed anywhere — your existing sums already coverthese modules, which is why the diff contains none. A reviewer expecting one
might otherwise read its absence as an omission.
go mod tidyis byte-clean onall three modules.
Verification
Under
golang:1.26, mirroring.github/workflows/ci.yaml:go vet·build·test -race -cover(authlib)cmd/authbridge-envoyandcmd/authbridge-proxy(GOWORK=off)exclude_plugin_*tags — build +test -racego mod tidybyte-clean × 3 modulesgofmt -lmainitself; the new package is gofmt-cleanAll of the above was run on this branch alone, without the listener fix applied — which is the
direct evidence for the claim above that this compiles and tests green independently of it.
The plugin's own suite is 858 lines.
Reproducible evidence that it does what it claims is the demo submitted
separately (
authbridge/demos/lineage/): enable the plugin, pointotel_endpointat any OTLP sink, and one A2A request yields the pair. On astock install that sink is the platform's own collector, whose default pipeline
exports to
debug— so the spans are readable straight from its log, with noextra service to deploy. (Phoenix is not installed by default;
components.phoenix.enabledisfalse, so it is one helm value away ratherthan already there.) Run against a live cluster, that is literally:
both carrying the same
lineage.exchange.id. Nothing beyond this repo and acluster is required to reproduce it.
Limits, stated plainly
The outbound listener has two filter chains. A connection matching
transport_protocol: tlsgoes toenvoy.filters.network.tcp_proxyand isforwarded to its original destination as bytes; a connection matching
raw_buffergoes to the HTTP connection manager, which is the only chaincarrying the
ext_procfilter. So for TLS traffic the plugin is neverinvoked: there is no method, no path, no host, no status — nothing to attach
a payload to. The only thing observable is the SNI name at handshake, which
is why an SNI observer is the named follow-up rather than "parse the body".
Our probe asserts both sides: the same external endpoint called over plaintext
HTTP derives exactly one hop, and called over HTTPS derives zero rows, while
both calls return 200 to the app.
capture_io: true, a largemessage is attached whole. There is no truncation in the plugin (checked).
pipeline YAML places this plugin after the gate plugins (ordering is by
position in the list — it is not soft-declared under this capabilities
model), and the pipeline short-circuits on a request-phase reject — so an
exchange refused by a gate is invisible to lineage. Denials after
OnRequestare captured (lineage.outcome=denied+lineage.denied_by).Moving lineage ahead of the gates is a named follow-up, not current
behaviour. Documented in the package doc; it matters to anyone who would
reach for these spans as an audit trail.
lineage.principal.subandlineage.principal.clientare emitted only oninbound request spans and only from a validated JWT — the plugin reads
pctx.Identity, which is nil unless a gate plugin verified a token(
plugin.go:530-539). An entry call that arrives without one thereforecarries no principal fact at all. That is deliberate: the alternative is
inferring a caller from a network address, which is a guess, and this
producer does not guess. The consequence is that the first hop of a trace is
typically anonymous.
plugin.go:268carries anexplicit
>>> OPTION-4 DELETION POINT <<<: deleting theselectParentandrestampTracestatecalls (and theparent.sourcefact) yields a sidecarthat parents on the wire context alone and writes no header at all. We have
not built that variant; the marker is there so the choice stays visible.
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com