@photon-ai/otel 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,10 @@ Vanilla OTel works, but the setup is verbose, the logger plumbing is awkward, an
7
7
  - **`setupOtel()`** — idempotent one-call bootstrap for traces + logs. Honors all standard `OTEL_EXPORTER_OTLP_*` env vars.
8
8
  - **`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`.
9
9
  - **`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.
10
+ - **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.
10
11
  - **`sanitizeEmail` / `sanitizePhone` / `sanitizeErrorMessage`** — PII helpers you can reuse anywhere.
11
12
 
12
- OTLP/HTTP only (no gRPC, no proto). Works identically on Bun and Node ≥ 20.
13
+ OTLP/HTTP only (no gRPC, no proto). Runs on Bun and Node ≥ 20.
13
14
 
14
15
  ## Install
15
16
 
@@ -34,7 +35,9 @@ const log = createLogger("server");
34
35
 
35
36
  await withSpan("handle-request", { route: "/users" }, async () => {
36
37
  log.info("processing request", { userId: 42 });
37
- // ... your work
38
+ // Outbound fetch is traced automatically: a CLIENT span, parented to this
39
+ // one, with a `traceparent` header injected for the downstream service.
40
+ await fetch("https://api.example.com/users");
38
41
  });
39
42
  ```
40
43
 
@@ -46,6 +49,8 @@ If `OTEL_EXPORTER_OTLP_ENDPOINT` (or the `endpoint` option) is unset, `setupOtel
46
49
  | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
47
50
  | `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs. Idempotent. Returns `{ shutdown(): Promise<void> }`. |
48
51
  | `isOtelActive(): boolean` | Returns `true` if `setupOtel` has already run in this process. |
52
+ | `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. |
53
+ | `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. |
49
54
  | `createLogger(module): PhotonLogger` | Returns `{ info, warn, error, debug }`. Each call emits to OTel + `console`, correlates to active span. |
50
55
  | `setLogLevel(level): void` | Set the minimum level emitted (`debug`/`info`/`warn`/`error`/`silent`). `LOG_LEVEL` env still wins. |
51
56
  | `getLogLevel(): LogLevel` | Current effective level after env / override / default resolution. |
