@photon-ai/otel 1.1.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,10 +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()` wraps `globalThis.fetch` so every outbound request gets a CLIENT span and W3C trace-context headers. On **Bun** this is the only fetch instrumentation that works — the standard `diagnostics_channel`-based OTel instrumentations emit nothing for Bun's native fetch and it behaves identically on Node.
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.
11
11
  - **`sanitizeEmail` / `sanitizePhone` / `sanitizeErrorMessage`** — PII helpers you can reuse anywhere.
12
12
 
13
- 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.
14
14
 
15
15
  ## Install
16
16
 
@@ -49,7 +49,8 @@ If `OTEL_EXPORTER_OTLP_ENDPOINT` (or the `endpoint` option) is unset, `setupOtel
49
49
  | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
50
50
  | `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs. Idempotent. Returns `{ shutdown(): Promise<void> }`. |
51
51
  | `isOtelActive(): boolean` | Returns `true` if `setupOtel` has already run in this process. |
52
- | `instrumentFetch(options?): FetchInstrumentation` | Wraps `globalThis.fetch` for CLIENT spans + W3C propagation. Auto-enabled by `setupOtel` when traces are configured. Returns `{ unpatch() }`. |
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. |
53
54
  | `createLogger(module): PhotonLogger` | Returns `{ info, warn, error, debug }`. Each call emits to OTel + `console`, correlates to active span. |
54
55
  | `setLogLevel(level): void` | Set the minimum level emitted (`debug`/`info`/`warn`/`error`/`silent`). `LOG_LEVEL` env still wins. |
55
56
  | `getLogLevel(): LogLevel` | Current effective level after env / override / default resolution. |
@@ -115,28 +116,81 @@ Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`:
115
116
 
116
117
  ## Automatic fetch instrumentation
117
118
 
