Skip to content

Commit 5b1ade4

Browse files
feat(scope)!: add scoped mode; remove OTEL_INSTRUMENT_FETCH env var (#11)
Add a scoped/embeddable mode to `setupOtel()` via a new `register: false` option, backed by an internal provider holder (`src/scope.ts`). In scoped mode the library builds and holds its own tracer/logger providers and routes `withSpan` / `createLogger` / `createInstrumentedFetch` through them, leaving the host app's global OpenTelemetry untouched. The shared context manager and W3C propagator are still installed if absent (needed for span nesting and trace propagation), and auto fetch instrumentation defaults off in scoped mode (it wraps the process-global `fetch`). `OtelHandle` now also exposes `tracerProvider` / `loggerProvider` so embedders can build extra tracers or attach processors. Remove the `OTEL_INSTRUMENT_FETCH` environment variable (shipped in 2.1.0). It only toggled the process-global fetch wrap on/off and never addressed the underlying provider-takeover problem that scoped mode now solves. Fetch instrumentation is still controlled by the `setupOtel({ instrumentFetch })` option, and individual clients can be traced with `createInstrumentedFetch()`. BREAKING CHANGE: the OTEL_INSTRUMENT_FETCH environment variable is removed. Use the `instrumentFetch` option to toggle fetch instrumentation, or `register: false` for scoped mode.
1 parent 74ed619 commit 5b1ade4

15 files changed

Lines changed: 477 additions & 378 deletions

.github/workflows/integration.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,6 @@ jobs:
3131
node-version: ${{ matrix.node-version }}
3232
- run: bun install --frozen-lockfile
3333

34-
# The OTEL_INSTRUMENT_FETCH=false case runs in a child process that imports
35-
# the built bundle (so it runs unchanged under both node and bun).
36-
- name: Build
37-
run: bun run build
38-
3934
- name: Start OTel Collector
4035
working-directory: tests/integration
4136
run: |

README.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ If `OTEL_EXPORTER_OTLP_ENDPOINT` (or the `endpoint` option) is unset, `setupOtel
4747

4848
| Function | Description |
4949
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
50-
| `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs. Idempotent. Returns `{ shutdown(): Promise<void> }`. |
50+
| `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs. Idempotent. Returns `{ shutdown(), tracerProvider, loggerProvider }`. Pass `register: false` for scoped mode (no global takeover). |
5151
| `isOtelActive(): boolean` | Returns `true` if `setupOtel` has already run in this process. |
5252
| `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. |
5353
| `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. |
@@ -113,7 +113,6 @@ Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`:
113113
| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value,key=value` headers; merged with `options.headers` (env wins). |
114114
| `DEPLOYMENT_ENV` | Attached as `deployment.environment` resource attribute. Defaults to `development`. Also drives the default log level. |
115115
| `LOG_LEVEL` | Minimum log level: `debug` \| `info` \| `warn` \| `error` \| `silent`. Overrides `setLogLevel()` / `setupOtel({ logLevel })`. |
116-
| `OTEL_INSTRUMENT_FETCH` | Toggle outbound `fetch` tracing: `true` / `1` on, `false` / `0` off. Overrides `instrumentFetch` and the default. |
117116
118117
## Automatic fetch instrumentation
119118
@@ -137,8 +136,7 @@ The strategy depends on the runtime (`mode: "auto"`, the default):
137136
Options (`instrumentFetch`):
138137
139138
- **`true` / `false`:** force on (even without an endpoint) / off. Defaults to on when a traces
140-
endpoint is configured. The `OTEL_INSTRUMENT_FETCH` env var overrides this (`true`/`1` to force on,
141-
`false`/`0` to disable) — toggle fetch tracing in production without a code change.
139+
endpoint is configured.
142140
- **`mode`:** `"auto"` (default — native on Node, wrap on Bun) or `"global"` (wrap on both runtimes).
143141
Choose `"global"` when you want identical spans everywhere and the built-in PII scrubbing of error
144142
messages kept on Node (see caveats).
@@ -193,6 +191,36 @@ SDKs per-instance, disable the global path with `setupOtel({ instrumentFetch: fa
193191
per-instance wrapping for SDKs you accept doubling on). **Bun has no such issue** — its global wrap only
194192
affects `globalThis.fetch`, so a separately-passed instrumented fetch is counted once.
195193
194+
## Scoped mode (embedding in a library)
195+
196+
By default `setupOtel()` registers the process-global OpenTelemetry tracer/logger providers — the
197+
convenient app-level setup. If you're building a **library** that ships its own telemetry, that would
198+
take over the host application's OpenTelemetry. Pass `register: false` to run **scoped**:
199+
200+
```ts
201+
const otel = setupOtel({ serviceName: "my-lib", register: false });
202+
203+
// withSpan / createLogger emit into the library's own providers...
204+
await withSpan("work", async () => {
205+
/* ... */
206+
});
207+
// ...and the host app's global tracer/logger providers are left untouched.
208+
```
209+
210+
In scoped mode:
211+
212+
- **No global takeover.** `setupOtel()` does not call `setGlobalTracerProvider` / `setGlobalLoggerProvider`;
213+
the library's spans and logs flow to its own providers while the host keeps its global OTel.
214+
- **The top-level helpers still work** — `withSpan`, `createLogger`, and `createInstrumentedFetch` resolve
215+
through the library's providers automatically.
216+
- **Shared context is preserved.** A W3C propagator and an `AsyncLocalStorageContextManager` are installed
217+
only if absent, so span nesting and `traceparent` propagation work — and if the host already set them, the
218+
library shares the host's (spans nest across the boundary).
219+
- **Auto fetch instrumentation defaults off** (wrapping `globalThis.fetch` is process-wide, and native undici
220+
can only read the global provider). Trace a specific client with `createInstrumentedFetch()` instead.
221+
- **The handle exposes the providers** — `otel.tracerProvider` / `otel.loggerProvider` — if you need to build
222+
extra tracers or wire additional processors.
223+
196224
## Running on Node vs Bun
197225
198226
The same code runs unmodified on both. Pick whichever you prefer:

docs/configuration.mdx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,6 @@ setupOtel({
183183
});
184184
```
185185

186-
Or disable it from the environment — no code change or redeploy of app logic required:
187-
188-
```bash
189-
OTEL_INSTRUMENT_FETCH=false
190-
```
191-
192-
`OTEL_INSTRUMENT_FETCH` accepts `true` / `1` (force on) and `false` / `0` (disable), and takes precedence over both the `instrumentFetch` option and the default. The object form (`mode`, `ignore`) still configures _how_ fetch is traced whenever instrumentation is on.
193-
194186
You can force it on even without an exporter endpoint:
195187

196188
```ts
@@ -236,7 +228,6 @@ The package always excludes its own OTLP trace and log exporter endpoints from f
236228
| OTLP headers | `OTEL_EXPORTER_OTLP_HEADERS` | `setupOtel({ headers })` |
237229
| Log level | `LOG_LEVEL` | `setupOtel({ logLevel })` or `setLogLevel()` |
238230
| Deployment environment | `DEPLOYMENT_ENV` | `development` |
239-
| Fetch instrumentation | `OTEL_INSTRUMENT_FETCH` | `setupOtel({ instrumentFetch })` or default |
240231

241232
## Best practices
242233

docs/guides/fetch-instrumentation.mdx

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,6 @@ When a traces endpoint is configured, setup instruments fetch by default. The st
1919

2020
The standalone `instrumentFetch()` export always performs the `globalThis.fetch` wrap, and `createInstrumentedFetch()` wraps a single fetch instance (for SDKs) without touching the global.
2121

22-
## Enabling and disabling
23-
24-
Pass `instrumentFetch: false` to turn it off, or `true` to force it on even without an endpoint:
25-
26-
```ts
27-
setupOtel({ serviceName: "orders-api", instrumentFetch: false });
28-
```
29-
30-
The `OTEL_INSTRUMENT_FETCH` environment variable overrides both the option and the default — `true` / `1` forces it on, `false` / `0` disables it. Because env wins over code (matching the rest of the package's configuration), you can silence noisy or expensive fetch spans in production without changing application code:
31-
32-
```bash
33-
OTEL_INSTRUMENT_FETCH=false
34-
```
35-
36-
The env var only flips the on/off decision; when instrumentation stays on, the object form still applies — `OTEL_INSTRUMENT_FETCH=true` alongside `instrumentFetch: { mode: "global", ignore }` keeps your `mode` and `ignore`.
37-
3822
## What each fetch span contains
3923

4024
For an outbound request, fetch instrumentation creates a span with:
@@ -141,8 +125,6 @@ If fetch is already wrapped by this package, a second call does not stack anothe
141125

142126
The implementation uses a global symbol marker to remember the original fetch. That guard works even if two copies of the package are loaded, such as Bun consuming TypeScript source while another path consumes the built ESM bundle.
143127

144-
`setupOtel()` is idempotent too: the first call wins, so a later `setupOtel({ instrumentFetch: false })` cannot turn off instrumentation that an earlier call already enabled. Decide on the first call, or use `OTEL_INSTRUMENT_FETCH`, which is read whenever setup actually runs.
145-
146128
## Restoring fetch
147129

148130
The returned handle exposes `unpatch()`.

src/instrument-fetch.ts

Lines changed: 37 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import {
44
propagation,
55
SpanKind,
66
SpanStatusCode,
7-
type Tracer,
8-
trace,
97
} from "@opentelemetry/api";
108
import {
119
ATTR_ERROR_TYPE,
@@ -16,6 +14,7 @@ import {
1614
ATTR_URL_FULL,
1715
} from "@opentelemetry/semantic-conventions";
1816
import { sanitizeErrorMessage } from "./sanitize";
17+
import { resolveTracer } from "./scope";
1918
import { PHOTON_OTEL_VERSION } from "./version";
2019

2120
export interface FetchSpanOptions {
@@ -72,15 +71,6 @@ const PATCH_MARKER = Symbol.for("@photon-ai/otel.fetch.original");
7271
const HTTP_ERROR_STATUS_MIN = 400;
7372
const DEFAULT_PORTS: Record<string, number> = { "https:": 443, "http:": 80 };
7473

75-
let scopedTracer: Tracer | undefined;
76-
77-
function getTracer(): Tracer {
78-
if (!scopedTracer) {
79-
scopedTracer = trace.getTracer("@photon-ai/otel", PHOTON_OTEL_VERSION);
80-
}
81-
return scopedTracer;
82-
}
83-
8474
function setGlobalFetch(fn: FetchFn): void {
8575
// `preconnect` (Bun) is copied onto wrappers by preserveProps; the cast just
8676
// tells TypeScript the runtime object satisfies the full `fetch` type.
@@ -201,44 +191,43 @@ function buildWrappedFetch(
201191
return original(input, init);
202192
}
203193
const name = method.toUpperCase();
204-
return getTracer().startActiveSpan(
205-
name,
206-
{ kind: SpanKind.CLIENT },
207-
async (span) => {
208-
if (staticAttributes) {
209-
span.setAttributes(staticAttributes);
210-
}
211-
span.setAttributes(fetchAttributes(name, url));
212-
try {
213-
const headers = buildPropagatedHeaders(input, init);
214-
const response = await callOriginal(original, input, init, headers);
215-
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
216-
span.setStatus({
217-
code:
218-
response.status >= HTTP_ERROR_STATUS_MIN
219-
? SpanStatusCode.ERROR
220-
: SpanStatusCode.OK,
221-
});
222-
return response;
223-
} catch (err) {
224-
span.recordException(err as Error);
225-
const errorObj = err instanceof Error ? err : undefined;
226-
span.setAttribute(
227-
ATTR_ERROR_TYPE,
228-
errorObj?.constructor.name ?? typeof err
229-
);
230-
span.setStatus({
231-
code: SpanStatusCode.ERROR,
232-
message: errorObj
233-
? sanitizeErrorMessage(errorObj.message)
234-
: sanitizeErrorMessage(String(err)),
235-
});
236-
throw err;
237-
} finally {
238-
span.end();
239-
}
194+
return resolveTracer(
195+
"@photon-ai/otel",
196+
PHOTON_OTEL_VERSION
197+
).startActiveSpan(name, { kind: SpanKind.CLIENT }, async (span) => {
198+
if (staticAttributes) {
199+
span.setAttributes(staticAttributes);
200+
}
201+
span.setAttributes(fetchAttributes(name, url));
202+
try {
203+
const headers = buildPropagatedHeaders(input, init);
204+
const response = await callOriginal(original, input, init, headers);
205+
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
206+
span.setStatus({
207+
code:
208+
response.status >= HTTP_ERROR_STATUS_MIN
209+
? SpanStatusCode.ERROR
210+
: SpanStatusCode.OK,
211+
});
212+
return response;
213+
} catch (err) {
214+
span.recordException(err as Error);
215+
const errorObj = err instanceof Error ? err : undefined;
216+
span.setAttribute(
217+
ATTR_ERROR_TYPE,
218+
errorObj?.constructor.name ?? typeof err
219+
);
220+
span.setStatus({
221+
code: SpanStatusCode.ERROR,
222+
message: errorObj
223+
? sanitizeErrorMessage(errorObj.message)
224+
: sanitizeErrorMessage(String(err)),
225+
});
226+
throw err;
227+
} finally {
228+
span.end();
240229
}
241-
);
230+
});
242231
};
243232
}
244233

src/logger.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { context as otelContext } from "@opentelemetry/api";
2-
import { type Logger, logs, SeverityNumber } from "@opentelemetry/api-logs";
2+
import { SeverityNumber } from "@opentelemetry/api-logs";
3+
import { resolveLogger } from "./scope";
34
import { PHOTON_OTEL_VERSION } from "./version";
45

56
export type LogAttrs = Record<string, string | number | boolean | undefined>;
@@ -59,15 +60,6 @@ export function getLogLevel(): LogLevel {
5960
return resolveLevel();
6061
}
6162

62-
let scopedLogger: Logger | undefined;
63-
64-
function getLogger(): Logger {
65-
if (!scopedLogger) {
66-
scopedLogger = logs.getLogger("@photon-ai/otel", PHOTON_OTEL_VERSION);
67-
}
68-
return scopedLogger;
69-
}
70-
7163
function filterUndefined(
7264
attrs?: LogAttrs
7365
): Record<string, string | number | boolean> {
@@ -129,7 +121,7 @@ function emit(
129121
attributes["exception.message"] = String(error);
130122
}
131123

132-
getLogger().emit({
124+
resolveLogger("@photon-ai/otel", PHOTON_OTEL_VERSION).emit({
133125
severityNumber,
134126
severityText,
135127
body: message,

src/scope.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { type Tracer, type TracerProvider, trace } from "@opentelemetry/api";
2+
import {
3+
type Logger,
4+
type LoggerProvider,
5+
logs,
6+
} from "@opentelemetry/api-logs";
7+
8+
/**
9+
* Holds the tracer/logger providers the active `setupOtel()` built. This lets
10+
* the library's own helpers (`withSpan`, `createLogger`, the fetch wrap) emit
11+
* into those providers even in scoped mode (`register: false`), where the
12+
* global OTel provider registry is deliberately left untouched so the library
13+
* can coexist with a host app's own OpenTelemetry setup.
14+
*
15+
* Resolution prefers the held provider and falls back to the global API, so
16+
* helpers used before `setupOtel()` (or after `shutdown()`) behave exactly as
17+
* they did when they read `trace.getTracer()` / `logs.getLogger()` directly.
18+
*/
19+
let heldTracerProvider: TracerProvider | undefined;
20+
let heldLoggerProvider: LoggerProvider | undefined;
21+
22+
export function setActiveProviders(providers: {
23+
tracerProvider: TracerProvider;
24+
loggerProvider: LoggerProvider;
25+
}): void {
26+
heldTracerProvider = providers.tracerProvider;
27+
heldLoggerProvider = providers.loggerProvider;
28+
}
29+
30+
export function clearActiveProviders(): void {
31+
heldTracerProvider = undefined;
32+
heldLoggerProvider = undefined;
33+
}
34+
35+
/**
36+
* The active tracer: the provider `setupOtel()` built when set, else the global
37+
* one. Resolved per call — `getTracer` is memoized inside the provider, so this
38+
* is cheap and never pins a stale provider across setup/shutdown cycles.
39+
*/
40+
export function resolveTracer(name: string, version?: string): Tracer {
41+
return (heldTracerProvider ?? trace.getTracerProvider()).getTracer(
42+
name,
43+
version
44+
);
45+
}
46+
47+
/** The active logger: the held provider when set, else the global one. */
48+
export function resolveLogger(name: string, version?: string): Logger {
49+
return (heldLoggerProvider ?? logs.getLoggerProvider()).getLogger(
50+
name,
51+
version
52+
);
53+
}

0 commit comments

Comments
 (0)