Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ jobs:
cat output/traces.json || true
echo "===== logs.json ====="
cat output/logs.json || true
echo "===== metrics.json ====="
cat output/metrics.json || true

- if: ${{ always() }}
name: Tear down collector
Expand Down
74 changes: 63 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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 standard `OTEL_EXPORTER_OTLP_*` env vars.
- **`setupOtel()`** — idempotent one-call bootstrap for traces + logs + metrics. Honors standard `OTEL_EXPORTER_OTLP_*` env vars.
- **`otel.getMeter(name)`** — creates standard OpenTelemetry instruments from this setup's meter provider, with identical behavior in global and scoped mode.
- **`createLogger(module)`** — structured logger that writes to both the OTel logger provider and `console`, with automatic trace correlation and exception capture. Every level (`debug`/`info`/`warn`/`error`) accepts `attrs` **and** an `error`, and is gated by a configurable `LOG_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 `fetch` tracing** — `setupOtel()` instruments outbound `fetch` so 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 standard `diagnostics_channel`-based instrumentations — it wraps `globalThis.fetch`. Pass `instrumentFetch: { mode: "global" }` to force the wrap on both for identical spans.
Expand All @@ -25,29 +26,74 @@ npm install @photon-ai/otel
```ts
import { createLogger, setupOtel, withSpan } from "@photon-ai/otel";

setupOtel({
const otel = setupOtel({
serviceName: "my-service",
serviceVersion: "1.0.0",
endpoint: "https://otel.example.com", // optional; env var wins
});

const log = createLogger("server");
const requests = otel.getMeter("server").createCounter("server.requests");

await withSpan("handle-request", { route: "/users" }, async () => {
log.info("processing request", { userId: 42 });
requests.add(1, { route: "/users" });
// 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");
});

await otel.shutdown();
```

If `OTEL_EXPORTER_OTLP_ENDPOINT` (or the `endpoint` option) is unset,
`setupOtel()` still returns real providers and instruments; measurements simply
are not exported. This keeps local development zero-config.

## Creating metrics

For application code, create instruments from the handle returned by
`setupOtel()`:

```ts
const ordersProcessed = otel
.getMeter("orders")
.createCounter("orders.processed", {
description: "Orders processed by the service",
});

ordersProcessed.add(1, { result: "success" });
```

Create meters and instruments after `setupOtel()` runs. Do not use
`metrics.getMeter()` in a module-level initializer: if it runs before setup, it
can bind instruments to OpenTelemetry's no-op provider.

For a reusable module, accept an OpenTelemetry `Meter` and build instruments in
a factory:

```ts
import type { Meter } from "@opentelemetry/api";

export const createOrderMetrics = (meter: Meter) => ({
processed: meter.createCounter("orders.processed", {
description: "Orders processed by the service",
}),
});

const orderMetrics = createOrderMetrics(otel.getMeter("orders"));
```

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.
This is the recommended integration boundary: application startup owns OTel
setup, while reusable code only knows about the standard `Meter` interface.
See the [metrics guide](./docs/guides/metrics.mdx) for shared-publisher usage,
attribute guidance, and scoped mode.

## API

| Function | Description |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs. Idempotent. Returns `{ shutdown(), tracerProvider, loggerProvider }`. Pass `register: false` for scoped mode (no global takeover). |
| `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs + metrics. The handle exposes `getMeter()`, providers, and `shutdown()`. Pass `register: false` for scoped mode. |
| `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. |
Expand Down Expand Up @@ -107,10 +153,14 @@ 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_ENDPOINT` | Base endpoint; `/v1/traces`, `/v1/logs`, and `/v1/metrics` 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_METRICS_ENDPOINT` | Full metrics URL (overrides the base for metrics). |
| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value,key=value` headers; merged with `options.headers` (env wins). |
| `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` | Trace-, log-, or metric-specific headers; override generic and code headers. |
| `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval in milliseconds. Defaults to `60000`. |
| `OTEL_METRIC_EXPORT_TIMEOUT` | Metric export timeout in milliseconds. Defaults to `30000`. |
| `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 })`. |

Expand Down Expand Up @@ -197,7 +247,7 @@ affects `globalThis.fetch`, so a separately-passed instrumented fetch is counted

## Scoped mode (embedding in a library)

By default `setupOtel()` registers the process-global OpenTelemetry tracer/logger providers — the
By default `setupOtel()` registers the process-global OpenTelemetry tracer/logger/meter providers — the
convenient app-level setup. If you're building a **library** that ships its own telemetry, that would
take over the host application's OpenTelemetry. Pass `register: false` to run **scoped**:

Expand All @@ -208,22 +258,24 @@ const otel = setupOtel({ serviceName: "my-lib", register: false });
await withSpan("work", async () => {
/* ... */
});
// ...and the host app's global tracer/logger providers are left untouched.
// ...and the host app's global tracer/logger/meter providers are left untouched.
```

