Everything below lives in
packages/core/src/telemetry/spans.ts and is re-exported from @adonia/core.
OpenTelemetry is a soft dependency
@opentelemetry/api is not a dependency of @adonia/core — not a runtime dep, not a peer
dep. AdoniaProvider.boot() resolves it once through a guarded dynamic import and installs the
resulting tracer. When the package is absent, or when telemetry.spans is false, every wrapper
is a direct call to its callback: no span object, no attribute record, no promise wrapper.
// config/adonia.ts
import { defineConfig } from '@adonia/core'
export default defineConfig({
telemetry: { spans: true }, // default
})Resolution happens exactly once per process, at boot, so the synchronous hot paths (descriptor compilation, pipeline stages) read a settled tracer instead of racing an import. Adonia only checks that the API is installed; if no SDK is registered, OTel’s own no-op tracer is used.
Spans
| Span | Attributes | Emitted by |
|---|---|---|
adonia.pipeline.<stage> |
— | each query-mutating stage of the index pipeline (§8.3) |
adonia.descriptor.compile |
resource, mode, cacheHit |
DescriptorCompiler.compile / compileIndex |
adonia.action.run |
action, targetCount, queued |
actions (M2 — helper exported, no caller yet) |
adonia.widget.data |
widget, cacheHit |
widgets (M2 — helper exported, no caller yet) |
<stage> is one of PIPELINE_STAGES: base, softDeleteScope, search, filters, sort,
eagerLoad. resolve/authorize/paginate/serialize are controller-side, not runner stages.
cacheHit on adonia.descriptor.compile is false until §9.4 descriptor memoization lands; the
attribute name is stable, and a hit flips it via span.setAttribute('cacheHit', true) from inside
the compile callback.
A callback that throws marks its span errored (SpanStatusCode.ERROR, exception recorded) and the
error is re-thrown unchanged. Async callbacks keep the span open until the promise settles.
Wiring the pipeline
The pipeline runner never imports telemetry. It calls the optional PipelineContext.onStage
around-hook once per stage function, and pipelineSpan is that hook:
import { pipelineSpan } from '@adonia/core'
const context: PipelineContext = { ...base, onStage: pipelineSpan }pipelineSpan<T>(stage, run) => T is assignable to the runner’s StageObserver. Wiring it
unconditionally is free — with telemetry off it is literally return run(). A host that wants
different instrumentation (timings, logs) supplies its own observer instead.
Custom spans
import { withSpan } from '@adonia/core'
withSpan('adonia.myplugin.sync', () => ({ resource: 'posts' }), (span) => {
span.setAttribute('rows', 42)
return doWork()
})Pass the attribute bag as a thunk on hot paths: it is only invoked when a tracer is recording.
Logging
Adonia log lines carry module: 'adonia':
import app from '@adonisjs/core/services/app'
import { adoniaLogger } from '@adonia/core'
const logger = adoniaLogger(await app.container.make('logger'))
logger.warn({ resource: 'posts' }, 'unscoped resource in a tenant panel')adoniaLogger accepts anything with a pino-shaped child(bindings) and returns the same logger
type, so host log-level config and transports apply unchanged.
Seeing spans locally
The example app (examples/blog-admin) ships telemetry: { spans: true } and a committed
bootstrap, examples/blog-admin/tracing.ts. Preload it:
cd examples/blog-admin && node --import ./tracing.js bin/server.jsEvery panel request then prints its adonia.descriptor.compile and adonia.pipeline.<stage>
spans through ConsoleSpanExporter.
The bootstrap is twelve lines, and two of them are not obvious:
// examples/blog-admin/tracing.ts (abridged)
const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })
provider.register()
setTracer(trace.getTracer('@adonia/core'))Hand Adonia the tracer explicitly. Auto-detection (resolveTracer) runs
import('@opentelemetry/api') from inside @adonia/core, so under pnpm’s isolated
node_modules — or any layout where the package is not on the library’s own resolution path —
it finds nothing and every wrapper stays a pass-through. setTracer is the supported way in, and
the boot-time probe never clobbers a tracer that is already installed, so preload order does not
matter for correctness.
Preloading still matters for coverage. AdoniaProvider.boot() compiles nothing yet, but spans
opened during boot are lost if no provider is registered by then.
Testing instrumentation
setTracer(fake) injects any object with a startActiveSpan(name, options, fn) method;
resetTelemetry() clears the enabled flag, the tracer, and the cached import between tests. See
packages/core/tests/telemetry.spec.ts for the facade in isolation, and
examples/blog-admin/tests/functional/adonia_telemetry.spec.ts for the end-to-end gate: it boots
the same startTracing() against an InMemorySpanExporter, drives a real panel request, and
asserts the §20 span names and attributes actually arrive at an SDK exporter (F2-2’s acceptance).
Boot-time warnings
§20’s other half — “adonia:doctor aggregates the boot-time warnings (unscoped resources,
allow-fallback in prod, missing queue binding, cookie/domain mismatch)” — is documented in
docs/doctor.md. The doctor reports those statically, without booting the app;
the queue-binding warning belongs to the actions runtime (M2) and is not a v1 check.