118
- `setupOtel()` patches `globalThis.fetch` to emit a CLIENT span per outbound request, carrying
119
- W3C `traceparent` (and baggage) headers so traces continue across services. Each span is named by
120
- HTTP method (`GET`, `POST`, …) and carries `http.request.method`, `url.full`, `server.address`,
121
- `server.port`, and `http.response.status_code`; `4xx`/`5xx` responses and thrown network errors are
122
- marked `ERROR`. This covers **outbound** requests only — inbound `Bun.serve`/Elysia server spans are
123
- separate (see [Framework integration](#framework-integration)).
124
-
125
- - **Default:** on when a traces endpoint is configured. Pass `instrumentFetch: false` to disable, or
126
- `instrumentFetch: true` to force it on even without an endpoint.
127
- - **Filter URLs:** `instrumentFetch: { ignore: (url) => url.includes("/healthz") }`. Your own OTLP
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
128
144
  endpoint is always excluded automatically, so the exporter never traces itself.
129
- - **Why Bun needs this:** Bun's native `fetch` emits no `diagnostics_channel` events, so
130
- `@opentelemetry/instrumentation-undici` / `-http` — and `opentelemetry-instrumentation-fetch-node`,
131
- which is itself `diagnostics_channel`-based — produce no spans. Wrapping the global is the only
132
- mechanism that works, and it behaves identically on Node.
133
- - **Caveat:** `url.full` includes the query string. If your URLs carry secrets there, use `ignore`
134
- or redact upstream.
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.
135
156
 
136
157
  `setupOtel()` also installs a global W3C trace-context + baggage propagator (previously none was
137
158
  registered) — that is what makes the outbound `traceparent` injection, and any manual propagation,
138
159
  actually take effect.
139
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
+
140
194
  ## Running on Node vs Bun
141
195
 
142
196
  The same code runs unmodified on both. Pick whichever you prefer:
@@ -149,6 +203,8 @@ node --experimental-strip-types src/server.ts
149
203
 
150
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.
151
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
+
152
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.
153
209
 
154
210
  ## Why HTTP exporters only?
package/dist/index.d.ts CHANGED
@@ -1,15 +1,62 @@
1
- interface InstrumentFetchOptions {
2
- /**
3
- * Return `true` to skip instrumenting a request whose absolute URL is passed
4
- * in. Useful to drop noisy endpoints or URLs that carry secrets in their
5
- * query string. The request is still performed — only the span is skipped.
6
- */
7
- ignore?: (url: string) => boolean;
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";
8
35
  }
9
36
  interface FetchInstrumentation {
10
- /** Restore the original `globalThis.fetch`. Safe to call more than once. */
11
- unpatch(): void;
37
+ /** Restore the original `globalThis.fetch`. Safe to call more than once. */
38
+ unpatch(): void;
12
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;
13
60
  /**
14
61
  * Wrap `globalThis.fetch` so every outbound request produces a CLIENT span and
15
62
  * carries W3C trace context to the downstream service.
@@ -24,7 +71,8 @@ interface FetchInstrumentation {
24
71
  * whose `unpatch()` restores the original fetch.
25
72
  */
26
73
  declare function instrumentFetch(options?: InstrumentFetchOptions): FetchInstrumentation;
27
-
74
+ //#endregion
75
+ //#region src/logger.d.ts
28
76
  type LogAttrs = Record<string, string | number | boolean | undefined>;
29
77
  /**
30
78
  * Minimum severity that gets emitted (to both the OTLP record and the console).
@@ -39,13 +87,14 @@ declare function setLogLevel(level: LogLevel): void;
39
87
  /** Current effective log level, after env / override / default resolution. */
40
88
  declare function getLogLevel(): LogLevel;
41
89
  interface PhotonLogger {
42
- debug(message: string, attrs?: LogAttrs, error?: unknown): void;
43
- error(message: string, attrs?: LogAttrs, error?: unknown): void;
44
- info(message: string, attrs?: LogAttrs, error?: unknown): void;
45
- 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;
46
94
  }
47
95
  declare function createLogger(module: string): PhotonLogger;
48
-
96
+ //#endregion
97
+ //#region src/sanitize.d.ts
49
98
  /**
50
99
  * Mask a phone number, keeping the leading `+` (if any) plus the first 3 digits
51
100
  * and the last 4 digits visible. Example: `+13315553374` -> `+133xxxxx3374`.
@@ -66,47 +115,48 @@ declare function sanitizeEmail(input: string): string;
66
115
  * them to span status.
67
116
  */
68
117
  declare function sanitizeErrorMessage(input: string): string;
69
-
118
+ //#endregion
119
+ //#region src/setup.d.ts
70
120
  interface SetupOtelOptions {
71
- /**
72
- * Default OTLP/HTTP base endpoint (e.g. `https://otel.example.com`). The
73
- * `/v1/traces` and `/v1/logs` paths are appended automatically. Standard
74
- * `OTEL_EXPORTER_OTLP_*` env vars always take precedence.
75
- */
76
- endpoint?: string;
77
- /**
78
- * Default OTLP headers (e.g. `{ Authorization: "Basic ..." }`). Merged with
79
- * any headers parsed from `OTEL_EXPORTER_OTLP_HEADERS`; env values win on
80
- * conflicts.
81
- */
82
- headers?: Record<string, string>;
83
- /**
84
- * Auto-instrument outbound `globalThis.fetch` with CLIENT spans and W3C
85
- * trace-context propagation. On Bun this is the only fetch instrumentation
86
- * that works (diagnostics_channel-based instrumentations emit nothing on
87
- * Bun's native fetch); it works identically on Node.
88
- *
89
- * `true` enables with defaults; pass an object to filter URLs via `ignore`.
90
- * Defaults to enabled when a traces endpoint is configured. Pass `false` to
91
- * disable.
92
- */
93
- instrumentFetch?: boolean | InstrumentFetchOptions;
94
- /**
95
- * Minimum log level emitted by `createLogger()` (to both OTLP and console).
96
- * The `LOG_LEVEL` env var still takes precedence. Defaults to `debug` in
97
- * development and `info` otherwise.
98
- */
99
- logLevel?: LogLevel;
100
- /**
101
- * Extra resource attributes attached to every span/log alongside
102
- * `service.name` / `service.version`.
103
- */
104
- resourceAttributes?: Record<string, string | number | boolean>;
105
- serviceName: string;
106
- 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;
107
157
  }
108
158
  interface OtelHandle {
109
- shutdown(): Promise<void>;
159
+ shutdown(): Promise<void>;
110
160
  }
111
161
  /**
112
162
  * Boot an OTLP/HTTP-based OpenTelemetry pipeline (traces + logs).
@@ -124,10 +174,13 @@ declare function setupOtel(options: SetupOtelOptions): OtelHandle;
124
174
  * `setupOtel` has already run in this process.
125
175
  */
126
176
  declare function isOtelActive(): boolean;
127
-
128
- declare const PHOTON_OTEL_VERSION = "0.1.0";
129
-
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
130
182
  declare function withSpan<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
131
183
  declare function withSpan<T>(name: string, attrs: LogAttrs, fn: () => Promise<T> | T): Promise<T>;
132
-
133
- export { type FetchInstrumentation, type InstrumentFetchOptions, type LogAttrs, type LogLevel, type OtelHandle, PHOTON_OTEL_VERSION, type PhotonLogger, type SetupOtelOptions, createLogger, getLogLevel, instrumentFetch, 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