OpenTelemetry services
View MarkdownStatus: Stable Package:
dev.vertique.opentelemetry.servicesArtifact:vertique-opentelemetry-servicesDepends on: opentelemetry-api (library), vertique-services API reference: javadoc.io
Observe-only adapter that enriches the active OpenTelemetry CONSUMER span on the Vert.x event bus with service dispatch attributes. The module uses the OpenTelemetry API only — no SDK in compile scope — so all span operations are guaranteed no-ops when no SDK is present. No configuration gate is provided by design: enrichment is always attempted and silently becomes a no-op when no recording span is active.
The module has no compile dependency on vertique-opentelemetry-core types. It never modifies the
dispatch outcome, and it never submits audit records.
When To Use It
Section titled “When To Use It”Install vertique-opentelemetry-services alongside DispatchModule when distributed tracing is
enabled and service dispatch spans should carry service target and one-way attributes. Pair with
vertique-opentelemetry-core to activate the Vert.x OTel tracer integration and the SDK bootstrap.
Without a recorded parent span (Vert.x DeliveryOptions.PROPAGATE default), no event-bus spans are
created and the enrichment interceptor is a silent no-op on every dispatch.
Core Concepts
Section titled “Core Concepts”Consumer-side enrichment only. No sender-side seam exists in the event bus dispatch pipeline.
The Vert.x OTel tracing integration creates a CONSUMER span on the receiving side when a parent
trace is propagated with the message (propagation mode: PROPAGATE, which is the default). The
interceptor enriches that CONSUMER span — not the sender-side span — because ServiceInterceptor
callbacks run within the consumer’s message handler, where the span is current.
Producer enrichment is descoped (SP-7). A sender-side seam would require intercepting the
EventBus.send/publish call before the message leaves the event loop. No such seam exists in the
current ServiceInterceptor SPI; onDispatch fires on the consumer side once the message is
received. Producer-side span enrichment is therefore not supported and is not emitted.
Spans exist only under a traced parent. Vert.x’s default PROPAGATE tracing policy means the
framework creates event-bus spans only when a parent trace is in scope at dispatch time. A dispatch
with no parent span produces zero event-bus spans. The interceptor’s Span.isRecording() guard
ensures it is a silent no-op in that case.
stableTargetId omission — never UNKNOWN on spans. The vertique.service.target attribute
is written only when ServiceDispatchContext.stableTargetId() is non-null and non-blank. When the
target id is absent, the attribute is omitted entirely. Writing a sentinel like "UNKNOWN" on span
attributes creates unbounded cardinality risk in trace backends and is never done.
Terminal-outcome recording is best-effort. ServiceInterceptor.onTerminalComplete fires after
the reply is sent to the caller. By that point the Vert.x OTel tracer may have already ended the
CONSUMER span. Writes to an ended span are safe no-ops per the OpenTelemetry API contract; the
status recording is best-effort and may be silently dropped.
Key Classes
Section titled “Key Classes”ServiceDispatchSpanEnrichmentInterceptor
Section titled “ServiceDispatchSpanEnrichmentInterceptor”@Singleton ServiceInterceptor. Enriches the active CONSUMER span with service dispatch
attributes on onDispatch, and records the terminal outcome on onTerminalComplete.
onDispatch(ServiceDispatchContext ctx)
Called synchronously when the service method is about to be invoked. Sets:
ServiceAttributes.SERVICE_ONEWAY("vertique.service.oneway",boolean) — always written when a recording span is current;truefor@OneWaydispatches,falsefor request/reply.ServiceAttributes.SERVICE_TARGET("vertique.service.target",string) — set toctx.stableTargetId()only when the value is non-null and non-blank. Omitted entirely whenstableTargetId()returnsnull.
When no recording span is current (no parent trace or PROPAGATE policy with no parent), both writes
are skipped. Exceptions are swallowed — this observer cannot affect the dispatch outcome. A swallowed
exception is logged once at WARN with its class name only; the exception message and stack trace are
never logged because exporter and SDK exceptions can carry credentials.
onTerminalComplete(ServiceDispatchContext ctx, Result<?> result, Instant startTime, Instant endTime)
Called after the terminal dispatch outcome is reached (post-recovery). Records:
- On
result.isFailure(): sets span status toStatusCode.ERRORand setsErrorAttributes.ERROR_TYPE("error.type") toresult.cause().getClass().getSimpleName()when the cause is non-null. - On success: span status is left
StatusCode.UNSET; no writes.
Span.recordException is never called. Exception events are not emitted — only the status and
error.type attribute are written.
Writes to an already-ended span are safe no-ops per the OTel API contract (best-effort contract: the reply is sent before this hook fires, so the tracer may have already closed the span).
Exceptions are swallowed and logged at WARN as class name only, exactly as for onDispatch.
// Traced request dispatched from inside a parent span's scope.// The parent span's context is propagated by Vert.x through the event-bus message headers.// On the consumer side, the OTel tracer creates a CONSUMER child span.// ServiceDispatchSpanEnrichmentInterceptor enriches that child span.Span parentSpan = tracer.spanBuilder("my-operation").startSpan();try (Scope scope = parentSpan.makeCurrent()) { vertx.eventBus() .request(address, DispatchEnvelope.of(payload), ENVELOPE_CODEC) .onComplete(ar -> { /* ... */ });}// The CONSUMER span (on the receiving side) will carry:// vertique.service.target = "integration.user-service.get-user" (if stableTargetId non-null)// vertique.service.oneway = falseInvariants and Gotchas
Section titled “Invariants and Gotchas”- Both
onDispatchandonTerminalCompleteresolveSpan.current()independently. The span retrieved inonDispatchmay differ from the one inonTerminalCompleteif the OTel context has changed. Both are guarded onspan.getSpanContext().isValid() && span.isRecording(). - When
stableTargetId()isnull, thevertique.service.targetattribute is absent from the span. Backends that expect the attribute will not find it; this is intentional to avoid polluting span data with a meaningless sentinel. onTerminalCompletefires after the reply is already sent. The CONSUMER span may already be ended by the time this hook runs. The OTel API’s no-op contract for ended spans ensures this is safe but means the status recording is best-effort, not guaranteed.
Span Attributes
Section titled “Span Attributes”| Attribute | Key | Source | Notes |
|---|---|---|---|
vertique.service.target |
AttributeKey.stringKey("vertique.service.target") |
ServiceDispatchContext.stableTargetId() |
Omitted when stableTargetId() is null or blank |
vertique.service.oneway |
AttributeKey.booleanKey("vertique.service.oneway") |
ServiceDispatchContext.oneWay() |
Always written when a recording span is current |
error.type |
ErrorAttributes.ERROR_TYPE |
result.cause().getClass().getSimpleName() |
Set only on terminal failure with a non-null cause |
Wiring
Section titled “Wiring”OpenTelemetryServicesModule is a Dagger @Module that contributes one multibinding:
ServiceDispatchSpanEnrichmentInterceptorintoSet<ServiceInterceptor>
Install it alongside DispatchModule and OpenTelemetryModule:
@Singleton@Component(modules = { VertxModule.class, OpenTelemetryModule.class, // vertique-opentelemetry-core: SDK bootstrap DispatchModule.class, // vertique-services: event bus dispatch OpenTelemetryServicesModule.class, // vertique-opentelemetry-services: this module AppModule.class})interface AppComponent { ServiceVerticle serviceVerticle();}OpenTelemetryServicesModule has no @BindsOptionalOf declarations. It compiles and wires
correctly with or without OpenTelemetryModule on the Dagger graph — enrichment is always
attempted and silently becomes a no-op when no recording span is present.
The adapter’s simple SPI contribution is declared on its injectable implementation with
@RegisterIntoSet. During the provider build, vertique-codegen-dagger emits
GeneratedRegistrationsModule, which this module includes explicitly. The generated module contains
only this type adaptation; configuration, registry, and optional bindings remain hand-written.
Dependencies
Section titled “Dependencies”io.opentelemetry:opentelemetry-api—Span,SpanContext,StatusCode,AttributeKeyio.opentelemetry:opentelemetry-semconv—ErrorAttributesdev.vertique:vertique-services—ServiceInterceptor,ServiceDispatchContextdev.vertique:vertique-core—Result(viacore.eventbus)com.google.dagger:dagger,jakarta.inject:jakarta.inject-apiorg.slf4j:slf4j-api,org.projectlombok:lombok(provided)