@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/dist/index.js CHANGED
@@ -1,555 +1,574 @@
1
- // src/instrument-fetch.ts
2
- import {
3
- context,
4
- propagation,
5
- SpanKind,
6
- SpanStatusCode,
7
- trace
8
- } from "@opentelemetry/api";
9
- import {
10
- ATTR_ERROR_TYPE,
11
- ATTR_HTTP_REQUEST_METHOD,
12
- ATTR_HTTP_RESPONSE_STATUS_CODE,
13
- ATTR_SERVER_ADDRESS,
14
- ATTR_SERVER_PORT,
15
- ATTR_URL_FULL
16
- } from "@opentelemetry/semantic-conventions";
17
-
18
- // src/sanitize.ts
19
- var PHONE_PATTERN = /\+?\d[\d\s()\-.]{6,18}\d/g;
20
- var EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
1
+ import { createRequire } from "node:module";
2
+ import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
3
+ import { ATTR_ERROR_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL } from "@opentelemetry/semantic-conventions";
4
+ import { SeverityNumber, logs } from "@opentelemetry/api-logs";
5
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
6
+ import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from "@opentelemetry/core";
7
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
8
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
9
+ import { resourceFromAttributes } from "@opentelemetry/resources";
10
+ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
11
+ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
12
+ //#region src/sanitize.ts
13
+ const PHONE_PATTERN = /\+?\d[\d\s()\-.]{6,18}\d/g;
14
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
15
+ /**
16
+ * Mask a phone number, keeping the leading `+` (if any) plus the first 3 digits
17
+ * and the last 4 digits visible. Example: `+13315553374` -> `+133xxxxx3374`.
18
+ *
19
+ * Inputs that don't have enough digits to safely mask are returned as
20
+ * `xxxx` to avoid leaking the entire short value.
21
+ */
21
22
  function sanitizePhone(input) {
22
- const hasPlus = input.startsWith("+");
23
- const digits = input.replace(/\D/g, "");
24
- if (digits.length < 8) {
25
- return hasPlus ? "+xxxx" : "xxxx";
26
- }
27
- const head = digits.slice(0, 3);
28
- const tail = digits.slice(-4);
29
- const middleLength = digits.length - head.length - tail.length;
30
- return `${hasPlus ? "+" : ""}${head}${"x".repeat(middleLength)}${tail}`;
31
- }
23
+ const hasPlus = input.startsWith("+");
24
+ const digits = input.replace(/\D/g, "");
25
+ if (digits.length < 8) return hasPlus ? "+xxxx" : "xxxx";
26
+ const head = digits.slice(0, 3);
27
+ const tail = digits.slice(-4);
28
+ const middleLength = digits.length - head.length - tail.length;
29
+ return `${hasPlus ? "+" : ""}${head}${"x".repeat(middleLength)}${tail}`;
30
+ }
31
+ /**
32
+ * Mask an email address, keeping the first 2 chars of the local part, the
33
+ * first char of the domain, and the TLD. Example:
34
+ * `foo.bar@example.com` -> `fo***@e***.com`.
35
+ */
32
36
  function sanitizeEmail(input) {
33
- const atIndex = input.lastIndexOf("@");
34
- if (atIndex < 1) {
35
- return "***";
36
- }
37
- const local = input.slice(0, atIndex);
38
- const domain = input.slice(atIndex + 1);
39
- const dotIndex = domain.lastIndexOf(".");
40
- if (dotIndex < 1) {
41
- return "***";
42
- }
43
- const localHead = local.slice(0, 2);
44
- const domainHead = domain.slice(0, 1);
45
- const tld = domain.slice(dotIndex);
46
- return `${localHead}***@${domainHead}***${tld}`;
47
- }
37
+ const atIndex = input.lastIndexOf("@");
38
+ if (atIndex < 1) return "***";
39
+ const local = input.slice(0, atIndex);
40
+ const domain = input.slice(atIndex + 1);
41
+ const dotIndex = domain.lastIndexOf(".");
42
+ if (dotIndex < 1) return "***";
43
+ return `${local.slice(0, 2)}***@${domain.slice(0, 1)}***${domain.slice(dotIndex)}`;
44
+ }
45
+ /**
46
+ * Replace every phone number and email address inside a free-form string with
47
+ * its sanitized form. Used to scrub `Error.message` values before attaching
48
+ * them to span status.
49
+ */
48
50
  function sanitizeErrorMessage(input) {
49
- return input.replace(EMAIL_PATTERN, (match) => sanitizeEmail(match)).replace(PHONE_PATTERN, (match) => sanitizePhone(match));
50
- }
51
-
52
- // src/version.ts
53
- var PHOTON_OTEL_VERSION = "0.1.0";
54
-
55
- // src/instrument-fetch.ts
56
- var PATCH_MARKER = /* @__PURE__ */ Symbol.for("@photon-ai/otel.fetch.original");
57
- var HTTP_ERROR_STATUS_MIN = 400;
58
- var DEFAULT_PORTS = { "https:": 443, "http:": 80 };
59
- var scopedTracer;
60
- function getTracer() {
61
- if (!scopedTracer) {
62
- scopedTracer = trace.getTracer("@photon-ai/otel", PHOTON_OTEL_VERSION);
63
- }
64
- return scopedTracer;
51
+ return input.replace(EMAIL_PATTERN, (match) => sanitizeEmail(match)).replace(PHONE_PATTERN, (match) => sanitizePhone(match));
52
+ }
53
+ //#endregion
54
+ //#region src/version.ts
55
+ const PHOTON_OTEL_VERSION = "1.1.0";
56
+ //#endregion
57
+ //#region src/instrument-fetch.ts
58
+ /**
59
+ * Stored on the wrapper via the global symbol registry (`Symbol.for`) so the
60
+ * double-wrap guard holds even when two copies of this module load — which can
61
+ * happen because the `bun` export condition serves `src/` while `default`
62
+ * serves `dist/`.
63
+ */
64
+ const PATCH_MARKER = Symbol.for("@photon-ai/otel.fetch.original");
65
+ const HTTP_ERROR_STATUS_MIN = 400;
66
+ const DEFAULT_PORTS = {
67
+ "https:": 443,
68
+ "http:": 80
69
+ };
70
+ let scopedTracer$1;
71
+ function getTracer$1() {
72
+ if (!scopedTracer$1) scopedTracer$1 = trace.getTracer("@photon-ai/otel", PHOTON_OTEL_VERSION);
73
+ return scopedTracer$1;
65
74
  }
