Skip to content

Commit fc0df5d

Browse files
feat(setup): add OTEL_INSTRUMENT_FETCH env var to toggle fetch tracing
Introduces `OTEL_INSTRUMENT_FETCH` (`true`/`1` | `false`/`0`) that overrides both the `instrumentFetch` option and the smart default, matching the rest of the package's env-wins config story. - `parseBooleanEnv` helper mirrors `envLevel()` — defers on unrecognized values so the code option still applies - Integration test covers the disabled case via a child process (`disabled-fetch.child.mjs`) because `setupOtel` is process-global and idempotent; CI workflow now runs `bun run build` first since the child imports the built bundle - Unit tests use a `fetchInstrumentationActive()` helper that checks both the global-wrap marker and the undici diagnostics_channel subscriber, catching the native Node path that never reassigns `globalThis.fetch`
1 parent 191a15b commit fc0df5d

9 files changed

Lines changed: 295 additions & 16 deletions

File tree

.github/workflows/integration.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ 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+
3439
- name: Start OTel Collector
3540
working-directory: tests/integration
3641
run: |

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ 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. |
116117
117118
## Automatic fetch instrumentation
118119
@@ -136,7 +137,8 @@ The strategy depends on the runtime (`mode: "auto"`, the default):
136137
Options (`instrumentFetch`):
137138
138139
- **`true` / `false`:** force on (even without an endpoint) / off. Defaults to on when a traces
139-
endpoint is configured.
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.
140142
- **`mode`:** `"auto"` (default — native on Node, wrap on Bun) or `"global"` (wrap on both runtimes).
141143
Choose `"global"` when you want identical spans everywhere and the built-in PII scrubbing of error
142144
messages kept on Node (see caveats).

docs/configuration.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ 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+
186194
You can force it on even without an exporter endpoint:
187195

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

232241
## Best practices
233242

docs/guides/fetch-instrumentation.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ 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+
2238
## What each fetch span contains
2339

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

126142
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.
127143

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+
128146
## Restoring fetch
129147

130148
The returned handle exposes `unpatch()`.

