Traced interfaces
Who this is for: anyone who wants a span around every call into a dependency without
instrumenting the dependency, and anyone deciding whether servo.Trace fits their graph.
A servo.Trace[I]() marker makes servo generate emit an OpenTelemetry
wrapper for interface I and hand that wrapper to every consumer of I, in place of whatever I
would otherwise resolve to. The wrapper is plain Go in the injector package: a type implementing
I whose every method with a leading context.Context runs inside a span, passes the span’s
context on to the implementation, and records a non-nil error on the span. Nothing in the
runtime is involved — the servo package has no OpenTelemetry dependency, and neither does the
generator’s module. The generated file imports go.opentelemetry.io/otel/trace and
go.opentelemetry.io/otel/codes in your module, exactly as a config file’s decoder is yours.
servo.Build(
servo.Root[*api.Server](),
servo.Trace[repository.OrderRepository](servo.Expected(domain.ErrNotFound)),
servo.Trace[cache.OrderCache](servo.Expected(cache.ErrMiss)),
servo.Trace[broker.EventPublisher](),
)
With a request span opened by a tracing middleware — servo.Use on an emitted server,
or your own handler chain — every traced dependency the request touches becomes a child span, with
no instrumentation written by hand and nothing a refactor can forget to update: the wrapper is
regenerated from the interface’s current method set every time.
What can be traced
A named interface, with at least one method. A consumer holding a concrete type has no seam to
wrap — the wrapper would be a different type, and the consumer’s parameter says which type it
holds — so servo.Trace[*postgres.DB]() is a declaration-time error pointing at the interface the
consumer should depend on instead. This is the one rule that gives the feature its shape, and it
is the same advice the rest of servo gives: depend on the interface where more than one
implementation could stand behind it.
Every method must be one the injector package can implement. An unexported method declared in
another package cannot be named from the generated file, and neither can an unexported type an
exported method’s signature mentions — Get(ctx, opts ...option) is fine for a consumer and
impossible for a wrapper that has to spell the signature out — so an interface carrying either is
refused unless it is declared in the injector’s own package.
Not the OpenTelemetry API itself, which is what every wrapper is built from. And not a scope accessor or a scope’s key type: generated code and extracted values respectively, with no provider behind them to wrap.
What the wrapper does
Everything about the wrapper follows from the interface’s method set, so it is worth knowing the rules rather than reading the file each time:
| A method whose… | Gets |
|---|---|
first parameter is context.Context |
A span named <pkg>.<Iface>/<Method>, started from the incoming context; the call receives the span’s context |
last result is error, and the call returns a non-nil one |
span.RecordError(err) and span.SetStatus(codes.Error, err.Error()) — unless the error is expected |
| first parameter is anything else, or there are no parameters | A plain delegation — no span, no context, nothing recorded |
A span ends when the method returns, panics included. The instrumentation scope passed to
TracerProvider.Tracer is the interface’s full import path, so two packages with one short name
stay distinguishable in a backend even though their span names both read store.Store/Get.
The wrapper has no lifecycle of its own. The implementation behind it keeps its Init, Run,
Stop and the rest, called by servo exactly as before, because the wrapper is a node without
capabilities by construction — not by structural detection, which an interface
that happened to declare an Init method would otherwise trip. Nothing is initialised twice and
nothing is stopped through a span.
Here is the whole of one generated wrapper, for an interface with a traced method and an untraced one:
type tracedStore struct {
inner store.Store
tracer trace.Tracer
}
func newTracedStore(inner store.Store, tp trace.TracerProvider) *tracedStore {
return &tracedStore{inner: inner, tracer: tp.Tracer("example.com/app/store")}
}
func (t *tracedStore) Get(ctx context.Context, key string) (string, error) {
ctx, span := t.tracer.Start(ctx, "store.Store/Get")
defer span.End()
r0, err := t.inner.Get(ctx, key)
if err != nil && !errors.Is(err, store.ErrMissing) {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return r0, err
}
func (t *tracedStore) Close() error {
return t.inner.Close()
}
Parameter names are the interface’s own where they are usable, so the wrapper reads like the
interface it implements; a blank, missing, or clashing name becomes a positional p1, p2. Only
the stable OpenTelemetry API is used — TracerProvider, Tracer.Start, Span.End,
Span.RecordError, Span.SetStatus, codes.Error — and nothing from a versioned semconv
package, so the generated file has no version to chase.
Expected errors
Go spells a cache miss, a not-found and an end of stream as errors. A span marked failed for every
one of them makes any error rate computed from traces meaningless, so servo.Trace takes an
option naming the errors that are results rather than failures:
servo.Trace[cache.OrderCache](servo.Expected(cache.ErrMiss), servo.Expected(cache.ErrStale)),
The argument must name a package-level variable or constant of an error type — exported, or
declared in the injector’s own package. It is read as syntax, exactly as servo.Include reads a
function’s name and never calls it: the generated check is errors.Is(err, cache.ErrMiss), so
the name is what servo needs and the value is never evaluated. A call, a literal, a local, a
non-error, or an unexported sentinel from another package is refused at the declaration. An error
that matches an Expected one ends its span cleanly, with nothing recorded.
There is no option for wrapping without error status at all, and no filter by error type. If a method’s errors are routinely not failures, the method’s signature is the place to say so.
The tracer provider
Every wrapper takes go.opentelemetry.io/otel/trace.TracerProvider as an ordinary graph
dependency, resolved like any constructor parameter — and resolved among your own module’s
constructors only. The OpenTelemetry packages return that interface from several functions of
their own (the global getter, the no-op provider), none of which is the one you meant, so for this
one key the usual exact-type step ignores candidates from other modules. There is no fallback to
otel.GetTracerProvider(): a global is what makes two apps in one process collide, and an
“initialise tracing before anything else” ordering assertion is exactly the kind of claim servo
derives from dependencies rather than trusts a comment for.
Write a constructor whose result implements the interface. The SDK’s *sdktrace.TracerProvider
does; so does a type of your own that embeds it, which is the shape that also gives servo a Stop
to flush and shut the exporter down at the right point in the teardown:
type Tracer struct{ *sdktrace.TracerProvider }
func NewTracer(cfg Config) (*Tracer, error) {
// exporter, resource, sampler...
return &Tracer{TracerProvider: sdktrace.NewTracerProvider(opts...)}, nil
}
func (t *Tracer) Stop(ctx context.Context) error { return t.Shutdown(ctx) }
One such type is the sole implementation and auto-binds. Two are an ambiguity naming the
servo.Bind[trace.TracerProvider, …]() to add, and an Override can swap in
an in-memory provider for the test variant. A module in which nothing imports the trace API at all
gets a diagnostic naming that import rather than a wrapper written against a package the build
cannot see.
Where the wrapper sits
The wrapper is a node in the graph: constructed in New after the implementation and the tracer
provider, before its consumers, at one level above the deeper of its two dependencies. It appears
everywhere a node does — servo explain, servo why, servo graph, the generated header and
App.Graph() — keyed as the interface’s type string with a #traced
suffix, with binding traced, and with Wraps naming
the node it decorates:
$ servo explain repository.OrderRepository
example.com/app/internal/repository.OrderRepository#traced
provider: generated OpenTelemetry wrapper around *example.com/app/internal/repository/postgres.Store (cmd/api/spec.go:21:3)
binding: traced
lifetime: singleton — one per process, built by New
level: 3
depends on: *example.com/app/internal/repository/postgres.Store, *example.com/app/internal/observability.Tracer
depended on: *example.com/app/internal/service.OrderService
capabilities: none
Three consequences of it being a real node:
It wraps whatever the interface resolves to. An explicit Bind, a structural match, a supplied
Value, or — in the test variant — an Override: the interface resolves exactly
as it would untraced, and the wrapper sits around the answer. NewTestApp traces the mock through
a wrapper of its own, so the generated code is exercised by the same tests that exercise the
mock, and an Override[trace.TracerProvider, …] lets those tests capture the spans.
A consumer of the concrete type bypasses it. A constructor taking *postgres.Store directly
gets the store, not the wrapper — the same node the wrapper holds. Tracing is a property of the
interface edge, and servo explain on the concrete type shows which consumers take which.
Two traced interfaces behind one implementation are two wrappers around one node, each with
its own span names. repository.OrderRepository and repository.UserRepository both bound to
*postgres.Store is the ordinary case.
A scoped member taking a traced singleton interface borrows the wrapper the way it borrows the logger behind it. What is not supported is tracing an interface whose implementation is itself scoped: a per-key wrapper would have to be built and torn down inside the scope’s entry, which is well-defined and not yet generated, so it is a diagnostic rather than a silent singleton. See Limitations.
Diagnostics
| Message | Cause | Fix |
|---|---|---|
servo.Trace's type argument must be an interface, not *postgres.DB |
A concrete type | Make the consumer depend on the interface, then trace that |
servo.Trace's type argument must be a named interface |
An interface literal | Name it; the span names come from the name |
servo.Trace[I]: the interface declares no methods |
any, or an empty interface |
Nothing to wrap |
servo.Trace[I]: method m is unexported, and the wrapper is generated into package main |
An unexported method from another package | Export it, or declare the interface beside the spec |
servo.Trace[I]: method M's signature uses store.option, an unexported type the wrapper … cannot name |
An exported method mentioning an unexported type from another package | Export the type, or declare the interface beside the spec |
servo.Trace[I]: the OpenTelemetry trace API is what every wrapper is built from |
Tracing trace.TracerProvider or trace.Tracer |
Trace your own interfaces |
servo.Trace[I] declared twice |
Two local declarations for one interface | Keep one; a local one may still supersede an included one |
servo.X is not a servo.Trace option |
Anything but Expected inside the call |
The only option is servo.Expected(err) |
servo.Expected belongs inside servo.Trace[I](...) |
Expected at Build’s top level |
Move it into the Trace call |
servo.Expected's argument must name a package-level error variable or constant |
A literal, a call, a local, a field | Name the sentinel |
servo.Expected(X): X is a int, not an error |
A non-error at package level | Name an error |
servo.Expected(X): X is unexported, and the generated errors.Is check lives in package main |
An unexported sentinel from another package | Export it |
servo.Expected(X) declared twice in the same servo.Trace |
The same sentinel named twice | Once is enough |
servo.Trace[I]() is declared, but nothing in the graph depends on I |
No consumer requests the interface in the production graph; the test variant, where an Override may have removed the consumer, simply emits no wrapper |
Take it as a constructor parameter the roots reach, or delete the declaration |
servo.Trace[I] needs go.opentelemetry.io/otel/trace, which nothing in this module's build imports |
No package in the build imports the trace API | Write the tracer provider’s constructor; its import brings the package in |
no provider for go.opentelemetry.io/otel/trace.TracerProvider |
No constructor in your module produces one | Write it, as above |
servo.Trace[I]: I is a scope accessor interface |
Tracing a Scoped declaration’s accessor |
Trace the interfaces the scoped type depends on |
servo.Trace[I]: I resolves to *chat.Room, which is scoped |
The implementation is a scope member | Not supported in this release; leave it untraced |
The resolution ones carry the same “needed by” chain every other resolution diagnostic does, with
the wrapper reading as the servo.Trace wrapper for I at the declaration’s position.
What servo.Trace does not do
No metrics: a per-method duration histogram and error counter would take a metric.MeterProvider
through the same wrapper, and is a separate marker if it comes, because the two signals have
different providers and are opted into separately. No logs. No attributes beyond the span name
and the recorded error — in particular no argument values, which could be anything from a
password to a megabyte. No tracing of concrete-typed edges, by design, and no wildcard: each
traced interface is a line in the spec, shared across injectors through
servo.Include when the list gets long.