66
75
  function setGlobalFetch(fn) {
67
- globalThis.fetch = fn;
76
+ globalThis.fetch = fn;
68
77
  }
69
78
  function getPatchOriginal(fn) {
70
- return fn[PATCH_MARKER];
79
+ return fn[PATCH_MARKER];
71
80
  }
72
81
  function setPatchOriginal(fn, original) {
73
- fn[PATCH_MARKER] = original;
82
+ fn[PATCH_MARKER] = original;
74
83
  }
84
+ /** Copy extra own properties (e.g. Bun's `fetch.preconnect`) onto the wrapper. */
75
85
  function preserveProps(from, to) {
76
- for (const key of Object.getOwnPropertyNames(from)) {
77
- if (key in to) {
78
- continue;
79
- }
80
- const descriptor = Object.getOwnPropertyDescriptor(from, key);
81
- if (descriptor) {
82
- Object.defineProperty(to, key, descriptor);
83
- }
84
- }
86
+ for (const key of Object.getOwnPropertyNames(from)) {
87
+ if (key in to) continue;
88
+ const descriptor = Object.getOwnPropertyDescriptor(from, key);
89
+ if (descriptor) Object.defineProperty(to, key, descriptor);
90
+ }
85
91
  }
86
92
  function resolveRequestMeta(input, init) {
87
- if (input instanceof Request) {
88
- return { method: input.method, url: input.url };
89
- }
90
- const url = typeof input === "string" ? input : input.toString();
91
- return { method: init?.method ?? "GET", url };
93
+ if (input instanceof Request) return {
94
+ method: input.method,
95
+ url: input.url
96
+ };
97
+ const url = typeof input === "string" ? input : input.toString();
98
+ return {
99
+ method: init?.method ?? "GET",
100
+ url
101
+ };
92
102
  }
93
103
  function resolvePort(parsed) {
94
- if (parsed.port) {
95
- return Number(parsed.port);
96
- }
97
- return DEFAULT_PORTS[parsed.protocol];
104
+ if (parsed.port) return Number(parsed.port);
105
+ return DEFAULT_PORTS[parsed.protocol];
98
106
  }
99
- function toAttributes(attrs) {
100
- const out = {};
101
- for (const [key, value] of Object.entries(attrs)) {
102
- if (value !== void 0) {
103
- out[key] = value;
104
- }
105
- }
106
- return out;
107
+ function toAttributes$1(attrs) {
108
+ const out = {};
109
+ for (const [key, value] of Object.entries(attrs)) if (value !== void 0) out[key] = value;
110
+ return out;
107
111
  }
108
112
  function fetchAttributes(method, url) {
109
- const attrs = {
110
- [ATTR_HTTP_REQUEST_METHOD]: method,
111
- [ATTR_URL_FULL]: url
112
- };
113
- try {
114
- const parsed = new URL(url);
115
- attrs[ATTR_SERVER_ADDRESS] = parsed.hostname || void 0;
116
- attrs[ATTR_SERVER_PORT] = resolvePort(parsed);
117
- } catch {
118
- }
119
- return toAttributes(attrs);
120
- }
113
+ const attrs = {
114
+ [ATTR_HTTP_REQUEST_METHOD]: method,
115
+ [ATTR_URL_FULL]: url
116
+ };
117
+ try {
118
+ const parsed = new URL(url);
119
+ attrs[ATTR_SERVER_ADDRESS] = parsed.hostname || void 0;
120
+ attrs[ATTR_SERVER_PORT] = resolvePort(parsed);
121
+ } catch {}
122
+ return toAttributes$1(attrs);
123
+ }
124
+ /** Build the outgoing headers and inject the active trace context into them. */
121
125
  function buildPropagatedHeaders(input, init) {
122
- const headers = new Headers(
123
- input instanceof Request ? input.headers : void 0
124
- );
125
- if (init?.headers) {
126
- for (const [key, value] of new Headers(init.headers).entries()) {
127
- headers.set(key, value);
128
- }
129
- }
130
- propagation.inject(context.active(), headers, {
131
- set: (carrier, key, value) => {
132
- carrier.set(key, value);
133
- }
134
- });
135
- return headers;
126
+ const headers = new Headers(input instanceof Request ? input.headers : void 0);
127
+ if (init?.headers) for (const [key, value] of new Headers(init.headers).entries()) headers.set(key, value);
128
+ propagation.inject(context.active(), headers, { set: (carrier, key, value) => {
129
+ carrier.set(key, value);
130
+ } });
131
+ return headers;
136
132
  }
137
133
  function callOriginal(original, input, init, headers) {
138
- if (input instanceof Request) {
139
- if (input.bodyUsed) {
140
- for (const [key, value] of headers.entries()) {
141
- input.headers.set(key, value);
142
- }
143
- return original(input, init);
144
- }
145
- return original(new Request(input, { ...init, headers }));
146
- }
147
- return original(input, { ...init, headers });
134
+ if (input instanceof Request) {
135
+ if (input.bodyUsed) {
136
+ for (const [key, value] of headers.entries()) input.headers.set(key, value);
137
+ return original(input, init);
138
+ }
139
+ return original(new Request(input, {
140
+ ...init,
141
+ headers
142
+ }));
143
+ }
144
+ return original(input, {
145
+ ...init,
146
+ headers
147
+ });
148
148
  }
