Skip to content

Observability

Instrument Adonia with structured logs, metrics, traces, and request diagnostics.

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.js

Every 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close