OpenTelemetry and observability

Beignet separates local inspection from production telemetry:

The OpenTelemetry provider is optional. Without it, Beignet keeps request IDs and W3C trace correlation for logs and devtools without adding an OpenTelemetry runtime dependency.

Install

Start with the app from Quickstart. Add the tracing provider and an SDK for your deployment:

bun add @beignet/provider-tracing-opentelemetry @opentelemetry/api

Install and register the SDK appropriate for the deployment. For a Next.js app on Vercel:

bun add @vercel/otel
// lib/telemetry.ts
import { registerOTel } from "@vercel/otel";

const state = globalThis as typeof globalThis & {
  __appTelemetryRegistered?: boolean;
};

export function registerTelemetry() {
  if (state.__appTelemetryRegistered) return;
  registerOTel({ serviceName: "my-app" });
  state.__appTelemetryRegistered = true;
}
// instrumentation.ts
import { registerTelemetry } from "@/lib/telemetry";

export function register() {
  registerTelemetry();
}

The root instrumentation.ts bootstraps the Next.js process. Standalone job workers, task and schedule commands, outbox drains, and scripts must call the same idempotent registerTelemetry() function before they initialize their Beignet server. Installing the Beignet provider without registering an SDK is safe, but OpenTelemetry's global tracer and meter remain no-ops.

Beignet does not start an SDK, choose an exporter, or create a process shutdown hook.

Register the provider

Declare tracing as an app port and defer its implementation to the provider:

--- a/ports/index.ts
+++ b/ports/index.ts
@@ -1,1 +1,2 @@
+import type { TracingPort } from "@beignet/core/tracing";
 import type { ErrorReporterPort } from "@beignet/core/error-reporting";
@@ -1,1 +1,2 @@
 export type AppPorts = {
+  tracing: TracingPort;
--- a/infra/port-wiring.ts
+++ b/infra/port-wiring.ts
@@ -1,1 +1,2 @@
   deferred: [
+    "tracing",

Add tracing after devtools in the existing server/providers.ts. This patch preserves authentication, logging, and database setup and registers the SDK for standalone processes that import this file:

--- a/server/providers.ts
+++ b/server/providers.ts
@@ -1,2 +1,4 @@
 import "@beignet/core/server-only";
+import { createOpenTelemetryTracingProvider } from "@beignet/provider-tracing-opentelemetry";
+import { registerTelemetry } from "@/lib/telemetry";
 import { createDevtoolsProvider } from "@beignet/devtools";
@@ -1,1 +1,3 @@
+registerTelemetry();
+
 const drizzleSqliteProvider = createDrizzleSqliteProvider({
@@ -1,2 +1,3 @@
 export const providers = [
   createDevtoolsProvider(),
+  createOpenTelemetryTracingProvider(),

The database variable in this patch is from the SQLite starter. Keep your own database provider when using Postgres or MySQL; only the tracing entry and SDK registration are new.

The provider contributes ports.tracing and replaces ports.instrumentation with a composed sink. OpenTelemetry receives later provider events while the earlier devtools sink continues to receive its enabled watchers.

Devtools is optional. In production, the provider can be installed without a devtools provider.

Verify tracing

bun run typecheck
bun beignet doctor --strict
bun run dev

Sign in and create a Todo. The app should behave as before. With your exporter configured, expect a beignet.request span and a child beignet.use_case span in your tracing backend. A successful request alone does not establish export: if spans are missing, check SDK registration and the exporter endpoint and credentials. The Beignet provider does not choose a telemetry backend for you.

Active spans

When ports.tracing is installed, Beignet wraps these execution boundaries:

BoundarySpan nameKind
HTTP requestbeignet.request <contract>server or internal
Use casebeignet.use_case <name>internal
Listenerbeignet.listener <name>consumer
Job handlerbeignet.job <name>consumer
Outbox deliverybeignet.outbox deliver <name>consumer
Schedule handlerbeignet.schedule <name>consumer
Task handlerbeignet.task <name>internal

The current span remains active across asynchronous handler work. Provider SDKs that use the OpenTelemetry API can therefore create children of the correct request, use case, listener, or workflow span without importing a Beignet-specific global.

Incoming traceparent and tracestate headers continue an HTTP trace. If a host adapter already established an active OpenTelemetry request span, Beignet's request span becomes its child instead of creating a competing root.

Metrics

The provider records:

Durations use milliseconds. Attributes are limited to stable operation names, types, outcomes, attempts, and provider names. Beignet does not attach request bodies, payloads, tenant IDs, user IDs, message IDs, error messages, or stacks to these metrics.

Custom TraceOperation values may attach rich span-only attributes. Add only bounded dimensions to metricAttributes; never copy request, actor, tenant, or payload values into metric labels.

An SDK that only configures tracing may leave the global OpenTelemetry meter as a no-op. Register a meter provider to export metrics, inject a configured meter, or set meter: false when the app intentionally exports traces only.

Errors and Sentry

Failed spans set error status and error.type. Exception messages and stacks remain out of spans by default. Enable recordExceptions only after reviewing the exporter and backend redaction policy:

createOpenTelemetryTracingProvider({
  recordExceptions: true,
});

Keep exception capture in ErrorReporterPort. When Sentry and another OpenTelemetry SDK are both installed, prevent Sentry from registering a second pipeline:

createSentryErrorReportingProvider({
  init: { skipOpenTelemetrySetup: true },
});

Beignet does not export OpenTelemetry logs. Continue using LoggerPort for structured runtime logs and AuditLogPort for durable business activity.

Durable propagation

Beignet carries a versioned, vendor-neutral TraceCarrier through its durable and distributed boundaries:

Existing outbox rows, Redis messages, and provider jobs without a carrier keep working and start a new trace. Unknown carrier versions, malformed traceparent, and invalid tracestate values are ignored rather than failing message delivery. Payloads are never added to trace metadata.

Direct adapters capture the active context when their instrumentation or tracing option exposes ports.tracing. The optional third argument on event publish and job dispatch also lets delivery layers forward a carrier without a global runtime:

await ctx.ports.jobs.dispatch(job, payload, { trace });
await ctx.ports.eventBus.publish(event, payload, { trace });

Beignet does not propagate OpenTelemetry baggage. Stable job, event, message, and request IDs remain useful application-level correlation fields.

Serverless behavior

The Beignet provider performs no network setup and starts no workers, timers, or background loops. It creates spans and records metrics through the SDK that the app already registered. The host integration or app-owned SDK remains responsible for batching, exporting, and flushing telemetry within the platform's lifecycle. Register the SDK once in every process type; Next.js does not invoke its root instrumentation.ts for standalone workers or CLI commands.

See Request lifecycle for HTTP correlation, Devtools for the local event timeline, Logging for diagnostic logs, and Error reporting for exception capture.

API reference