149
149
  function buildWrappedFetch(original, options) {
150
- return (input, init) => {
151
- const { method, url } = resolveRequestMeta(input, init);
152
- if (options?.ignore?.(url)) {
153
- return original(input, init);
154
- }
155
- const name = method.toUpperCase();
156
- return getTracer().startActiveSpan(
157
- name,
158
- { kind: SpanKind.CLIENT },
159
- async (span) => {
160
- span.setAttributes(fetchAttributes(name, url));
161
- try {
162
- const headers = buildPropagatedHeaders(input, init);
163
- const response = await callOriginal(original, input, init, headers);
164
- span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
165
- span.setStatus({
166
- code: response.status >= HTTP_ERROR_STATUS_MIN ? SpanStatusCode.ERROR : SpanStatusCode.OK
167
- });
168
- return response;
169
- } catch (err) {
170
- span.recordException(err);
171
- const errorObj = err instanceof Error ? err : void 0;
172
- span.setAttribute(
173
- ATTR_ERROR_TYPE,
174
- errorObj?.constructor.name ?? typeof err
175
- );
176
- span.setStatus({
177
- code: SpanStatusCode.ERROR,
178
- message: errorObj ? sanitizeErrorMessage(errorObj.message) : sanitizeErrorMessage(String(err))
179
- });
180
- throw err;
181
- } finally {
182
- span.end();
183
- }
184
- }
185
- );
186
- };
187
- }
150
+ const staticAttributes = options?.attributes;
151
+ return (input, init) => {
152
+ const { method, url } = resolveRequestMeta(input, init);
153
+ if (options?.ignore?.(url)) return original(input, init);
154
+ const name = method.toUpperCase();
155
+ return getTracer$1().startActiveSpan(name, { kind: SpanKind.CLIENT }, async (span) => {
156
+ if (staticAttributes) span.setAttributes(staticAttributes);
157
+ span.setAttributes(fetchAttributes(name, url));
158
+ try {
159
+ const response = await callOriginal(original, input, init, buildPropagatedHeaders(input, init));
160
+ span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
161
+ span.setStatus({ code: response.status >= HTTP_ERROR_STATUS_MIN ? SpanStatusCode.ERROR : SpanStatusCode.OK });
162
+ return response;
163
+ } catch (err) {
164
+ span.recordException(err);
165
+ const errorObj = err instanceof Error ? err : void 0;
166
+ span.setAttribute(ATTR_ERROR_TYPE, errorObj?.constructor.name ?? typeof err);
167
+ span.setStatus({
168
+ code: SpanStatusCode.ERROR,
169
+ message: errorObj ? sanitizeErrorMessage(errorObj.message) : sanitizeErrorMessage(String(err))
170
+ });
171
+ throw err;
172
+ } finally {
173
+ span.end();
174
+ }
175
+ });
176
+ };
177
+ }
178
+ /**
179
+ * Wrap a single fetch function — not `globalThis.fetch` — so requests made
180
+ * through the RETURNED fetch produce a CLIENT span and carry W3C trace context
181
+ * to the downstream service.
182
+ *
183
+ * Built for SDKs that accept a `fetch` option, e.g.
184
+ * `new OpenAI({ fetch: createInstrumentedFetch() })`. Unlike `instrumentFetch`,
185
+ * it never mutates the global and has no lifecycle to unpatch — it just returns
186
+ * a new fetch you pass where you need it.
187
+ *
188
+ * `baseFetch` defaults to the current `globalThis.fetch`, read at call time.
189
+ * Idempotent: passing an already-instrumented fetch returns it unchanged.
190
+ *
191
+ * Always uses the global-wrap technique (the native undici instrumentation
192
+ * cannot target a single instance), so it behaves identically on Bun and Node.
193
+ * On Node, if `setupOtel`'s global fetch instrumentation is also active, the
194
+ * SDK's request is captured twice — disable it (`instrumentFetch: false`) for
195
+ * paths you instrument per-instance.
196
+ */
197
+ function createInstrumentedFetch(baseFetch = globalThis.fetch, options) {
198
+ if (getPatchOriginal(baseFetch)) return baseFetch;
199
+ const wrapped = buildWrappedFetch(baseFetch, options);
200
+ preserveProps(baseFetch, wrapped);
201
+ setPatchOriginal(wrapped, baseFetch);
202
+ return wrapped;
203
+ }
204
+ /**
205
+ * Wrap `globalThis.fetch` so every outbound request produces a CLIENT span and
206
+ * carries W3C trace context to the downstream service.
207
+ *
208
+ * On Bun this is the only fetch instrumentation that works: Bun's native fetch
209
+ * emits no `diagnostics_channel` events, so the standard `instrumentation-undici`
210
+ * / `instrumentation-http` (and `opentelemetry-instrumentation-fetch-node`,
211
+ * which is itself diagnostics_channel-based) produce no spans. It works
212
+ * identically on Node, where `globalThis.fetch` is undici-backed.
213
+ *
214
+ * Idempotent: a second call does not stack another wrapper. Returns a handle
215
+ * whose `unpatch()` restores the original fetch.
216
+ */
188
217
  function instrumentFetch(options) {
189
- const current = globalThis.fetch;
190
- const existingOriginal = getPatchOriginal(current);
191
- if (existingOriginal) {
192
- return {
193
- unpatch() {
194
- if (globalThis.fetch === current) {
195
- setGlobalFetch(existingOriginal);
196
- }
197
- }
198
- };
199
- }
200
- const original = current;
201
- const wrapped = buildWrappedFetch(original, options);
202
- preserveProps(original, wrapped);
203
- setPatchOriginal(wrapped, original);
204
- setGlobalFetch(wrapped);
205
- return {
206
- unpatch() {
207
- if (globalThis.fetch === wrapped) {
208
- setGlobalFetch(original);
209
- }
210
- }
211
- };
212
- }
213
-
214
- // src/logger.ts
215
- import { context as otelContext } from "@opentelemetry/api";
216
- import { logs, SeverityNumber } from "@opentelemetry/api-logs";
217
- var LEVEL_SEVERITY = {
218
- debug: SeverityNumber.DEBUG,
219
- // 5
220
- info: SeverityNumber.INFO,
221
- // 9
222
- warn: SeverityNumber.WARN,
223
- // 13
224
- error: SeverityNumber.ERROR,
225
- // 17
226
- silent: Number.POSITIVE_INFINITY
218
+ const current = globalThis.fetch;
219
+ const existingOriginal = getPatchOriginal(current);
220
+ if (existingOriginal) return { unpatch() {
221
+ if (globalThis.fetch === current) setGlobalFetch(existingOriginal);
222
+ } };
223
+ const original = current;
224
+ const wrapped = createInstrumentedFetch(original, options);
225
+ setGlobalFetch(wrapped);
226
+ return { unpatch() {
227
+ if (globalThis.fetch === wrapped) setGlobalFetch(original);
228
+ } };
229
+ }
230
+ //#endregion
231
+ //#region src/logger.ts
232
+ const LEVEL_SEVERITY = {
233
+ debug: SeverityNumber.DEBUG,
234
+ info: SeverityNumber.INFO,
235
+ warn: SeverityNumber.WARN,
236
+ error: SeverityNumber.ERROR,
237
+ silent: Number.POSITIVE_INFINITY
227
238
  };
