Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ jobs:
node-version: ${{ matrix.node-version }}
- run: bun install --frozen-lockfile

# The OTEL_INSTRUMENT_FETCH=false case runs in a child process that imports
# the built bundle (so it runs unchanged under both node and bun).
- name: Build
run: bun run build

- name: Start OTel Collector
working-directory: tests/integration
run: |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`:
| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value,key=value` headers; merged with `options.headers` (env wins). |
| `DEPLOYMENT_ENV` | Attached as `deployment.environment` resource attribute. Defaults to `development`. Also drives the default log level. |
| `LOG_LEVEL` | Minimum log level: `debug` \| `info` \| `warn` \| `error` \| `silent`. Overrides `setLogLevel()` / `setupOtel({ logLevel })`. |
| `OTEL_INSTRUMENT_FETCH` | Toggle outbound `fetch` tracing: `true` / `1` on, `false` / `0` off. Overrides `instrumentFetch` and the default. |

## Automatic fetch instrumentation

Expand All @@ -136,7 +137,8 @@ The strategy depends on the runtime (`mode: "auto"`, the default):
Options (`instrumentFetch`):

- **`true` / `false`:** force on (even without an endpoint) / off. Defaults to on when a traces
endpoint is configured.
endpoint is configured. The `OTEL_INSTRUMENT_FETCH` env var overrides this (`true`/`1` to force on,
`false`/`0` to disable) — toggle fetch tracing in production without a code change.
- **`mode`:** `"auto"` (default — native on Node, wrap on Bun) or `"global"` (wrap on both runtimes).
Choose `"global"` when you want identical spans everywhere and the built-in PII scrubbing of error
messages kept on Node (see caveats).
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ setupOtel({
});
```

Or disable it from the environment — no code change or redeploy of app logic required:

```bash
OTEL_INSTRUMENT_FETCH=false
```

`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.

You can force it on even without an exporter endpoint:

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

## Best practices

Expand Down
18 changes: 18 additions & 0 deletions docs/guides/fetch-instrumentation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ When a traces endpoint is configured, setup instruments fetch by default. The st

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

## Enabling and disabling

Pass `instrumentFetch: false` to turn it off, or `true` to force it on even without an endpoint:

```ts
setupOtel({ serviceName: "orders-api", instrumentFetch: false });
```

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:

```bash
OTEL_INSTRUMENT_FETCH=false
```

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`.

## What each fetch span contains

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

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.

`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.

## Restoring fetch

