---
title: "Observability"
description: "Instrument Adonia with structured logs, metrics, traces, and request diagnostics."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Observability

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.

```ts
// 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:

<!-- @sample-preamble
import type { PipelineContext } from '@adonia/core'
declare const base: PipelineContext
-->

```ts
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

<!-- @sample-preamble
declare function doWork(): { synced: number }
-->

```ts
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'`:

```ts
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`](https://github.com/rikoriswandha/adonia/blob/main/examples/blog-admin/tracing.ts). Preload it:

```sh
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:

```ts no-check
// 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`](/doctor). 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.

Source: https://adonia.pages.dev/observability/index.mdx