228
- var levelOverride;
239
+ let levelOverride;
229
240
  function envLevel() {
230
- const raw = process.env.LOG_LEVEL?.toLowerCase();
231
- if (raw && raw in LEVEL_SEVERITY) {
232
- return raw;
233
- }
234
- return;
241
+ const raw = process.env.LOG_LEVEL?.toLowerCase();
242
+ if (raw && raw in LEVEL_SEVERITY) return raw;
235
243
  }
236
244
  function defaultLevel() {
237
- return (process.env.DEPLOYMENT_ENV ?? "development") === "development" ? "debug" : "info";
238
- }
245
+ return (process.env.DEPLOYMENT_ENV ?? "development") === "development" ? "debug" : "info";
246
+ }
247
+ /**
248
+ * Resolve the active level fresh on each call so that `LOG_LEVEL` changes and
249
+ * `setLogLevel()` both take effect immediately. Resolution order (env wins, to
250
+ * match the rest of the package's config story):
251
+ * 1. `LOG_LEVEL` env var
252
+ * 2. `setLogLevel()` / `setupOtel({ logLevel })`
253
+ * 3. environment-driven default (`debug` in development, `info` otherwise)
254
+ */
239
255
  function resolveLevel() {
240
- return envLevel() ?? levelOverride ?? defaultLevel();
256
+ return envLevel() ?? levelOverride ?? defaultLevel();
241
257
  }
258
+ /**
259
+ * Programmatically set the minimum log level. Takes effect immediately for
260
+ * subsequent logs. `LOG_LEVEL` env var still wins if set.
261
+ */
242
262
  function setLogLevel(level) {
243
- levelOverride = level;
263
+ levelOverride = level;
244
264
  }
265
+ /** Current effective log level, after env / override / default resolution. */
245
266
  function getLogLevel() {
246
- return resolveLevel();
267
+ return resolveLevel();
247
268
  }
248
- var scopedLogger;
269
+ let scopedLogger;
249
270
  function getLogger() {
250
- if (!scopedLogger) {
251
- scopedLogger = logs.getLogger("@photon-ai/otel", PHOTON_OTEL_VERSION);
252
- }
253
- return scopedLogger;
271
+ if (!scopedLogger) scopedLogger = logs.getLogger("@photon-ai/otel", PHOTON_OTEL_VERSION);
272
+ return scopedLogger;
254
273
  }
255
274
  function filterUndefined(attrs) {
256
- if (!attrs) {
257
- return {};
258
- }
259
- const out = {};
260
- for (const [k, v] of Object.entries(attrs)) {
261
- if (v !== void 0) {
262
- out[k] = v;
263
- }
264
- }
265
- return out;
275
+ if (!attrs) return {};
276
+ const out = {};
277
+ for (const [k, v] of Object.entries(attrs)) if (v !== void 0) out[k] = v;
278
+ return out;
266
279
  }
267
280
  function consoleFor(severityNumber) {
268
- if (severityNumber >= SeverityNumber.ERROR) {
269
- return console.error;
270
- }
271
- if (severityNumber >= SeverityNumber.WARN) {
272
- return console.warn;
273
- }
274
- if (severityNumber >= SeverityNumber.INFO) {
275
- return console.info;
276
- }
277
- return console.debug;
281
+ if (severityNumber >= SeverityNumber.ERROR) return console.error;
282
+ if (severityNumber >= SeverityNumber.WARN) return console.warn;
283
+ if (severityNumber >= SeverityNumber.INFO) return console.info;
284
+ return console.debug;
278
285
  }
