This document covers how OpenTelemetry is set up in this repo, why certain decisions were made, and what's left to do.
For practical "how to add tracing" examples, see otel-guide.md.
┌─────────────────┐ traceparent header ┌─────────────────┐
│ Browser │ ─────────────────────────────▶ │ Hono Server │
│ (React) │ │ (Bun) │
│ │ │ │
│ web/ │ │ server/ │
│ instrumentation│ │ instrumentation│
│ .ts │ │ .ts │
└────────┬────────┘ └────────┬────────┘
│ │
│ POST /api/otel/v1/traces │ OTLP proto
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Trace Proxy │ ──────────────────────────────▶ │ Axiom │
│ (server.tsx) │ OTLP HTTP │ │
└─────────────────┘ └─────────────────┘
Service names: web (frontend) and server (backend)
In a standard Node.js + Express app, you'd just use @opentelemetry/instrumentation-http. This library monkey-patches Node's native http module. Since Express uses http internally, you get automatic tracing of incoming requests for free.
Bun doesn't use Node's http module. It has its own high-performance HTTP server (Bun.serve). This means:
@opentelemetry/instrumentation-httpdoes nothing for incoming requests- The standard Node.js auto-instrumentation doesn't create "entry spans" for your routes
We use @hono/otel which provides a middleware specifically designed for Hono:
import { httpInstrumentationMiddleware } from "@hono/otel";
app.use(httpInstrumentationMiddleware());This handles:
- Extracting
traceparentheader from incoming requests - Creating spans for HTTP requests
- Propagating context so child spans (DB queries, etc.) are linked
Library-level instrumentations work fine because they patch npm packages, not Node internals:
@opentelemetry/instrumentation-pg- PostgreSQL queries ✓@opentelemetry/instrumentation-redis- Redis commands ✓@opentelemetry/instrumentation-mongodb- MongoDB queries ✓- Outgoing
fetch()calls - auto-traced ✓ - Any other library instrumentation - just install the library and it auto-traces
We disable only the 4 Node.js core module patches that conflict with Bun:
instrumentation-http- we use@hono/otelmiddleware insteadinstrumentation-fs- patches Node's fs moduleinstrumentation-dns- patches Node's dns moduleinstrumentation-net- patches Node's net module
The instrumentation import must be first so it can patch modules before they're loaded:
// IMPORTANT: Instrumentation must be first
import "@/server/instrumentation";
import { db } from "@/server/database"; // pg is patched before this loadsWe've disabled biome's import organizer for this file to preserve the order.
| File | Purpose |
|---|---|
lib/tracing.ts |
Shared tracing utilities: createWithSpan, addSpanAttributes, recordSpanError |
web/instrumentation.ts |
Browser SDK setup (WebTracerProvider, fetch/XHR/document-load auto-instrumentation) |
web/tracing.ts |
Frontend tracing (re-exports shared utils with "web" tracer) |
web/client.tsx |
Calls initInstrumentation() before app loads |
server/instrumentation.ts |
Node SDK setup (auto-instrumentations, Axiom exporter) |
server/server.tsx |
Hono app with @hono/otel middleware + trace proxy endpoint |
server/tracing.ts |
Backend tracing (re-exports shared utils with "server" tracer) |
server/request-context.ts |
AsyncLocalStorage for request-scoped user context |
server/logger.ts |
Trace and user-aware Pino logger (auto-injects traceId/spanId/userId) |
env.ts |
OTEL config schema (AXIOM_TOKEN, AXIOM_DATASET, OTEL_SERVICE_NAME, SERVICE_VERSION) |
| Env var | Default | Description |
|---|---|---|
AXIOM_TOKEN |
- | Axiom API token (required for tracing to work) |
AXIOM_DATASET |
- | Axiom dataset name (required for tracing to work) |
OTEL_SERVICE_NAME |
server |
Backend service name in traces |
SERVICE_VERSION |
dev |
Backend service version (set via CI: git rev-parse --short HEAD) |
VITE_SERVICE_VERSION |
dev |
Frontend service version (set via CI, must have VITE_ prefix) |
LOG_LEVEL |
info |
Pino log level (trace, debug, info, warn, error, fatal) |
Frontend uses import.meta.env.MODE for deployment environment (automatically development or production).
Without AXIOM_TOKEN and AXIOM_DATASET, tracing is silently disabled.
- User context injection - Uncomment the Better Auth integration in
server/server.tsxonce auth is set up. Automatically addsuser.idanduser.emailto every request span and log entry.
- Trace authentication flows - Add spans around login, logout, session refresh
- Trace database migrations - Wrap Drizzle migrations in spans
- Add frontend performance metrics - Web Vitals (LCP, FID, CLS) as span attributes
- Custom Axiom dashboard - Pre-built queries for common debugging scenarios
- Sampling configuration - Uncomment in
server/instrumentation.tswhen traffic grows
- Metrics - Axiom handles this differently; traces are enough for now
- OTEL Collector - Direct-to-Axiom is simpler; collector adds operational overhead
- Jaeger/Zipkin support - Axiom-only for now; can abstract exporter later if needed
- OpenTelemetry JS Documentation
- Axiom OpenTelemetry Guide
- Semantic Conventions - Standard attribute names