src/setup.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ export interface SetupOtelOptions {
4848
*
4949
* `true` enables with defaults; pass an object to filter URLs via `ignore`.
5050
* Defaults to enabled when a traces endpoint is configured. Pass `false` to
51-
* disable.
51+
* disable. The `OTEL_INSTRUMENT_FETCH` env var takes precedence (`true`/`1`
52+
* to force on, `false`/`0` to disable), matching the rest of the package's
53+
* env-wins config.
5254
*/
5355
instrumentFetch?: boolean | InstrumentFetchOptions;
5456
/**
@@ -93,6 +95,23 @@ function parseEnvHeaders(raw: string | undefined): Record<string, string> {
9395
return out;
9496
}
9597

98+
/**
99+
* Parse a boolean-ish env var. Returns `undefined` for unset or unrecognized
100+
* values so the caller can fall through to its code option — mirroring how
101+
* `logger.ts`'s `envLevel()` defers on values it doesn't recognize. Accepts
102+
* `true`/`1` and `false`/`0` (case- and whitespace-insensitive).
103+
*/
104+
function parseBooleanEnv(raw: string | undefined): boolean | undefined {
105+
const value = raw?.trim().toLowerCase();
106+
if (value === "true" || value === "1") {
107+
return true;
108+
}
109+
if (value === "false" || value === "0") {
110+
return false;
111+
}
112+
return;
113+
}
114+
96115
function resolveTracesEndpoint(base: string | undefined): string | undefined {
97116
const traces = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
98117
if (traces) {
@@ -145,7 +164,9 @@ function otlpEndpointKeysOf(
145164

146165
/**
147166
* Start fetch instrumentation unless disabled. Defaults to on when a traces
148-
* pipeline is configured. On Node (mode `"auto"`) this registers the native
167+
* pipeline is configured; the `OTEL_INSTRUMENT_FETCH` env var (`true`/`1` |
168+
* `false`/`0`) overrides both the option and that default. On Node (mode
169+
* `"auto"`) this registers the native
149170
* `@opentelemetry/instrumentation-undici`; on Bun, or with mode `"global"`, it
150171
* wraps `globalThis.fetch`. Always excludes our own OTLP endpoints so the
151172
* exporter's traffic is never self-traced (matters on Node, where the OTLP
@@ -157,7 +178,11 @@ function startFetchInstrumentation(
157178
tracesEndpoint: string | undefined,
158179
logsEndpoint: string | undefined
159180
): FetchInstrumentation | undefined {
160-
const want = option ?? hasTraces;
181+
// Env wins over code (and over the smart default), matching the rest of the
182+
// package's config story. The env value only drives the on/off decision; the
183+
// object form below still configures *how* fetch is instrumented when on.
184+
const envWant = parseBooleanEnv(process.env.OTEL_INSTRUMENT_FETCH);
185+
const want = envWant ?? option ?? hasTraces;
161186
if (!want) {
162187
return;
163188
}

tests/integration/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ docker compose up -d
2121
# 2. Wait until it's healthy
2222
curl -sf http://localhost:13133/ >/dev/null && echo healthy
2323

24-
# 3. Run the test from the repo root
24+
# 3. Build, then run the test from the repo root. The build is required: the
25+
# OTEL_INSTRUMENT_FETCH=false case runs in a child process that imports the
26+
# built bundle (dist/) so it runs identically under node and bun.
2527
cd ../..
28+
bun run build
2629
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run test:integration
2730

2831
# (optional) eyeball the raw telemetry the collector received
@@ -48,5 +51,10 @@ unset, so step 3 works without the env var too. Output files land in
4851
- The test tags every span/log with a unique per-run nonce, calls
4952
`handle.shutdown()` to flush the batch processors over the wire, then polls
5053
the output files until its telemetry arrives before asserting.
54+
- The `OTEL_INSTRUMENT_FETCH=false` case is driven by `disabled-fetch.child.mjs`
55+
in a separate process (`setupOtel` is process-global and idempotent, so it
56+
can't share the main run's process). The parent spawns it with the current
57+
runtime and then asserts the collector received the child's control span but
58+
no fetch CLIENT span.
5159

5260
This is the same flow CI runs in `.github/workflows/integration.yml`.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Child process for the integration test's OTEL_INSTRUMENT_FETCH=false case.
2+
//
3+
// setupOtel registers process-global OTel providers and is idempotent, so the
4+
// disabled-fetch scenario can't share a process with the main (enabled) run.
5+
// This standalone process boots its own pipeline with OTEL_INSTRUMENT_FETCH set
6+
// to "false" (by the parent), makes one fetch, and exits — the parent then
7+
// asserts against the collector that no CLIENT fetch span arrived for this run's
8+
// nonce, while the control (parent) span did.
9+
//
10+
// It imports the BUILT bundle (dist) so it runs unchanged under both `node` and
11+
// `bun`; the parent spawns it with process.execPath, i.e. the current runtime,
12+
// so this leg exercises whichever runtime the suite is running on.
13+
14+
import { createServer } from "node:http";
15+
import { setupOtel, withSpan } from "../../dist/index.js";
16+
17+
const nonce = process.env.CHILD_NONCE;
18+
const parentSpanName = process.env.CHILD_PARENT_SPAN;
19+
const fetchMarker = process.env.CHILD_FETCH_MARKER;
20+
21+
async function main() {
22+
const handle = setupOtel({
23+
serviceName: "photon-otel-integration",
24+
resourceAttributes: { "test.nonce": nonce },
25+
});
26+
27+
const target = createServer((_req, res) => {
28+
res.statusCode = 200;
29+
res.end("ok");
30+
});
31+
await new Promise((resolve) => {
32+
target.listen(0, "127.0.0.1", () => resolve());
33+
});
34+
const { port } = target.address();
35+
36+
// The control span proves this run's pipeline delivered to the collector; the
37+
// fetch inside it must NOT produce a CLIENT span, because fetch instrumentation
38+
// is disabled via OTEL_INSTRUMENT_FETCH=false.
39+
await withSpan(parentSpanName, async () => {
40+
const res = await fetch(`http://127.0.0.1:${port}/${fetchMarker}`);
41+
await res.text();
42+
});
43+
44+
await new Promise((resolve) => {
45+
target.close(() => resolve());
46+
});
47+
// Flush the batch processors over the wire before the process exits.
48+
await handle.shutdown();
49+
}
50+
51+
main()
52+
.then(() => process.exit(0))
53+
.catch((err) => {
54+
console.error(err);
55+
process.exit(1);
56+
});

tests/integration/otel-collector.test.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { spawn } from "node:child_process";
12
import { existsSync, readFileSync } from "node:fs";
23
import { createServer } from "node:http";
34
import type { AddressInfo } from "node:net";
@@ -32,6 +33,10 @@ const here = dirname(fileURLToPath(import.meta.url));
3233
const outputDir = process.env.COLLECTOR_OUTPUT_DIR ?? join(here, "output");
3334
const tracesFile = join(outputDir, "traces.json");
3435
const logsFile = join(outputDir, "logs.json");
36+
// The OTEL_INSTRUMENT_FETCH=false case runs in a child process (see below); it
37+
// imports the built bundle, so the integration suite needs `bun run build`.
38+
const childScript = join(here, "disabled-fetch.child.mjs");
39+
const distEntry = join(here, "..", "..", "dist", "index.js");
3540

3641
// Tag this run's telemetry so assertions match exactly our data even if the
3742
// collector's output files contain spans/logs from a previous run.
@@ -40,6 +45,10 @@ const happySpanName = `integration-happy-${nonce}`;
4045
const errorSpanName = `integration-error-${nonce}`;
4146
const fetchParentSpanName = `integration-fetch-parent-${nonce}`;
4247
const fetchMarker = `integration-fetch-${nonce}`;
48+
// Tags for the separate OTEL_INSTRUMENT_FETCH=false child run.
49+
const disabledNonce = `${nonce}-disabled`;
50+
const disabledParentSpanName = `integration-disabled-parent-${nonce}`;
51+
const disabledFetchMarker = `integration-disabled-fetch-${nonce}`;
4352
const rawEmail = "user@example.com";
4453

4554
// --- OTLP/JSON shapes emitted by the collector's file exporter ---------------
@@ -154,7 +163,7 @@ function readLines(file: string): string[] {
154163
.filter((line) => line.length > 0);
155164
}
156165

157-
function readSpans(): CollectedSpan[] {
166+
function readSpans(targetNonce: string = nonce): CollectedSpan[] {
158167
const spans: CollectedSpan[] = [];
159168
for (const line of readLines(tracesFile)) {
160169
let parsed: OtlpTracesData;
@@ -180,9 +189,10 @@ function readSpans(): CollectedSpan[] {
180189
}
181190
}
182191
}
183-
// Scope to THIS run: the file exporter appends across runs, and log bodies
184-
// aren't unique, so filter by the per-run nonce carried on the resource.
185-
return spans.filter((s) => s.resource["test.nonce"] === nonce);
192+
// Scope to a specific run: the file exporter appends across runs, and log
193+
// bodies aren't unique, so filter by the per-run nonce carried on the
194+
// resource. Defaults to the main enabled run; the child run passes its own.
195+
return spans.filter((s) => s.resource["test.nonce"] === targetNonce);
186196
}
187197

188198
function readLogs(): CollectedLog[] {
@@ -242,14 +252,52 @@ function isErrorStatus(status: OtlpStatus | undefined): boolean {
242252

243253
let spans: CollectedSpan[] = [];
244254
let logRecords: CollectedLog[] = [];
255+
let disabledSpans: CollectedSpan[] = [];
245256
let errorSpanRejected = false;
246257

258+
/**
259+
* Run the OTEL_INSTRUMENT_FETCH=false scenario in its own process (clean global
260+
* OTel state) under the SAME runtime as the suite (`process.execPath`), so the
261+
* disable is verified end-to-end on whichever of Bun/Node is running. Resolves
262+
* when the child exits 0; rejects on spawn error or a non-zero exit.
263+
*/
264+
function runDisabledFetchChild(): Promise<void> {
265+
return new Promise((resolve, reject) => {
266+
const child = spawn(process.execPath, [childScript], {
267+
stdio: "inherit",
268+
env: {
269+
...process.env,
270+
OTEL_INSTRUMENT_FETCH: "false",
271+
OTEL_EXPORTER_OTLP_ENDPOINT:
272+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318",
273+
CHILD_NONCE: disabledNonce,
274+
CHILD_PARENT_SPAN: disabledParentSpanName,
275+
CHILD_FETCH_MARKER: disabledFetchMarker,
276+
},
277+
});
278+
child.on("error", reject);
279+
child.on("exit", (code) => {
280+
if (code === 0) {
281+
resolve();
282+
} else {
283+
reject(new Error(`disabled-fetch child exited with code ${code}`));
284+
}
285+
});
286+
});
287+
}
288+
247289
beforeAll(async () => {
248290
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??= "http://localhost:4318";
249291
// Keep level resolution deterministic regardless of the CI environment
250292
// (LOG_LEVEL would otherwise win over the logLevel option below).
251293
delete process.env.LOG_LEVEL;
252294

295+
if (!existsSync(distEntry)) {
296+
throw new Error(
297+
`Missing ${distEntry}. The OTEL_INSTRUMENT_FETCH=false child imports the built bundle — run \`bun run build\` before the integration test.`
298+
);
299+
}
300+
253301
const handle = setupOtel({
254302
serviceName: SERVICE_NAME,
255303
serviceVersion: PHOTON_OTEL_VERSION,
@@ -299,6 +347,11 @@ beforeAll(async () => {
299347
// Flush the batch processors over the wire before reading the collector files.
300348
await handle.shutdown();
301349

350+
// Separately, drive the OTEL_INSTRUMENT_FETCH=false scenario in a clean child
351+
// process (setupOtel is process-global and idempotent, so it can't share this
352+
// process with the enabled run above).
353+
await runDisabledFetchChild();
354+
302355
spans = await pollUntil(
303356
readSpans,
304357
(items) =>
@@ -314,6 +367,12 @@ beforeAll(async () => {
314367
items.some((r) => r.body === "hello from integration") &&
315368
items.some((r) => r.body === "integration error log")
316369
);
370+
// Wait for the child run's control (parent) span so that asserting the absence
371+
// of a fetch span below is meaningful — the child's pipeline did deliver.
372+
disabledSpans = await pollUntil(
373+
() => readSpans(disabledNonce),
374+
(items) => items.some((s) => s.name === disabledParentSpanName)
375+
);
317376
}, HOOK_TIMEOUT_MS);
318377

319378
describe("real OTLP/HTTP round-trip to an OpenTelemetry Collector", () => {
@@ -400,4 +459,23 @@ describe("real OTLP/HTTP round-trip to an OpenTelemetry Collector", () => {
400459
expect(fetchSpan?.traceId).toBe(parent?.traceId);
401460
expect(fetchSpan?.parentSpanId).toBe(parent?.spanId);
402461
});
462+
463+
it("suppresses the fetch CLIENT span when OTEL_INSTRUMENT_FETCH=false", () => {
464+
// Control: the child run's parent span DID reach the collector, so the
465+
// absence of a fetch span is meaningful (the pipeline ran, just no fetch).
466+
const parent = disabledSpans.find((s) => s.name === disabledParentSpanName);
467+
expect(parent).toBeDefined();
468+
// No CLIENT fetch span was emitted for the disabled run...
469+
const fetchSpan = disabledSpans.find((s) =>
470+
String(s.attributes["url.full"] ?? "").includes(disabledFetchMarker)
471+
);
472+
expect(fetchSpan).toBeUndefined();
473+
// ...and the control span has no child spans at all (the fetch was its only
474+
// call) — true on both runtimes: native undici on Node never registered, and
475+
// the globalThis.fetch wrap on Bun was never installed.
476+
const children = disabledSpans.filter(
477+
(s) => s.parentSpanId === parent?.spanId
478+
);
479+
expect(children).toHaveLength(0);
480+
});
403481
});

0 commit comments

Comments
 (0)