279
286
  function emit(severityNumber, severityText, module, message, attrs, error) {
280
- if (severityNumber < LEVEL_SEVERITY[resolveLevel()]) {
281
- return;
282
- }
283
- const userAttrs = filterUndefined(attrs);
284
- const attributes = {
285
- "log.module": module,
286
- ...userAttrs
287
- };
288
- if (error instanceof Error) {
289
- attributes["exception.type"] = error.name;
290
- attributes["exception.message"] = error.message;
291
- if (error.stack) {
292
- attributes["exception.stacktrace"] = error.stack;
293
- }
294
- } else if (error !== void 0) {
295
- attributes["exception.type"] = typeof error;
296
- attributes["exception.message"] = String(error);
297
- }
298
- getLogger().emit({
299
- severityNumber,
300
- severityText,
301
- body: message,
302
- attributes,
303
- context: otelContext.active()
304
- });
305
- const extras = [];
306
- if (Object.keys(userAttrs).length > 0) {
307
- extras.push(userAttrs);
308
- }
309
- if (error !== void 0) {
310
- extras.push(error);
311
- }
312
- consoleFor(severityNumber)(`[${module}]`, severityText, message, ...extras);
287
+ if (severityNumber < LEVEL_SEVERITY[resolveLevel()]) return;
288
+ const userAttrs = filterUndefined(attrs);
289
+ const attributes = {
290
+ "log.module": module,
291
+ ...userAttrs
292
+ };
293
+ if (error instanceof Error) {
294
+ attributes["exception.type"] = error.name;
295
+ attributes["exception.message"] = error.message;
296
+ if (error.stack) attributes["exception.stacktrace"] = error.stack;
297
+ } else if (error !== void 0) {
298
+ attributes["exception.type"] = typeof error;
299
+ attributes["exception.message"] = String(error);
300
+ }
301
+ getLogger().emit({
302
+ severityNumber,
303
+ severityText,
304
+ body: message,
305
+ attributes,
306
+ context: context.active()
307
+ });
308
+ const extras = [];
309
+ if (Object.keys(userAttrs).length > 0) extras.push(userAttrs);
310
+ if (error !== void 0) extras.push(error);
311
+ consoleFor(severityNumber)(`[${module}]`, severityText, message, ...extras);
313
312
  }
314
313
  function createLogger(module) {
315
- return {
316
- debug: (message, attrs, error) => emit(SeverityNumber.DEBUG, "DEBUG", module, message, attrs, error),
317
- info: (message, attrs, error) => emit(SeverityNumber.INFO, "INFO", module, message, attrs, error),
318
- warn: (message, attrs, error) => emit(SeverityNumber.WARN, "WARN", module, message, attrs, error),
319
- error: (message, attrs, error) => emit(SeverityNumber.ERROR, "ERROR", module, message, attrs, error)
320
- };
321
- }
322
-
323
- // src/setup.ts
324
- import { context as context2, propagation as propagation2, trace as trace2 } from "@opentelemetry/api";
325
- import { logs as logs2 } from "@opentelemetry/api-logs";
326
- import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
327
- import {
328
- CompositePropagator,
329
- W3CBaggagePropagator,
330
- W3CTraceContextPropagator
331
- } from "@opentelemetry/core";
332
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
333
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
334
- import { resourceFromAttributes } from "@opentelemetry/resources";
335
- import {
336
- BatchLogRecordProcessor,
337
- LoggerProvider
338
- } from "@opentelemetry/sdk-logs";
339
- import {
340
- BasicTracerProvider,
341
- BatchSpanProcessor
342
- } from "@opentelemetry/sdk-trace-base";
343
- var activeHandle;
344
- var TRAILING_SLASH = /\/$/;
314
+ return {
315
+ debug: (message, attrs, error) => emit(SeverityNumber.DEBUG, "DEBUG", module, message, attrs, error),
316
+ info: (message, attrs, error) => emit(SeverityNumber.INFO, "INFO", module, message, attrs, error),
317
+ warn: (message, attrs, error) => emit(SeverityNumber.WARN, "WARN", module, message, attrs, error),
318
+ error: (message, attrs, error) => emit(SeverityNumber.ERROR, "ERROR", module, message, attrs, error)
319
+ };
320
+ }
321
+ //#endregion
322
+ //#region src/instrument-fetch-native.ts
323
+ /**
324
+ * Reconstruct the absolute URL undici describes from `origin` + `path`, matching
325
+ * how the instrumentation builds `url.full`. This lets the caller's `ignore(url)`
326
+ * predicate (and the OTLP self-trace exclusion) behave identically to the
327
+ * global-wrap path.
328
+ */
329
+ function toAbsoluteUrl(request) {
330
+ try {
331
+ return new URL(request.path, request.origin).toString();
332
+ } catch {
333
+ return `${request.origin}${request.path}`;
334
+ }
335
+ }
336
+ /** Node's "module isn't installed" errors carry one of these messages. */
337
+ const MODULE_NOT_FOUND_MESSAGE = /Cannot find (module|package)/;
338
+ /**
339
+ * True only when `error` signals an optional package being absent, so the caller
340
+ * can safely fall back to the `globalThis.fetch` wrap. A version mismatch or a
341
+ * throw from the package's own initialization is a real failure and must be
342
+ * rethrown rather than masked as "not installed".
343
+ */
344
+ function isModuleNotFoundError(error) {
345
+ if (typeof error !== "object" || error === null) return false;
346
+ const { code, message } = error;
347
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") return true;
348
+ return typeof message === "string" && MODULE_NOT_FOUND_MESSAGE.test(message);
349
+ }
350
+ /**
351
+ * Register `@opentelemetry/instrumentation-undici` — Node's native fetch
352
+ * instrumentation, which reads the global tracer provider and propagator that
353
+ * `setupOtel()` installs. Returns `undefined` when the optional packages aren't
354
+ * installed, or when static `attributes` are requested (the undici path has no
355
+ * hook to stamp them on every span), so the caller can fall back to the
356
+ * `globalThis.fetch` wrap.
357
+ *
358
+ * The packages are referenced only through `requireFn(...)` string calls (never
359
+ * a static `import`), so esbuild can't bundle them and Bun never loads them.
360
+ */
361
+ function instrumentFetchNative(options, requireFn) {
362
+ if (options?.attributes && Object.keys(options.attributes).length > 0) return;
363
+ let UndiciInstrumentation;
364
+ let registerInstrumentations;
365
+ try {
366
+ const undiciModule = requireFn("@opentelemetry/instrumentation-undici");
367
+ const instrumentationModule = requireFn("@opentelemetry/instrumentation");
368
+ UndiciInstrumentation = undiciModule.UndiciInstrumentation;
369
+ registerInstrumentations = instrumentationModule.registerInstrumentations;
370
+ } catch (error) {
371
+ if (isModuleNotFoundError(error)) return;
372
+ throw error;
373
+ }
374
+ const userIgnore = options?.ignore;
375
+ const instrumentation = new UndiciInstrumentation({ ignoreRequestHook: userIgnore ? (request) => userIgnore(toAbsoluteUrl(request)) : void 0 });
376
+ registerInstrumentations({ instrumentations: [instrumentation] });
377
+ return { unpatch() {
378
+ instrumentation.disable();
379
+ } };
380
+ }
381
+ //#endregion
382
+ //#region src/runtime.ts
383
+ /**
384
+ * `true` when running on Bun, `false` on Node (or any other runtime).
385
+ *
386
+ * This is the one place the library detects its runtime. Bun's native `fetch`
387
+ * emits no `diagnostics_channel` events, so the official OpenTelemetry
388
+ * instrumentations (`instrumentation-undici` / `-http`) produce no spans there
389
+ * — we must wrap `globalThis.fetch` instead. On Node we prefer the native
390
+ * undici instrumentation. `setupOtel()` branches on this constant.
391
+ *
392
+ * `process.versions.bun` is the canonical signal: it survives deletion of the
393
+ * `Bun` global and matches the codebase's `process.*` convention. Evaluated
394
+ * once at module load — the runtime never changes mid-process.
395
+ */
396
+ const IS_BUN = typeof process !== "undefined" && process.versions?.bun !== void 0;
397
+ //#endregion
398
+ //#region src/setup.ts
399
+ let activeHandle;
400
+ const TRAILING_SLASH = /\/$/;
345
401
  function parseEnvHeaders(raw) {
346
- if (!raw) {
347
- return {};
348
- }
349
- const out = {};
350
- for (const pair of raw.split(",")) {
351
- const eq = pair.indexOf("=");
352
- if (eq <= 0) {
353
- continue;
354
- }
355
- const key = pair.slice(0, eq).trim();
356
- const value = pair.slice(eq + 1).trim();
357
- if (key) {
358
- out[key] = value;
359
- }
360
- }
361
- return out;
402
+ if (!raw) return {};
403
+ const out = {};
404
+ for (const pair of raw.split(",")) {
405
+ const eq = pair.indexOf("=");
406
+ if (eq <= 0) continue;
407
+ const key = pair.slice(0, eq).trim();
408
+ const value = pair.slice(eq + 1).trim();
409
+ if (key) out[key] = value;
410
+ }
411
+ return out;
362
412
  }
