@photon-ai/otel
@photon-ai/otel
A DX-focused OpenTelemetry wrapper for Bun and Node.js.
Vanilla OTel works, but the setup is verbose, the logger plumbing is awkward, and PII scrubbing is on you. @photon-ai/otel wraps the OTLP/HTTP stack into a few well-named functions:
setupOtel()— idempotent one-call bootstrap for traces + logs. Honors all standardOTEL_EXPORTER_OTLP_*env vars.createLogger(module)— structured logger that writes to both the OTel logger provider andconsole, with automatic trace correlation and exception capture. Every level (debug/info/warn/error) acceptsattrsand anerror, and is gated by a configurableLOG_LEVEL.withSpan(name, attrs?, fn)— wrap any sync or async function in a span; errors are recorded and PII in the error message is scrubbed before being attached to span status.- Automatic
fetchtracing —setupOtel()instruments outboundfetchso every request gets a CLIENT span and W3C trace-context headers. On Node it uses the official@opentelemetry/instrumentation-undici; on Bun — whose native fetch emits nothing for the standarddiagnostics_channel-based instrumentations — it wrapsglobalThis.fetch. PassinstrumentFetch: { mode: "global" }to force the wrap on both for identical spans. sanitizeEmail/sanitizePhone/sanitizeErrorMessage— PII helpers you can reuse anywhere.
OTLP/HTTP only (no gRPC, no proto). Runs on Bun and Node ≥ 20.
Install
bun add @photon-ai/otel
# or
npm install @photon-ai/otelQuick start
import { createLogger, setupOtel, withSpan } from "@photon-ai/otel";
setupOtel({
serviceName: "my-service",
serviceVersion: "1.0.0",
endpoint: "https://otel.example.com", // optional; env var wins
});
const log = createLogger("server");
await withSpan("handle-request", { route: "/users" }, async () => {
log.info("processing request", { userId: 42 });
// Outbound fetch is traced automatically: a CLIENT span, parented to this
// one, with a `traceparent` header injected for the downstream service.
await fetch("https://api.example.com/users");
});If OTEL_EXPORTER_OTLP_ENDPOINT (or the endpoint option) is unset, setupOtel() still runs but exporters are no-ops — perfect for local development with zero config.
API
| Function | Description |
|---|---|
setupOtel(options): OtelHandle |
Boots OTLP/HTTP traces + logs. Idempotent. Returns { shutdown(): Promise<void> }. |
isOtelActive(): boolean |
Returns true if setupOtel has already run in this process. |
instrumentFetch(options?): FetchInstrumentation |
Low-level wrap of globalThis.fetch for CLIENT spans + W3C propagation. Returns { unpatch() }. setupOtel calls this on Bun; on Node it prefers native undici. |
createInstrumentedFetch(baseFetch?, options?): typeof fetch |
Returns a NEW instrumented fetch (CLIENT spans + W3C propagation) wrapping baseFetch (default globalThis.fetch) without touching the global. For SDKs that take a fetch option. |
createLogger(module): PhotonLogger |
Returns { info, warn, error, debug }. Each call emits to OTel + console, correlates to active span. |
setLogLevel(level): void |
Set the minimum level emitted (debug/info/warn/error/silent). LOG_LEVEL env still wins. |
getLogLevel(): LogLevel |
Current effective level after env / override / default resolution. |
withSpan(name, fn) |
Wraps fn (sync or async) in a span. Records exceptions and scrubs PII in error messages. |
withSpan(name, attrs, fn) |
Same as above but attaches attrs to the span. |
sanitizeEmail(input) |
Masks an email: foo.bar@example.com → fo***@e***.com. |
sanitizePhone(input) |
Masks a phone: +13315553374 → +133xxxxx3374. |
sanitizeErrorMessage(input) |
Masks every email and phone embedded in a free-form string. |
PHOTON_OTEL_VERSION |
Constant — current package version. |
Logger signatures
log.debug(message, attrs?, error?);
log.info(message, attrs?, error?);
log.warn(message, attrs?, error?); // attach the exception that caused a retry
log.error(message, attrs?, error?);Every level takes the same (message, attrs?, error?) shape — attach an exception to a
warn/info/debug, not just error. attrs is
Record<string, string | number | boolean | undefined>; undefined values are dropped.
An Error is recorded as exception.type / exception.message / exception.stacktrace
on the OTLP record (per the OTel exception semantic convention); a non-Error throw is
coerced so at least exception.message is preserved.
Each call also prints a single human-readable console line — [module] LEVEL message { ...attrs } plus the raw error (so the runtime renders the full stack) — routed to
console.debug / console.info / console.warn / console.error by severity. Both
sinks share one level gate.
Log level
Logs below the active level are dropped from both OTLP and the console. The level is resolved fresh on every call, so changes take effect immediately:
LOG_LEVELenv var (debug|info|warn|error|silent) — wins if set.setLogLevel(level)orsetupOtel({ logLevel }).- Default:
debugin development (DEPLOYMENT_ENVunset ordevelopment),infootherwise.
import { setLogLevel } from "@photon-ai/otel";
setLogLevel("warn"); // debug + info now suppressed everywhere
// or set LOG_LEVEL=warn in the environment, which overrides the call above"silent" suppresses everything, including errors.
Configuration
Standard OpenTelemetry env vars always take precedence over SetupOtelOptions:
| Variable | Effect |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Base endpoint; /v1/traces and /v1/logs auto-appended. |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
Full traces URL (overrides the base for traces). |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT |
Full logs URL (overrides the base for logs). |
OTEL_EXPORTER_OTLP_HEADERS |
key=value,key=value headers; merged with options.headers (env wins). |
DEPLOYMENT_ENV |
Attached as deployment.environment resource attribute. Defaults to development. Also drives the default log level. |
LOG_LEVEL |
Minimum log level: debug | info | warn | error | silent. Overrides setLogLevel() / setupOtel({ logLevel }). |
Automatic fetch instrumentation
setupOtel() instruments outbound fetch to emit a CLIENT span per request, carrying W3C
traceparent (and baggage) headers so traces continue across services. Spans are named by HTTP
method (GET, POST, …) and carry http.request.method, url.full, server.address,
server.port, and http.response.status_code. This covers outbound requests only — inbound
Bun.serve/Elysia server spans are separate (see Framework integration).
The strategy depends on the runtime (mode: "auto", the default):
- Node uses the official
@opentelemetry/instrumentation-undici(Node'sfetchis undici-backed). It captures all undici traffic — not justglobalThis.fetch— emits the full HTTP-client semantic conventions (url.scheme,url.path,network.peer.*,user_agent.original, …), and never monkey-patches the global. It ships as an optional dependency; if it isn't installed, the library falls back to the global wrap. - Bun wraps
globalThis.fetchdirectly. Bun's nativefetchemits nodiagnostics_channelevents, so@opentelemetry/instrumentation-undici/-httpproduce no spans there — wrapping the global is the only mechanism that works.
Options (instrumentFetch):
true/false: force on (even without an endpoint) / off. Defaults to on when a traces endpoint is configured.mode:"auto"(default — native on Node, wrap on Bun) or"global"(wrap on both runtimes). Choose"global"when you want identical spans everywhere and the built-in PII scrubbing of error messages kept on Node (see caveats).ignore:instrumentFetch: { ignore: (url) => url.includes("/healthz") }. Your own OTLP endpoint is always excluded automatically, so the exporter never traces itself.
Caveats:
- Telemetry differs by runtime under
"auto". undici (Node) emits richer attributes and follows HTTP semconv for span status — a 2xx client span is leftUNSET, and only5xx/network failures are markedERROR; the Bun wrap marks all4xx/5xxasERROR. The Bun wrap also scrubs PII from the error message attached to span status — undici does not. Usemode: "global"for parity. url.fullincludes the query string on both. If your URLs carry secrets there, useignoreor redact upstream.- Native fetch tracing needs Node ≥ 20.6 (the undici instrumentation's floor); older 20.x falls back to the global wrap.
setupOtel() also installs a global W3C trace-context + baggage propagator (previously none was
registered) — that is what makes the outbound traceparent injection, and any manual propagation,
actually take effect.
Instrumenting a specific fetch (SDKs)
Sometimes you don't want to touch globalThis.fetch — you just want one SDK's outbound calls traced.
createInstrumentedFetch(baseFetch?, options?) returns a new fetch (CLIENT spans + W3C
traceparent injection) wrapping baseFetch (default globalThis.fetch, read at call time) without
mutating the global. Pass it wherever an SDK accepts a fetch:
import { createInstrumentedFetch } from "@photon-ai/otel";
import OpenAI from "openai";
const client = new OpenAI({
// tag every span from this SDK so it's distinguishable from other traffic
fetch: createInstrumentedFetch(undefined, {
attributes: { "peer.service": "openai" },
}),
});- Returns a fetch function directly — there's no global lifecycle, so no
unpatch()handle. - Idempotent: passing an already-instrumented fetch returns it unchanged.
options:ignore: (url) => booleanskips spans for some URLs;attributesmerges static attributes into every span (the practical way to tell different SDKs' spans apart).- Always uses the wrapper technique, so it behaves identically on Bun and Node (the native undici instrumentation can't target a single instance).
Avoid double-counting on Node. If setupOtel()'s global fetch instrumentation is active (the
default on Node uses undici, which captures all undici traffic), an SDK request made through a
per-instance wrapper is recorded twice — once by the wrapper, once by undici. When instrumenting
SDKs per-instance, disable the global path with setupOtel({ instrumentFetch: false }) (or reserve
per-instance wrapping for SDKs you accept doubling on). Bun has no such issue — its global wrap only
affects globalThis.fetch, so a separately-passed instrumented fetch is counted once.
Running on Node vs Bun
The same code runs unmodified on both. Pick whichever you prefer:
bun run src/server.ts
# or
node --experimental-strip-types src/server.tsThe package uses process.env (not Bun.env) and AsyncLocalStorageContextManager (backed by async_hooks), both of which are supported natively by Bun and Node ≥ 20.
The one runtime-specific behavior is fetch instrumentation — native undici on Node, a globalThis.fetch wrap on Bun (see Automatic fetch instrumentation). Set instrumentFetch: { mode: "global" } for identical behavior on both.
For Bun consumers, the exports map points at the TypeScript source via the bun condition for faster cold starts during dev. Node consumers get the pre-built ESM bundle.
Why HTTP exporters only?
- Bun's gRPC support is incomplete in some paths — HTTP is reliable everywhere.
- JSON-over-HTTP is trivial to debug with
curland a packet sniffer. - One fewer transport keeps the dependency surface small.
If you need gRPC, instantiate your own exporter and processor with @opentelemetry/api directly — setupOtel() is opinionated, not a wall.
Framework integration
This package is framework-agnostic. For Elysia.js (Bun-native), see the elysiajs-otel skill for a recipe combining @photon-ai/otel with @elysiajs/opentelemetry auto-instrumentation. A dedicated @photon-ai/otel-elysia package may follow.
License
MIT