@@ -109,6 +114,83 @@ Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`:
109
114
  | `DEPLOYMENT_ENV` | Attached as `deployment.environment` resource attribute. Defaults to `development`. Also drives the default log level. |
110
115
  | `LOG_LEVEL` | Minimum log level: `debug` \| `info` \| `warn` \| `error` \| `silent`. Overrides `setLogLevel()` / `setupOtel({ logLevel })`. |
111
116
 
117
+ ## Automatic fetch instrumentation
118
+
119
+ `setupOtel()` instruments outbound `fetch` to emit a CLIENT span per request, carrying W3C
120
+ `traceparent` (and baggage) headers so traces continue across services. Spans are named by HTTP
121
+ method (`GET`, `POST`, …) and carry `http.request.method`, `url.full`, `server.address`,
122
+ `server.port`, and `http.response.status_code`. This covers **outbound** requests only — inbound
123
+ `Bun.serve`/Elysia server spans are separate (see [Framework integration](#framework-integration)).
124
+
125
+ The strategy depends on the runtime (`mode: "auto"`, the default):
126
+
127
+ - **Node** uses the official `@opentelemetry/instrumentation-undici` (Node's `fetch` is undici-backed).
128
+ It captures all undici traffic — not just `globalThis.fetch` — emits the full HTTP-client semantic
129
+ conventions (`url.scheme`, `url.path`, `network.peer.*`, `user_agent.original`, …), and never
130
+ monkey-patches the global. It ships as an optional dependency; if it isn't installed, the library
131
+ falls back to the global wrap.
132
+ - **Bun** wraps `globalThis.fetch` directly. Bun's native `fetch` emits no `diagnostics_channel`
133
+ events, so `@opentelemetry/instrumentation-undici` / `-http` produce no spans there — wrapping the
134
+ global is the only mechanism that works.
135
+
136
+ Options (`instrumentFetch`):
137
+
138
+ - **`true` / `false`:** force on (even without an endpoint) / off. Defaults to on when a traces
139
+ endpoint is configured.
140
+ - **`mode`:** `"auto"` (default — native on Node, wrap on Bun) or `"global"` (wrap on both runtimes).
141
+ Choose `"global"` when you want identical spans everywhere and the built-in PII scrubbing of error
142
+ messages kept on Node (see caveats).
143
+ - **`ignore`:** `instrumentFetch: { ignore: (url) => url.includes("/healthz") }`. Your own OTLP
144
+ endpoint is always excluded automatically, so the exporter never traces itself.
145
+
146
+ Caveats:
147
+
148
+ - **Telemetry differs by runtime under `"auto"`.** undici (Node) emits richer attributes and follows
149
+ HTTP semconv for span status — a 2xx client span is left `UNSET`, and only `5xx`/network failures are
150
+ marked `ERROR`; the Bun wrap marks all `4xx`/`5xx` as `ERROR`. The Bun wrap also scrubs PII from the
151
+ error message attached to span status — **undici does not**. Use `mode: "global"` for parity.
152
+ - **`url.full` includes the query string** on both. If your URLs carry secrets there, use `ignore` or
153
+ redact upstream.
154
+ - **Native fetch tracing needs Node ≥ 20.6** (the undici instrumentation's floor); older 20.x falls
155
+ back to the global wrap.
156
+
157
+ `setupOtel()` also installs a global W3C trace-context + baggage propagator (previously none was
158
+ registered) — that is what makes the outbound `traceparent` injection, and any manual propagation,
159
+ actually take effect.
160
+
161
+ ## Instrumenting a specific fetch (SDKs)
162
+
163
+ Sometimes you don't want to touch `globalThis.fetch` — you just want one SDK's outbound calls traced.
164
+ `createInstrumentedFetch(baseFetch?, options?)` returns a **new** fetch (CLIENT spans + W3C
165
+ `traceparent` injection) wrapping `baseFetch` (default `globalThis.fetch`, read at call time) **without
166
+ mutating the global**. Pass it wherever an SDK accepts a `fetch`:
167
+
168
+ ```ts
169
+ import { createInstrumentedFetch } from "@photon-ai/otel";
170
+ import OpenAI from "openai";
171
+
172
+ const client = new OpenAI({
173
+ // tag every span from this SDK so it's distinguishable from other traffic
174
+ fetch: createInstrumentedFetch(undefined, {
175
+ attributes: { "peer.service": "openai" },
176
+ }),
177
+ });
178
+ ```
179
+
180
+ - Returns a fetch function directly — there's no global lifecycle, so no `unpatch()` handle.
181
+ - Idempotent: passing an already-instrumented fetch returns it unchanged.
182
+ - `options`: `ignore: (url) => boolean` skips spans for some URLs; `attributes` merges static attributes
183
+ into every span (the practical way to tell different SDKs' spans apart).
184
+ - Always uses the wrapper technique, so it behaves identically on Bun and Node (the native undici
185
+ instrumentation can't target a single instance).
186
+
187
+ **Avoid double-counting on Node.** If `setupOtel()`'s global fetch instrumentation is active (the
188
+ default on Node uses undici, which captures *all* undici traffic), an SDK request made through a
189
+ per-instance wrapper is recorded **twice** — once by the wrapper, once by undici. When instrumenting
190
+ SDKs per-instance, disable the global path with `setupOtel({ instrumentFetch: false })` (or reserve
191
+ per-instance wrapping for SDKs you accept doubling on). **Bun has no such issue** — its global wrap only
192
+ affects `globalThis.fetch`, so a separately-passed instrumented fetch is counted once.
193
+
112
194
  ## Running on Node vs Bun
113
195
 
114
196
  The same code runs unmodified on both. Pick whichever you prefer:
@@ -121,6 +203,8 @@ node --experimental-strip-types src/server.ts
121
203
 
122
204
  The package uses `process.env` (not `Bun.env`) and `AsyncLocalStorageContextManager` (backed by `async_hooks`), both of which are supported natively by Bun and Node ≥ 20.
123
205
 
206
+ The one runtime-specific behavior is fetch instrumentation — native undici on Node, a `globalThis.fetch` wrap on Bun (see [Automatic fetch instrumentation](#automatic-fetch-instrumentation)). Set `instrumentFetch: { mode: "global" }` for identical behavior on both.
207
+
124
208
  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.
125
209
 
126
210
  ## Why HTTP exporters only?
package/dist/index.d.ts CHANGED
@@ -1,3 +1,78 @@
1
+ import { Attributes } from "@opentelemetry/api";
2
+
3
+ //#region src/instrument-fetch.d.ts
4
+ interface FetchSpanOptions {
5
+ /**
6
+ * Static attributes merged into every CLIENT span this instrumentation
7
+ * produces. Handy for tagging an SDK's traffic, e.g. `{ "peer.service":
8
+ * "openai" }`, so spans from different instrumented fetches stay
9
+ * distinguishable.
10
+ */
11
+ attributes?: Attributes;
12
+ /**
13
+ * Return `true` to skip instrumenting a request whose absolute URL is passed
14
+ * in. Useful to drop noisy endpoints or URLs that carry secrets in their
15
+ * query string. The request is still performed — only the span is skipped.
16
+ */
17
+ ignore?: (url: string) => boolean;
18
+ }
19
+ interface InstrumentFetchOptions extends FetchSpanOptions {
20
+ /**
21
+ * Which fetch-instrumentation strategy `setupOtel()` should use:
22
+ * - `"auto"` (default): the official `@opentelemetry/instrumentation-undici`
23
+ * on Node (richer HTTP-client semantic conventions, captures all undici
24
+ * traffic, no global monkey-patch), and the `globalThis.fetch` wrap on Bun
25
+ * (the only thing that works there).
26
+ * - `"global"`: always wrap `globalThis.fetch` on both runtimes. Produces
27
+ * identical spans everywhere and keeps the built-in PII scrubbing of error
28
+ * messages, at the cost of the richer Node attributes. Use this when you
29
+ * want Bun and Node telemetry to match exactly.
30
+ *
31
+ * Only consulted by `setupOtel()`. Calling `instrumentFetch()` directly always
32
+ * performs a global wrap regardless of this field.
33
+ */
34
+ mode?: "auto" | "global";
35
+ }
36
+ interface FetchInstrumentation {
37
+ /** Restore the original `globalThis.fetch`. Safe to call more than once. */
38
+ unpatch(): void;
39
+ }
40
+ /**
41
+ * Wrap a single fetch function — not `globalThis.fetch` — so requests made
42
+ * through the RETURNED fetch produce a CLIENT span and carry W3C trace context
43
+ * to the downstream service.
44
+ *
45
+ * Built for SDKs that accept a `fetch` option, e.g.
46
+ * `new OpenAI({ fetch: createInstrumentedFetch() })`. Unlike `instrumentFetch`,
47
+ * it never mutates the global and has no lifecycle to unpatch — it just returns
48
+ * a new fetch you pass where you need it.
49
+ *
50
+ * `baseFetch` defaults to the current `globalThis.fetch`, read at call time.
51
+ * Idempotent: passing an already-instrumented fetch returns it unchanged.
52
+ *
53
+ * Always uses the global-wrap technique (the native undici instrumentation
54
+ * cannot target a single instance), so it behaves identically on Bun and Node.
55
+ * On Node, if `setupOtel`'s global fetch instrumentation is also active, the
56
+ * SDK's request is captured twice — disable it (`instrumentFetch: false`) for
57
+ * paths you instrument per-instance.
58
+ */
59
+ declare function createInstrumentedFetch(baseFetch?: typeof fetch, options?: FetchSpanOptions): typeof fetch;
60
+ /**
61
+ * Wrap `globalThis.fetch` so every outbound request produces a CLIENT span and
62
+ * carries W3C trace context to the downstream service.
63
+ *
64
+ * On Bun this is the only fetch instrumentation that works: Bun's native fetch
65
+ * emits no `diagnostics_channel` events, so the standard `instrumentation-undici`
66
+ * / `instrumentation-http` (and `opentelemetry-instrumentation-fetch-node`,
67
+ * which is itself diagnostics_channel-based) produce no spans. It works
68
+ * identically on Node, where `globalThis.fetch` is undici-backed.
69
+ *
70
+ * Idempotent: a second call does not stack another wrapper. Returns a handle
71
+ * whose `unpatch()` restores the original fetch.
72
+ */
73
+ declare function instrumentFetch(options?: InstrumentFetchOptions): FetchInstrumentation;
74
+ //#endregion
75
+ //#region src/logger.d.ts
1
76
  type LogAttrs = Record<string, string | number | boolean | undefined>;
2
77
  /**
3
78
  * Minimum severity that gets emitted (to both the OTLP record and the console).
@@ -12,13 +87,14 @@ declare function setLogLevel(level: LogLevel): void;
12
87
  /** Current effective log level, after env / override / default resolution. */
13
88
  declare function getLogLevel(): LogLevel;
14
89
  interface PhotonLogger {
15
- debug(message: string, attrs?: LogAttrs, error?: unknown): void;
16
- error(message: string, attrs?: LogAttrs, error?: unknown): void;
17
- info(message: string, attrs?: LogAttrs, error?: unknown): void;
18
- warn(message: string, attrs?: LogAttrs, error?: unknown): void;
90
+ debug(message: string, attrs?: LogAttrs, error?: unknown): void;
91
+ error(message: string, attrs?: LogAttrs, error?: unknown): void;
92
+ info(message: string, attrs?: LogAttrs, error?: unknown): void;
93
+ warn(message: string, attrs?: LogAttrs, error?: unknown): void;
19
94
  }
20
95
  declare function createLogger(module: string): PhotonLogger;
21
-
96
+ //#endregion
97
+ //#region src/sanitize.d.ts
22
98
  /**
23
99
  * Mask a phone number, keeping the leading `+` (if any) plus the first 3 digits
24
100
  * and the last 4 digits visible. Example: `+13315553374` -> `+133xxxxx3374`.
@@ -39,36 +115,48 @@ declare function sanitizeEmail(input: string): string;
39
115
  * them to span status.
40
116
  */
41
117
  declare function sanitizeErrorMessage(input: string): string;
42
-
118
+ //#endregion
119
+ //#region src/setup.d.ts
43
120
  interface SetupOtelOptions {
44
- /**
45
- * Default OTLP/HTTP base endpoint (e.g. `https://otel.example.com`). The
46
- * `/v1/traces` and `/v1/logs` paths are appended automatically. Standard
47
- * `OTEL_EXPORTER_OTLP_*` env vars always take precedence.
48
- */
49
- endpoint?: string;
50
- /**
51
- * Default OTLP headers (e.g. `{ Authorization: "Basic ..." }`). Merged with
52
- * any headers parsed from `OTEL_EXPORTER_OTLP_HEADERS`; env values win on
53
- * conflicts.
54
- */
55
- headers?: Record<string, string>;
56
- /**
57
- * Minimum log level emitted by `createLogger()` (to both OTLP and console).
58
- * The `LOG_LEVEL` env var still takes precedence. Defaults to `debug` in
59
- * development and `info` otherwise.
60
- */
61
- logLevel?: LogLevel;
62
- /**
63
- * Extra resource attributes attached to every span/log alongside
64
- * `service.name` / `service.version`.
65
- */
66
- resourceAttributes?: Record<string, string | number | boolean>;
67
- serviceName: string;
68
- serviceVersion?: string;
121
+ /**
122
+ * Default OTLP/HTTP base endpoint (e.g. `https://otel.example.com`). The
123
+ * `/v1/traces` and `/v1/logs` paths are appended automatically. Standard
124
+ * `OTEL_EXPORTER_OTLP_*` env vars always take precedence.
125
+ */
126
+ endpoint?: string;
127
+ /**
128
+ * Default OTLP headers (e.g. `{ Authorization: "Basic ..." }`). Merged with
129
+ * any headers parsed from `OTEL_EXPORTER_OTLP_HEADERS`; env values win on
130
+ * conflicts.
131
+ */
132
+ headers?: Record<string, string>;
133
+ /**
134
+ * Auto-instrument outbound `globalThis.fetch` with CLIENT spans and W3C
135
+ * trace-context propagation. On Bun this is the only fetch instrumentation
136
+ * that works (diagnostics_channel-based instrumentations emit nothing on
137
+ * Bun's native fetch); it works identically on Node.
138
+ *
139
+ * `true` enables with defaults; pass an object to filter URLs via `ignore`.
140
+ * Defaults to enabled when a traces endpoint is configured. Pass `false` to
141
+ * disable.
142
+ */
143
+ instrumentFetch?: boolean | InstrumentFetchOptions;
144
+ /**
145
+ * Minimum log level emitted by `createLogger()` (to both OTLP and console).
146
+ * The `LOG_LEVEL` env var still takes precedence. Defaults to `debug` in
147
+ * development and `info` otherwise.
148
+ */
149
+ logLevel?: LogLevel;
150
+ /**
151
+ * Extra resource attributes attached to every span/log alongside
152
+ * `service.name` / `service.version`.
153
+ */
154
+ resourceAttributes?: Record<string, string | number | boolean>;
155
+ serviceName: string;
156
+ serviceVersion?: string;
69
157
  }