363
413
  function resolveTracesEndpoint(base) {
364
- const traces = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
365
- if (traces) {
366
- return traces;
367
- }
368
- const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? base;
369
- return generic ? `${generic.replace(TRAILING_SLASH, "")}/v1/traces` : void 0;
414
+ const traces = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
415
+ if (traces) return traces;
416
+ const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? base;
417
+ return generic ? `${generic.replace(TRAILING_SLASH, "")}/v1/traces` : void 0;
370
418
  }
371
419
  function resolveLogsEndpoint(base) {
372
- const logsEndpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;
373
- if (logsEndpoint) {
374
- return logsEndpoint;
375
- }
376
- const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? base;
377
- return generic ? `${generic.replace(TRAILING_SLASH, "")}/v1/logs` : void 0;
378
- }
420
+ const logsEndpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;
421
+ if (logsEndpoint) return logsEndpoint;
422
+ const generic = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? base;
423
+ return generic ? `${generic.replace(TRAILING_SLASH, "")}/v1/logs` : void 0;
424
+ }
425
+ /**
426
+ * Normalize a URL to an `origin + path` key (trailing slash stripped) for exact
427
+ * self-trace matching. Returns `undefined` for unparseable URLs.
428
+ */
379
429
  function otlpEndpointKey(url) {
380
- try {
381
- const parsed = new URL(url);
382
- return `${parsed.origin}${parsed.pathname.replace(TRAILING_SLASH, "")}`;
383
- } catch {
384
- return;
385
- }
430
+ try {
431
+ const parsed = new URL(url);
432
+ return `${parsed.origin}${parsed.pathname.replace(TRAILING_SLASH, "")}`;
433
+ } catch {
434
+ return;
435
+ }
386
436
  }
