ADR-0020: The telemetry contract needs no extra¶
Status: Accepted Date: 2026-10-13 Deciders: Alex Nodeland Amends: ADR-0018: where the OpenTelemetry contract lives in lattice-core, and the Langfuse client, which stays in each library
Context¶
ADR-0018's third step moved artifactr's and reflexr's OpenTelemetry setup into lattice_core.otel, behind the otel extra. With it went the contract between a library and configure_telemetry: Contribution, the TelemetryContribution port, MetricsDetail and Instrumented (artifactr ADR-0046). Behind the extra, the contract could not reach the code that needs it most:
- each library's
telemetrylayer records through OpenTelemetry's API alone, never the SDK, and defined its ownMetricsDetail; - relayr's contribution copied
ContributionandMetricsDetail, "as the libraries'MetricsDetailsays".
Nothing of lattice-core is published yet, so where the contract lives can still change without a deprecation.
ADR-0018 also listed the Langfuse client among the code copied word for word. It only looks alike: each library's langfuse_client defaults its span filter to that library's own should_export_span.
Decision¶
- The contract is
lattice_core.otel.contribution, which needs no extra. It names the SDK'sViewandReadableSpanonly in its annotations, underTYPE_CHECKING, so it imports with nothing but lattice-core's base installed.lattice_core.otel.configure, theotelextra, imports the contract from it, and nothing else defines its names. - Its users take it from there. artifactr's and reflexr's
telemetrylayers takeMetricsDetailfrom it; theirotelmodules re-exportContribution,TelemetryContributionandInstrumented. relayr's contribution is a lattice-coreContribution, andrelayr.otelre-exports it withMetricsDetail; relayr'sotelextra requires lattice-core. - The
otel/subpackage holds both modules, the contract and the setup, so every OpenTelemetry name in lattice-core is under one path. The layering test lists the modules inside an extra's subpackage that need no extra, and finds every other such module itself. - The setup's optional imports stay lazy.
lattice_core.otel.configureimports FastAPI, SQLAlchemy, Langfuse andlattice_core.langfuseonly inside a function or underTYPE_CHECKING, and the layering test checks it. Each extra's modules import in a fresh interpreter with the other extras' packages missing. langfuse_clientstays in each library, since its default filter is that library's. lattice-core holds the filters it composes,span_filterandno_spans, andconfigure_telemetry(langfuse=...)builds its own client from them.
Options considered¶
| Option | For | Against |
|---|---|---|
| A module of its own, needing no extra (chosen) | The telemetry layers and relayr name the contract without the SDK; one definition | One more module |
Keep it in lattice_core.otel.configure |
No change | The telemetry layers can't import it, so MetricsDetail stays defined three times, and relayr's Contribution twice |
A top-level lattice_core.telemetry |
A shorter path | lattice-core's OpenTelemetry names in two places |
| The contract importing the SDK at run time | No TYPE_CHECKING |
The libraries' base installs would need the SDK, which their ports keep out (artifactr ADR-0034) |
Consequences¶
- Easier: a contribution's shape is defined once, and a library's telemetry layer, relayr and an application all build on it.
- Easier: the layering test holds
configure's optional imports lazy, so an application without FastAPI or SQLAlchemy can still configure telemetry. - Harder:
MetricsDetailin artifactr and reflexr is now lattice-core's, re-exported; reflexr's was atypestatement, and is now a plain alias of the same three values.