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.sanitizeEmail/sanitizePhone/sanitizeErrorMessage— PII helpers you can reuse anywhere.
OTLP/HTTP only (no gRPC, no proto). Works identically on Bun and Node ≥ 20.
bun add @photon-ai/otel
# or
npm install @photon-ai/otelimport { 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 });
// ... your work
});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.
| 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. |
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. |
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.
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.
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 }). |
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.
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.
- 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.
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.
MIT