387
437
  function otlpEndpointKeysOf(tracesEndpoint, logsEndpoint) {
388
- const keys = [];
389
- for (const endpoint of [tracesEndpoint, logsEndpoint]) {
390
- if (!endpoint) {
391
- continue;
392
- }
393
- const key = otlpEndpointKey(endpoint);
394
- if (key) {
395
- keys.push(key);
396
- }
397
- }
398
- return keys;
399
- }
438
+ const keys = [];
439
+ for (const endpoint of [tracesEndpoint, logsEndpoint]) {
440
+ if (!endpoint) continue;
441
+ const key = otlpEndpointKey(endpoint);
442
+ if (key) keys.push(key);
443
+ }
444
+ return keys;
445
+ }
446
+ /**
447
+ * Start fetch instrumentation unless disabled. Defaults to on when a traces
448
+ * pipeline is configured. On Node (mode `"auto"`) this registers the native
449
+ * `@opentelemetry/instrumentation-undici`; on Bun, or with mode `"global"`, it
450
+ * wraps `globalThis.fetch`. Always excludes our own OTLP endpoints so the
451
+ * exporter's traffic is never self-traced (matters on Node, where the OTLP
452
+ * exporter can use fetch).
453
+ */
400
454
  function startFetchInstrumentation(option, hasTraces, tracesEndpoint, logsEndpoint) {
401
- const want = option ?? hasTraces;
402
- if (!want) {
403
- return;
404
- }
405
- const userOptions = typeof option === "object" ? option : void 0;
406
- const otlpEndpointKeys = otlpEndpointKeysOf(tracesEndpoint, logsEndpoint);
407
- return instrumentFetch({
408
- ignore: (url) => {
409
- const key = otlpEndpointKey(url);
410
- const isOtlpEndpoint = key !== void 0 && otlpEndpointKeys.includes(key);
411
- return isOtlpEndpoint || (userOptions?.ignore?.(url) ?? false);
412
- }
413
- });
414
- }
455
+ if (!(option ?? hasTraces)) return;
456
+ const userOptions = typeof option === "object" ? option : void 0;
457
+ const otlpEndpointKeys = otlpEndpointKeysOf(tracesEndpoint, logsEndpoint);
458
+ const ignore = (url) => {
459
+ const key = otlpEndpointKey(url);
460
+ return key !== void 0 && otlpEndpointKeys.includes(key) || (userOptions?.ignore?.(url) ?? false);
461
+ };
462
+ if ((userOptions?.mode ?? "auto") === "auto" && !IS_BUN) {
463
+ const native = instrumentFetchNative({
464
+ ...userOptions,
465
+ ignore
466
+ }, createRequire(import.meta.url));
467
+ if (native) return native;
468
+ }
469
+ return instrumentFetch({
470
+ ...userOptions,
471
+ ignore
472
+ });
473
+ }
474
+ /**
475
+ * Boot an OTLP/HTTP-based OpenTelemetry pipeline (traces + logs).
476
+ *
477
+ * Idempotent: calling twice in the same process is a no-op on the second
478
+ * call, so libraries can safely invoke this without clobbering an app-level
479
+ * OTel setup that ran earlier.
480
+ *
481
+ * Standard `OTEL_EXPORTER_OTLP_*` env vars override the `endpoint` and
482
+ * `headers` arguments — this matches the OpenTelemetry SDK config spec.
483
+ */
415
484
  function setupOtel(options) {
416
- if (activeHandle) {
417
- return activeHandle;
418
- }
419
- if (options.logLevel) {
420
- setLogLevel(options.logLevel);
421
- }
422
- const tracesEndpoint = resolveTracesEndpoint(options.endpoint);
423
- const logsEndpoint = resolveLogsEndpoint(options.endpoint);
424
- const mergedHeaders = {
425
- ...options.headers,
426
- ...parseEnvHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS)
427
- };
428
- const hasHeaders = Object.keys(mergedHeaders).length > 0;
429
- const resource = resourceFromAttributes({
430
- "service.name": options.serviceName,
431
- ...options.serviceVersion ? { "service.version": options.serviceVersion } : {},
432
- "deployment.environment": process.env.DEPLOYMENT_ENV ?? "development",
433
- ...options.resourceAttributes
434
- });
435
- context2.setGlobalContextManager(new AsyncLocalStorageContextManager());
436
- propagation2.setGlobalPropagator(
437
- new CompositePropagator({
438
- propagators: [
439
- new W3CTraceContextPropagator(),
440
- new W3CBaggagePropagator()
441
- ]
442
- })
443
- );
444
- const traceProcessors = tracesEndpoint ? [
445
- new BatchSpanProcessor(
446
- new OTLPTraceExporter({
447
- url: tracesEndpoint,
448
- headers: hasHeaders ? mergedHeaders : void 0
449
- })
450
- )
451
- ] : [];
452
- const tracerProvider = new BasicTracerProvider({
453
- resource,
454
- spanProcessors: traceProcessors
455
- });
456
- trace2.setGlobalTracerProvider(tracerProvider);
457
- const fetchInstrumentation = startFetchInstrumentation(
458
- options.instrumentFetch,
459
- traceProcessors.length > 0,
460
- tracesEndpoint,
461
- logsEndpoint
462
- );
463
- const logProcessors = logsEndpoint ? [
464
- new BatchLogRecordProcessor(
465
- new OTLPLogExporter({
466
- url: logsEndpoint,
467
- headers: hasHeaders ? mergedHeaders : void 0
468
- })
469
- )
470
- ] : [];
471
- const loggerProvider = new LoggerProvider({
472
- resource,
473
- processors: logProcessors
474
- });
475
- logs2.setGlobalLoggerProvider(loggerProvider);
476
- const handle = {
477
- async shutdown() {
478
- fetchInstrumentation?.unpatch();
479
- await Promise.allSettled([
480
- tracerProvider.shutdown(),
481
- loggerProvider.shutdown()
482
- ]);
483
- activeHandle = void 0;
484
- }
485
- };
486
- activeHandle = handle;
487
- return handle;
488
- }
485
+ if (activeHandle) return activeHandle;
486
+ if (options.logLevel) setLogLevel(options.logLevel);
487
+ const tracesEndpoint = resolveTracesEndpoint(options.endpoint);
488
+ const logsEndpoint = resolveLogsEndpoint(options.endpoint);
489
+ const mergedHeaders = {
490
+ ...options.headers,
491
+ ...parseEnvHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS)
492
+ };
493
+ const hasHeaders = Object.keys(mergedHeaders).length > 0;
494
+ const resource = resourceFromAttributes({
495
+ "service.name": options.serviceName,
496
+ ...options.serviceVersion ? { "service.version": options.serviceVersion } : {},
497
+ "deployment.environment": process.env.DEPLOYMENT_ENV ?? "development",
498
+ ...options.resourceAttributes
499
+ });
500
+ context.setGlobalContextManager(new AsyncLocalStorageContextManager());
501
+ propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }));
502
+ const traceProcessors = tracesEndpoint ? [new BatchSpanProcessor(new OTLPTraceExporter({
503
+ url: tracesEndpoint,
504
+ headers: hasHeaders ? mergedHeaders : void 0
505
+ }))] : [];
506
+ const tracerProvider = new BasicTracerProvider({
507
+ resource,
508
+ spanProcessors: traceProcessors
509
+ });
510
+ trace.setGlobalTracerProvider(tracerProvider);
511
+ const fetchInstrumentation = startFetchInstrumentation(options.instrumentFetch, traceProcessors.length > 0, tracesEndpoint, logsEndpoint);
512
+ const loggerProvider = new LoggerProvider({
513
+ resource,
514
+ processors: logsEndpoint ? [new BatchLogRecordProcessor(new OTLPLogExporter({
515
+ url: logsEndpoint,
516
+ headers: hasHeaders ? mergedHeaders : void 0
517
+ }))] : []
518
+ });
519
+ logs.setGlobalLoggerProvider(loggerProvider);
520
+ const handle = { async shutdown() {
521
+ fetchInstrumentation?.unpatch();
522
+ await Promise.allSettled([tracerProvider.shutdown(), loggerProvider.shutdown()]);
523
+ activeHandle = void 0;
524
+ } };
525
+ activeHandle = handle;
526
+ return handle;
527
+ }
528
+ /**
529
+ * Read-only accessor for tests / debug paths that need to know whether
530
+ * `setupOtel` has already run in this process.
531
+ */
489
532
  function isOtelActive() {
490
- return activeHandle !== void 0;
533
+ return activeHandle !== void 0;
491
534
  }