In scoped mode:

- **No global takeover.** `setupOtel()` does not call `setGlobalTracerProvider` / `setGlobalLoggerProvider`;
the library's spans and logs flow to its own providers while the host keeps its global OTel.
- **No global takeover.** `setupOtel()` does not register its tracer, logger, or meter provider globally;
the library's spans, logs, and metrics flow to its own providers while the host keeps its global OTel.
- **The top-level helpers still work** — `withSpan`, `createLogger`, and `createInstrumentedFetch` resolve
through the library's providers automatically.
- **Shared context is preserved.** A W3C propagator and an `AsyncLocalStorageContextManager` are installed
only if absent, so span nesting and `traceparent` propagation work — and if the host already set them, the
library shares the host's (spans nest across the boundary).
- **Auto fetch instrumentation defaults off** (wrapping `globalThis.fetch` is process-wide, and native undici
can only read the global provider). Trace a specific client with `createInstrumentedFetch()` instead.
- **The handle exposes the providers** — `otel.tracerProvider` / `otel.loggerProvider` — if you need to build
extra tracers or wire additional processors.
- **The handle owns metric lookup.** Use `otel.getMeter(name)` for the common path; it always resolves through
this setup's provider, including in scoped mode.
- **The handle exposes the providers** — `otel.tracerProvider`, `otel.loggerProvider`, and `otel.meterProvider`
— as advanced integration escape hatches.

## Running on Node vs Bun

Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 17 additions & 11 deletions docs/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ description: "Understand the design decisions behind @photon-ai/otel and how the

`@photon-ai/otel` is a thin opinionated layer over OpenTelemetry SDK primitives.

It does not invent a telemetry model. It chooses a small set of defaults that make traces, logs, correlation, and outbound propagation work consistently in Bun and Node.js.
It does not invent a telemetry model. It chooses a small set of defaults that make traces, logs, metrics, correlation, and outbound propagation work consistently in Bun and Node.js.

## Public surface

The package exports one entry point with these groups:

- setup: `setupOtel()`, `isOtelActive()`
- setup: `setupOtel()`, `isOtelActive()`, and the returned handle's `getMeter()` method
- logging: `createLogger()`, `setLogLevel()`, `getLogLevel()`
- tracing helper: `withSpan()`
- fetch instrumentation: `instrumentFetch()`
Expand All @@ -24,7 +24,7 @@ Keeping this surface small is a design choice. Most service code should not need

`setupOtel()` owns process-level OpenTelemetry initialization.

It creates a resource, installs context propagation, configures trace and log providers, and optionally instruments fetch.
It creates a resource, installs context propagation, configures trace, log, and metric providers, and optionally instruments fetch.

The important OpenTelemetry primitives are:

Expand All @@ -38,18 +38,24 @@ The important OpenTelemetry primitives are:
- `LoggerProvider`
- `BatchLogRecordProcessor`
- `OTLPLogExporter`
- `MeterProvider`
- `PeriodicExportingMetricReader`
- `OTLPMetricExporter`

This gives the package one predictable runtime path instead of exposing every OpenTelemetry SDK choice to each service.

## Why global providers are used
## Global and scoped providers

OpenTelemetry JavaScript APIs use global providers for trace and log lookup.
OpenTelemetry JavaScript APIs use global providers for trace, log, and metric lookup.

`withSpan()` calls `trace.getTracer(...)`.
In the default mode, setup registers all three providers globally. The package's
`withSpan()` and `createLogger()` helpers also retain the providers built by
setup, allowing those helpers to target the same pipeline without a global
lookup in scoped mode.