70
158
  interface OtelHandle {
71
- shutdown(): Promise<void>;
159
+ shutdown(): Promise<void>;
72
160
  }
73
161
  /**
74
162
  * Boot an OTLP/HTTP-based OpenTelemetry pipeline (traces + logs).
@@ -86,10 +174,13 @@ declare function setupOtel(options: SetupOtelOptions): OtelHandle;
86
174
  * `setupOtel` has already run in this process.
87
175
  */
88
176
  declare function isOtelActive(): boolean;
89
-
90
- declare const PHOTON_OTEL_VERSION = "0.1.0";
91
-
177
+ //#endregion
178
+ //#region src/version.d.ts
179
+ declare const PHOTON_OTEL_VERSION = "1.1.0";
180
+ //#endregion
181
+ //#region src/with-span.d.ts
92
182
  declare function withSpan<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
93
183
  declare function withSpan<T>(name: string, attrs: LogAttrs, fn: () => Promise<T> | T): Promise<T>;
94
-
95
- export { type LogAttrs, type LogLevel, type OtelHandle, PHOTON_OTEL_VERSION, type PhotonLogger, type SetupOtelOptions, createLogger, getLogLevel, isOtelActive, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel, setupOtel, withSpan };
184
+ //#endregion
185
+ export { type FetchInstrumentation, type FetchSpanOptions, type InstrumentFetchOptions, type LogAttrs, type LogLevel, type OtelHandle, PHOTON_OTEL_VERSION, type PhotonLogger, type SetupOtelOptions, createInstrumentedFetch, createLogger, getLogLevel, instrumentFetch, isOtelActive, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel, setupOtel, withSpan };
186
+ //# sourceMappingURL=index.d.ts.map