492
-
493
- // src/with-span.ts
494
- import {
495
- SpanStatusCode as SpanStatusCode2,
496
- trace as trace3
497
- } from "@opentelemetry/api";
498
- var scopedTracer2;
499
- function getTracer2() {
500
- if (!scopedTracer2) {
501
- scopedTracer2 = trace3.getTracer("@photon-ai/otel", PHOTON_OTEL_VERSION);
502
- }
503
- return scopedTracer2;
504
- }
505
- function toAttributes2(attrs) {
506
- const out = {};
507
- for (const [k, v] of Object.entries(attrs)) {
508
- if (v !== void 0) {
509
- out[k] = v;
510
- }
511
- }
512
- return out;
535
+ //#endregion
536
+ //#region src/with-span.ts
537
+ let scopedTracer;
538
+ function getTracer() {
539
+ if (!scopedTracer) scopedTracer = trace.getTracer("@photon-ai/otel", PHOTON_OTEL_VERSION);
540
+ return scopedTracer;
541
+ }
542
+ function toAttributes(attrs) {
543
+ const out = {};
544
+ for (const [k, v] of Object.entries(attrs)) if (v !== void 0) out[k] = v;
545
+ return out;
513
546
  }
514
547
  function withSpan(name, attrsOrFn, maybeFn) {
515
- const fn = typeof attrsOrFn === "function" ? attrsOrFn : maybeFn;
516
- if (!fn) {
517
- throw new Error("withSpan: function argument is required");
518
- }
519
- const attrs = typeof attrsOrFn === "function" ? void 0 : attrsOrFn;
520
- return getTracer2().startActiveSpan(name, async (span) => {
521
- if (attrs) {
522
- span.setAttributes(toAttributes2(attrs));
523
- }
524
- try {
525
- const result = await fn();
526
- span.setStatus({ code: SpanStatusCode2.OK });
527
- return result;
528
- } catch (err) {
529
- span.recordException(err);
530
- const errorObj = err instanceof Error ? err : void 0;
531
- span.setAttribute("error.type", errorObj?.constructor.name ?? typeof err);
532
- span.setStatus({
533
- code: SpanStatusCode2.ERROR,
534
- message: errorObj ? sanitizeErrorMessage(errorObj.message) : sanitizeErrorMessage(String(err))
535
- });
536
- throw err;
537
- } finally {
538
- span.end();
539
- }
540
- });
541
- }
542
- export {
543
- PHOTON_OTEL_VERSION,
544
- createLogger,
545
- getLogLevel,
546
- instrumentFetch,
547
- isOtelActive,
548
- sanitizeEmail,
549
- sanitizeErrorMessage,
550
- sanitizePhone,
551
- setLogLevel,
552
- setupOtel,
553
- withSpan
554
- };
548
+ const fn = typeof attrsOrFn === "function" ? attrsOrFn : maybeFn;
549
+ if (!fn) throw new Error("withSpan: function argument is required");
550
+ const attrs = typeof attrsOrFn === "function" ? void 0 : attrsOrFn;
551
+ return getTracer().startActiveSpan(name, async (span) => {
552
+ if (attrs) span.setAttributes(toAttributes(attrs));
553
+ try {
554
+ const result = await fn();
555
+ span.setStatus({ code: SpanStatusCode.OK });
556
+ return result;
557
+ } catch (err) {
558
+ span.recordException(err);
559
+ const errorObj = err instanceof Error ? err : void 0;
560
+ span.setAttribute("error.type", errorObj?.constructor.name ?? typeof err);
561
+ span.setStatus({
562
+ code: SpanStatusCode.ERROR,
563
+ message: errorObj ? sanitizeErrorMessage(errorObj.message) : sanitizeErrorMessage(String(err))
564
+ });
565
+ throw err;
566
+ } finally {
567
+ span.end();
568
+ }
569
+ });
570
+ }
571
+ //#endregion
572
+ export { PHOTON_OTEL_VERSION, createInstrumentedFetch, createLogger, getLogLevel, instrumentFetch, isOtelActive, sanitizeEmail, sanitizeErrorMessage, sanitizePhone, setLogLevel, setupOtel, withSpan };
573
+
555
574
  //# sourceMappingURL=index.js.map