The returned handle exposes `unpatch()`.
Expand Down
31 changes: 28 additions & 3 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export interface SetupOtelOptions {
*
* `true` enables with defaults; pass an object to filter URLs via `ignore`.
* Defaults to enabled when a traces endpoint is configured. Pass `false` to
* disable.
* disable. The `OTEL_INSTRUMENT_FETCH` env var takes precedence (`true`/`1`
* to force on, `false`/`0` to disable), matching the rest of the package's
* env-wins config.
*/
instrumentFetch?: boolean | InstrumentFetchOptions;
/**
Expand Down Expand Up @@ -93,6 +95,23 @@ function parseEnvHeaders(raw: string | undefined): Record<string, string> {
return out;
}

/**
* Parse a boolean-ish env var. Returns `undefined` for unset or unrecognized
* values so the caller can fall through to its code option — mirroring how
* `logger.ts`'s `envLevel()` defers on values it doesn't recognize. Accepts
* `true`/`1` and `false`/`0` (case- and whitespace-insensitive).
*/
function parseBooleanEnv(raw: string | undefined): boolean | undefined {
const value = raw?.trim().toLowerCase();
if (value === "true" || value === "1") {
return true;
}
if (value === "false" || value === "0") {
return false;
}
return;
}

function resolveTracesEndpoint(base: string | undefined): string | undefined {
const traces = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
if (traces) {
Expand Down Expand Up @@ -145,7 +164,9 @@ function otlpEndpointKeysOf(

/**
* Start fetch instrumentation unless disabled. Defaults to on when a traces
* pipeline is configured. On Node (mode `"auto"`) this registers the native
* pipeline is configured; the `OTEL_INSTRUMENT_FETCH` env var (`true`/`1` |
* `false`/`0`) overrides both the option and that default. On Node (mode
* `"auto"`) this registers the native
* `@opentelemetry/instrumentation-undici`; on Bun, or with mode `"global"`, it
* wraps `globalThis.fetch`. Always excludes our own OTLP endpoints so the
* exporter's traffic is never self-traced (matters on Node, where the OTLP
Expand All @@ -157,7 +178,11 @@ function startFetchInstrumentation(
tracesEndpoint: string | undefined,
logsEndpoint: string | undefined
): FetchInstrumentation | undefined {
const want = option ?? hasTraces;
// Env wins over code (and over the smart default), matching the rest of the
// package's config story. The env value only drives the on/off decision; the
// object form below still configures *how* fetch is instrumented when on.
const envWant = parseBooleanEnv(process.env.OTEL_INSTRUMENT_FETCH);
const want = envWant ?? option ?? hasTraces;
if (!want) {
return;
}
Expand Down
10 changes: 9 additions & 1 deletion tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ docker compose up -d
# 2. Wait until it's healthy
curl -sf http://localhost:13133/ >/dev/null && echo healthy

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

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

This is the same flow CI runs in `.github/workflows/integration.yml`.
56 changes: 56 additions & 0 deletions tests/integration/disabled-fetch.child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Child process for the integration test's OTEL_INSTRUMENT_FETCH=false case.
//
// setupOtel registers process-global OTel providers and is idempotent, so the
// disabled-fetch scenario can't share a process with the main (enabled) run.
// This standalone process boots its own pipeline with OTEL_INSTRUMENT_FETCH set
// to "false" (by the parent), makes one fetch, and exits — the parent then
// asserts against the collector that no CLIENT fetch span arrived for this run's
// nonce, while the control (parent) span did.
//
// It imports the BUILT bundle (dist) so it runs unchanged under both `node` and
// `bun`; the parent spawns it with process.execPath, i.e. the current runtime,
// so this leg exercises whichever runtime the suite is running on.

import { createServer } from "node:http";
import { setupOtel, withSpan } from "../../dist/index.js";

const nonce = process.env.CHILD_NONCE;
const parentSpanName = process.env.CHILD_PARENT_SPAN;
const fetchMarker = process.env.CHILD_FETCH_MARKER;

async function main() {
const handle = setupOtel({
serviceName: "photon-otel-integration",
resourceAttributes: { "test.nonce": nonce },
});

const target = createServer((_req, res) => {
res.statusCode = 200;
res.end("ok");
});
await new Promise((resolve) => {
target.listen(0, "127.0.0.1", () => resolve());
});
const { port } = target.address();

// The control span proves this run's pipeline delivered to the collector; the
// fetch inside it must NOT produce a CLIENT span, because fetch instrumentation
// is disabled via OTEL_INSTRUMENT_FETCH=false.
await withSpan(parentSpanName, async () => {
const res = await fetch(`http://127.0.0.1:${port}/${fetchMarker}`);
await res.text();
});

await new Promise((resolve) => {
target.close(() => resolve());
});
// Flush the batch processors over the wire before the process exits.
await handle.shutdown();
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
86 changes: 82 additions & 4 deletions tests/integration/otel-collector.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
Expand Down Expand Up @@ -32,6 +33,10 @@ const here = dirname(fileURLToPath(import.meta.url));
const outputDir = process.env.COLLECTOR_OUTPUT_DIR ?? join(here, "output");
const tracesFile = join(outputDir, "traces.json");
const logsFile = join(outputDir, "logs.json");
// The OTEL_INSTRUMENT_FETCH=false case runs in a child process (see below); it
// imports the built bundle, so the integration suite needs `bun run build`.
const childScript = join(here, "disabled-fetch.child.mjs");
const distEntry = join(here, "..", "..", "dist", "index.js");

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

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

function readSpans(): CollectedSpan[] {
function readSpans(targetNonce: string = nonce): CollectedSpan[] {
const spans: CollectedSpan[] = [];
for (const line of readLines(tracesFile)) {
let parsed: OtlpTracesData;
Expand All @@ -180,9 +189,10 @@ function readSpans(): CollectedSpan[] {
}
}
}
// Scope to THIS run: the file exporter appends across runs, and log bodies
// aren't unique, so filter by the per-run nonce carried on the resource.
return spans.filter((s) => s.resource["test.nonce"] === nonce);
// Scope to a specific run: the file exporter appends across runs, and log
// bodies aren't unique, so filter by the per-run nonce carried on the
// resource. Defaults to the main enabled run; the child run passes its own.
return spans.filter((s) => s.resource["test.nonce"] === targetNonce);
}

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

let spans: CollectedSpan[] = [];
let logRecords: CollectedLog[] = [];
let disabledSpans: CollectedSpan[] = [];
let errorSpanRejected = false;

/**
* Run the OTEL_INSTRUMENT_FETCH=false scenario in its own process (clean global
* OTel state) under the SAME runtime as the suite (`process.execPath`), so the
* disable is verified end-to-end on whichever of Bun/Node is running. Resolves
* when the child exits 0; rejects on spawn error or a non-zero exit.
*/
function runDisabledFetchChild(): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [childScript], {
stdio: "inherit",
env: {
...process.env,
OTEL_INSTRUMENT_FETCH: "false",
OTEL_EXPORTER_OTLP_ENDPOINT:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318",
CHILD_NONCE: disabledNonce,
CHILD_PARENT_SPAN: disabledParentSpanName,
CHILD_FETCH_MARKER: disabledFetchMarker,
},
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`disabled-fetch child exited with code ${code}`));
}
});
});
}

