Skip to content

Logging and telemetry

Collect safe structured logs, bootstrap a host-owned OpenTelemetry SDK, and choose the separate Workers tracing path.

Logging and telemetry are independent optional integrations. @lenso/log emits structured Pino logs; @lenso/otel exposes the official OpenTelemetry API. Core, Engine and Tasks can record API spans without initializing an SDK. Without host bootstrap, those API calls are no-ops.

This page documents the verified published @lenso/log@0.2.0 and @lenso/otel@0.2.0, audited against Log and OTel at 549b987. Install the exact package and optional protocol peers needed by the chosen entry. There is no built-in Observe query CLI, telemetry store or collector in this baseline.

Collect stderr logs once

Pass a logger from the host to startApp; plugin contexts receive scoped logging without depending on Pino:

import { definePlugin, startApp } from "@lenso/core";import { createLogger } from "@lenso/log";
const greeting = definePlugin({  id: "greeting",  setup(context) {    return {      async greet(name: string) {        context.logger?.info({ operation: "greet" }, "Greeting requested");        return `Hello, ${name}`;      },    };  },});const app = await startApp({  plugins: [greeting],  logger: createLogger({ level: "info" }),});try {  await app.get(greeting).greet("Ada");} finally {  await app.stop();}

Default JSON goes to stderr. Keep stdout for finite CLI envelopes and MCP protocol frames. Configure the process supervisor to collect stderr once. @lenso/log does not register an SDK or export OTel logs; adding a separate log exporter can duplicate records.

pretty: true provides synchronous readable development output. createLogger({ logger: existingPino }) enriches a borrowed logger without closing it; the caller retains transports and lifetime. Do not assume a supplied logger inherits the default redaction configuration.

Initialize telemetry before application imports

The Bun host owns one SDK bootstrap. Put it in an entry or preload, before CLI/config/business imports, then drain application work before shutdown:

import { bootstrapTelemetry } from "@lenso/otel/bun";import { createORPCInstrumentation } from "@lenso/otel/orpc";
const telemetry = await bootstrapTelemetry({  serviceName: "notes",  instrumentations: [createORPCInstrumentation()],});try {  const { main } = await import("./server.ts");  // The application provides main(): it resolves after listener/workers drain.  await main();} finally {  await telemetry.shutdown();}

The main lifecycle above is an application contract, not an export promised by the Notes example. Do not bootstrap in plugin setup, a config module, or each request. The host supplies an authorized OTLP destination/exporter and its credentials through trusted deployment configuration. Importing Lenso alone installs neither a collector nor automatic Node instrumentation or exception hooks.

traceExporter, metricExporter, sampler, contextManager, propagator and instrumentations accept official interfaces. Supplied exporters/context managers are borrowed by default; takeOwnership: true explicitly transfers lifetime. Owned defaults dispose once, including failure paths. mode: "external" leaves global providers/context untouched and only manages explicitly supplied instrumentation registrations; the external SDK owner flushes and shuts down.

forceFlush() and shutdown() are bounded and reject on timeout/export failure. A timeout does not stop arbitrary custom exporter code; actual cleanup can continue, with ownership reserved until it settles. An export failure is telemetry evidence, not proof the business operation failed.

Finite CLI commands

The source greeting already contains a finite-command preload with flushOnCliExit: true. After building framework packages and configuring an authorized local OTLP receiver:

# From the reviewed framework checkout; endpoint credentials stay in the environment.bun --preload ./examples/greeting/src/telemetry.ts packages/cli/dist/bin.js \  call greeting greet '{"name":"Ada"}' --root examples/greeting --json

CLI ends its span and performs bounded final flush/shutdown in finally. Export failure prints a fixed stderr diagnostic and preserves the business exit code. External SDK mode retains external ownership. Do not reuse a finite-command preload as a lifecycle solution for dev children or a long-running server.

Choose one HTTP propagation owner

Optional /orpc requires @orpc/opentelemetry@2.0.0-beta.42 for this source baseline. The corresponding Web and clients use the same oRPC beta; verify installed compatibility before combining artifacts.

If Web's telemetry.requestLifetime or another Fetch/HTTP instrumentation owns propagation, use createORPCInstrumentation({ propagationEnabled: false }). Otherwise oRPC instrumentation propagates by default. Its HTTP/procedure spans and Web's optional INTERNAL body/work/cleanup span describe different lifetimes. Avoid two HTTP propagation owners.

Official oRPC exception instrumentation can capture business exception messages. Keep credentials, bodies and signed URLs out of those messages as well as ordinary logs.

Workers uses platform tracing

The browser-safe @lenso/otel/workers entry requires the optional @orpc/cloudflare@2.0.0-beta.42 peer and enabled Wrangler traces. The existing Worker entry calls bootstrapWorkerTracing() once; its configuration enables observability.traces.enabled.

Bun SDK routeNative Workers route
Explicit /bun bootstrapExplicit /workers bootstrap
Host owns flush/shutdownPlatform owns request spans and export
OTel API context supplies trace correlationNative tracing does not bridge Lenso OTel API spans/context
Optional oRPC OTel instrumentationOfficial Workers Traces and oRPC Cloudflare tracer
Bun Pino logging entryPlatform logs or an application-supplied structural logger

Do not combine the two oRPC tracer owners or shut down a provider per Worker request. Native Workers tracing currently supplies no custom OTLP export, no configurable oRPC propagation and no yielded/enqueued stream events. It does not automatically activate Core/Tasks API spans or Pino trace enrichment. Isolate termination does not guarantee app cleanup.

Correlate without leaking data

Useful log/trace fields include instance/plugin/operation, jobId, attempt, traceId and spanId. A durable task attempt starts a fresh root linked to the original producer; it need not share the producer trace ID. Only bounded traceparent/tracestate are stored separately from task payload. Neither trace context nor job ID authorizes a caller. Keep high-cardinality IDs out of metric labels.

Default Log redaction covers common structured secrets, body/payload keys and Error text/stacks. It is not comprehensive secret discovery: free-form messages, unusual nested keys and custom serializers need application policy. Log fixed events and safe codes, not full configs, payloads or unknown exceptions.

Diagnose missing evidence

SymptomCheck
Logs exist, no tracesSDK bootstrap, import order, sampling and actual exporter/receiver
CLI loses final spansFinite-command preload and flushOnCliExit; distinguish export failure from command failure
Duplicate HTTP spans/recordsPropagation owner and duplicate stderr/OTel collection
No trace IDs in Workers logsNative route does not bridge OTel API context; use platform correlation
Old events cannot be queriedFind the configured storage/query backend; an OTLP endpoint alone is not a query service

Use retained stderr or the authorized backend when available. Missing collection means missing historical evidence, not an invitation to guess an Observe command. Bootstrap/ownership tests, CLI export tests, and Web lifetime tests establish the respective boundaries.