`createLogger()` calls `logs.getLogger(...)`.

Both depend on global providers installed by setup. This is why setup should run before application work starts.
The returned handle's `getMeter()` method calls its configured meter provider
directly. This keeps metrics scoped to that setup when `register: false`;
application code does not need to know which registration mode is active.

## Why setup is idempotent

Expand All @@ -67,7 +73,7 @@ That makes repeated setup calls safe in tests, CLIs, and shared bootstrap utilit

## Why OTLP/HTTP only

The implementation uses OTLP over HTTP for traces and logs.
The implementation uses OTLP over HTTP for traces, logs, and metrics.

Reasons:

Expand All @@ -82,7 +88,7 @@ The package is opinionated here. If a service needs custom exporters or gRPC tra

Local development should not require infrastructure.

When no endpoint is configured, the package still installs providers but uses empty processor lists. Application code can call `withSpan()` and `createLogger()` without branching on environment.
When no endpoint is configured, the package still installs providers but uses empty processor and reader lists. Application code can call `withSpan()`, `createLogger()`, and `otel.getMeter()` without branching on environment.

This design means the same code path runs locally and in production. The deployment environment decides whether telemetry is exported.

Expand Down
18 changes: 13 additions & 5 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,21 @@ The package appends signal paths:

- Traces: `/v1/traces`
- Logs: `/v1/logs`
- Metrics: `/v1/metrics`

`endpoint` is ignored when `OTEL_EXPORTER_OTLP_ENDPOINT` is set.

## Signal-specific endpoints

Use signal-specific environment variables when traces and logs need different destinations.
Use signal-specific environment variables when signals need different destinations.

```bash
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otel.example.com/v1/traces
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://logs.example.com/v1/logs
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://metrics.example.com/v1/metrics
```

These are full URLs. The package does not append `/v1/traces` or `/v1/logs` to signal-specific variables.
These are full URLs. The package uses signal-specific endpoint values exactly as provided.

## Headers

Expand All @@ -101,11 +103,16 @@ OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer prod-token,x-tenant=acme"

Environment headers are merged over code headers. If the same header exists in both places, the environment value wins.

Each signal also accepts `OTEL_EXPORTER_OTLP_TRACES_HEADERS`,
`OTEL_EXPORTER_OTLP_LOGS_HEADERS`, or
`OTEL_EXPORTER_OTLP_METRICS_HEADERS`. Signal-specific headers override both
generic environment headers and code headers.

This lets code include harmless local defaults while production injects real credentials outside the repository.

## Resource attributes

`resourceAttributes` adds attributes to every span and log record.
`resourceAttributes` adds attributes to every span, log record, and metric.

```ts
setupOtel({
Expand Down Expand Up @@ -230,7 +237,7 @@ setupOtel({

See [Fetch instrumentation](/guides/fetch-instrumentation) for the runtime differences and the `createInstrumentedFetch()` helper for instrumenting an SDK's fetch without touching the global.

The package always excludes its own OTLP trace and log exporter endpoints from fetch instrumentation. That prevents exporter traffic from recursively tracing itself.
The package always excludes its own OTLP exporter endpoints from fetch instrumentation. That prevents exporter traffic from recursively tracing itself.

## Environment variable precedence

Expand All @@ -239,7 +246,8 @@ The package always excludes its own OTLP trace and log exporter endpoints from f
| Base OTLP endpoint | `OTEL_EXPORTER_OTLP_ENDPOINT` | `setupOtel({ endpoint })` |
| Trace endpoint | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | base endpoint + `/v1/traces` |
| Log endpoint | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | base endpoint + `/v1/logs` |
| OTLP headers | `OTEL_EXPORTER_OTLP_HEADERS` | `setupOtel({ headers })` |
| Metric endpoint | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | base endpoint + `/v1/metrics` |
| Signal headers | signal-specific `*_HEADERS` | generic headers, then `setupOtel({ headers })` |
| Log level | `LOG_LEVEL` | `setupOtel({ logLevel })` or `setLogLevel()` |
| Deployment environment | `DEPLOYMENT_ENV` | `development` |

Expand Down
Loading
Loading