beforeAll(async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??= "http://localhost:4318";
// Keep level resolution deterministic regardless of the CI environment
// (LOG_LEVEL would otherwise win over the logLevel option below).
delete process.env.LOG_LEVEL;

if (!existsSync(distEntry)) {
throw new Error(
`Missing ${distEntry}. The OTEL_INSTRUMENT_FETCH=false child imports the built bundle — run \`bun run build\` before the integration test.`
);
}

const handle = setupOtel({
serviceName: SERVICE_NAME,
serviceVersion: PHOTON_OTEL_VERSION,
Expand Down Expand Up @@ -299,6 +347,11 @@ beforeAll(async () => {
// Flush the batch processors over the wire before reading the collector files.
await handle.shutdown();

// Separately, drive the OTEL_INSTRUMENT_FETCH=false scenario in a clean child
// process (setupOtel is process-global and idempotent, so it can't share this
// process with the enabled run above).
await runDisabledFetchChild();

spans = await pollUntil(
readSpans,
(items) =>
Expand All @@ -314,6 +367,12 @@ beforeAll(async () => {
items.some((r) => r.body === "hello from integration") &&
items.some((r) => r.body === "integration error log")
);
// Wait for the child run's control (parent) span so that asserting the absence
// of a fetch span below is meaningful — the child's pipeline did deliver.
disabledSpans = await pollUntil(
() => readSpans(disabledNonce),
(items) => items.some((s) => s.name === disabledParentSpanName)
);
}, HOOK_TIMEOUT_MS);

describe("real OTLP/HTTP round-trip to an OpenTelemetry Collector", () => {
Expand Down Expand Up @@ -400,4 +459,23 @@ describe("real OTLP/HTTP round-trip to an OpenTelemetry Collector", () => {
expect(fetchSpan?.traceId).toBe(parent?.traceId);
expect(fetchSpan?.parentSpanId).toBe(parent?.spanId);
});

it("suppresses the fetch CLIENT span when OTEL_INSTRUMENT_FETCH=false", () => {
// Control: the child run's parent span DID reach the collector, so the
// absence of a fetch span is meaningful (the pipeline ran, just no fetch).
const parent = disabledSpans.find((s) => s.name === disabledParentSpanName);
expect(parent).toBeDefined();
// No CLIENT fetch span was emitted for the disabled run...
const fetchSpan = disabledSpans.find((s) =>
String(s.attributes["url.full"] ?? "").includes(disabledFetchMarker)
);
expect(fetchSpan).toBeUndefined();
// ...and the control span has no child spans at all (the fetch was its only
// call) — true on both runtimes: native undici on Node never registered, and
// the globalThis.fetch wrap on Bun was never installed.
const children = disabledSpans.filter(
(s) => s.parentSpanId === parent?.spanId
);
expect(children).toHaveLength(0);
});
});
Loading
Loading