From a3d5558ea260e0eddad5f680c0de01f99e41a12a Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 13 Jul 2026 19:11:25 -0700 Subject: [PATCH] Instrument fetch (undici) for HTTP client spans The distro only registered @opentelemetry/instrumentation-http (Node core http/https). The OpenAI SDK used by LangChain issues requests via the global fetch (undici) on Node 18+, so LLM HTTP calls produced no client spans. Register @opentelemetry/instrumentation-undici (enabled by default). When the fetch-based A365 exporter is active, a merged undici ignoreRequestHook skips its export origin so telemetry traffic is not self-traced (undici does not honor tracing suppression). Adds unit tests for the registration wiring, the ignore-hook behavior, and A365 origin resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + package-lock.json | 18 ++ package.json | 1 + src/distro/distro.ts | 34 ++++ src/distro/instrumentations.ts | 34 +++- src/shared/config.ts | 3 +- src/types.ts | 8 + .../unit/distro/instrumentations.test.ts | 192 ++++++++++++++++++ test/internal/unit/main.test.ts | 4 +- 9 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 test/internal/unit/distro/instrumentations.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9559f8..47bd12c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Features Added +- Instrument outgoing `fetch` (undici) requests so HTTP client spans are captured for `fetch`-based clients such as the OpenAI SDK used by LangChain + ### Other Changes - Remove the unused `AZURE_MONITOR_DISTRO_VERSION` env var and its constant; the distro reports its version via `MICROSOFT_OPENTELEMETRY_VERSION` diff --git a/package-lock.json b/package-lock.json index 43f6896..b343482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@opentelemetry/instrumentation-mysql": "^0.64.0", "@opentelemetry/instrumentation-pg": "^0.70.0", "@opentelemetry/instrumentation-redis": "^0.66.0", + "@opentelemetry/instrumentation-undici": "^0.29.0", "@opentelemetry/instrumentation-winston": "^0.62.0", "@opentelemetry/resource-detector-azure": "^0.26.0", "@opentelemetry/resources": "^2.8.0", @@ -1752,6 +1753,23 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.29.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.29.0.tgz", + "integrity": "sha1-JxdFpazRErc+dfCgbsOfBJP6tPc=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.219.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, "node_modules/@opentelemetry/instrumentation-winston": { "version": "0.62.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.62.0.tgz", diff --git a/package.json b/package.json index 3717858..529f5bb 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@opentelemetry/instrumentation-mysql": "^0.64.0", "@opentelemetry/instrumentation-pg": "^0.70.0", "@opentelemetry/instrumentation-redis": "^0.66.0", + "@opentelemetry/instrumentation-undici": "^0.29.0", "@opentelemetry/instrumentation-winston": "^0.62.0", "@opentelemetry/resource-detector-azure": "^0.26.0", "@opentelemetry/resources": "^2.8.0", diff --git a/src/distro/distro.ts b/src/distro/distro.ts index e644956..8b887d4 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -32,6 +32,7 @@ import { } from "../azureMonitor/index.js"; import { isOtlpEnabled, createOtlpComponents } from "../otlp/index.js"; import { A365Configuration, Agent365Exporter, A365SpanProcessor } from "../a365/index.js"; +import { resolveAgent365Endpoint } from "../a365/exporter/utils.js"; import { configureA365Logger } from "../a365/logging.js"; import { GenAIMainAgentLogRecordProcessor, @@ -62,6 +63,7 @@ let isShutdown = false; const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray = [ "http", + "undici", "azureSdk", "mongoDb", "mySql", @@ -79,6 +81,33 @@ const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray = ["redis", "redis4"]; +/** + * Resolve the origin(s) of the A365 fetch-based exporter endpoint so the undici + * instrumentation can skip tracing our own telemetry export requests. Returns + * an empty list when the A365 exporter is not active or the endpoint cannot be + * resolved. + * + * @internal + */ +export function _resolveA365ExporterOrigins(a365Config: A365Configuration): string[] { + if (!a365Config.enabled || !a365Config.enableObservabilityExporter) { + return []; + } + let baseUrl = a365Config.domainOverride; + if (!baseUrl) { + try { + baseUrl = resolveAgent365Endpoint(a365Config.clusterCategory); + } catch { + return []; + } + } + try { + return [new URL(baseUrl).origin]; + } catch { + return []; + } +} + /** * When A365 export is enabled, default to GenAI-focused telemetry by disabling * non-GenAI instrumentations unless callers explicitly configure them. @@ -241,8 +270,13 @@ export function useMicrosoftOpenTelemetry(options?: MicrosoftOpenTelemetryOption ); // ── Instrumentations, sampler, and views (always created) ───────── + // When the fetch-based A365 exporter is active, tell the undici + // instrumentation to skip its export requests so they are not traced as + // spurious client (dependency) spans on other exporters (e.g. Azure Monitor). + const ignoreUndiciOrigins = _resolveA365ExporterOrigins(a365Config); const instrumentations = createInstrumentations(config, { filterAzureMonitorRequests: azureMonitorEnabled, + ignoreUndiciOrigins, }); const sampler = createSampler(config); const views: ViewOptions[] = createViews(config); diff --git a/src/distro/instrumentations.ts b/src/distro/instrumentations.ts index e4479b2..7ae5317 100644 --- a/src/distro/instrumentations.ts +++ b/src/distro/instrumentations.ts @@ -16,6 +16,11 @@ import type { IgnoreOutgoingRequestFunction, } from "@opentelemetry/instrumentation-http"; import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import type { + UndiciInstrumentationConfig, + UndiciRequest, +} from "@opentelemetry/instrumentation-undici"; +import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici"; import { MongoDBInstrumentation } from "@opentelemetry/instrumentation-mongodb"; import { MySQLInstrumentation } from "@opentelemetry/instrumentation-mysql"; import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; @@ -43,10 +48,11 @@ import { logLevelToSeverityNumber } from "../azureMonitor/utils/logUtils.js"; */ export function createInstrumentations( config: InternalConfig, - options?: { filterAzureMonitorRequests?: boolean }, + options?: { filterAzureMonitorRequests?: boolean; ignoreUndiciOrigins?: string[] }, ): Instrumentation[] { const instrumentations: Instrumentation[] = []; const filterAzureMonitor = options?.filterAzureMonitorRequests ?? false; + const ignoreUndiciOrigins = options?.ignoreUndiciOrigins ?? []; // ── Trace instrumentations ────────────────────────────────────── if (config.instrumentationOptions.http?.enabled) { @@ -67,6 +73,32 @@ export function createInstrumentations( instrumentations.push(new HttpInstrumentation(httpConfig)); } + // Undici / global `fetch` — instruments outgoing requests the Node + // http/https instrumentation does not see (e.g. the OpenAI SDK's fetch calls). + if (config.instrumentationOptions.undici?.enabled) { + let undiciConfig = config.instrumentationOptions.undici as UndiciInstrumentationConfig; + + // Undici does not honour tracing suppression, so requests made by our own + // fetch-based telemetry exporters (e.g. the A365 exporter) would otherwise + // be traced as spurious client spans. Skip requests to those origins while + // still delegating to any caller-provided ignore hook. Clone the config so + // the caller-owned object is never mutated. + if (ignoreUndiciOrigins.length > 0) { + const providedIgnoreRequestHook = undiciConfig.ignoreRequestHook; + undiciConfig = { + ...undiciConfig, + ignoreRequestHook: (request: UndiciRequest) => { + if (ignoreUndiciOrigins.includes(request.origin)) { + return true; + } + return providedIgnoreRequestHook ? providedIgnoreRequestHook(request) : false; + }, + }; + } + + instrumentations.push(new UndiciInstrumentation(undiciConfig)); + } + if (config.instrumentationOptions.azureSdk?.enabled) { instrumentations.push(createAzureSdkInstrumentation(config.instrumentationOptions.azureSdk)); } diff --git a/src/shared/config.ts b/src/shared/config.ts index 24fa940..d7b7891 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -34,7 +34,7 @@ export class InternalConfig { /** Azure Monitor Exporter Configuration */ public azureMonitorExporterOptions: AzureMonitorExporterOptions; /** - * OpenTelemetry Instrumentations configuration included as part of Azure Monitor (azureSdk, http, mongoDb, mySql, postgreSql, redis, redis4) + * OpenTelemetry Instrumentations configuration included as part of Azure Monitor (azureSdk, http, undici, mongoDb, mySql, postgreSql, redis, redis4) */ public instrumentationOptions: InstrumentationOptions; /** Enable Live Metrics feature */ @@ -82,6 +82,7 @@ export class InternalConfig { this.metricExportIntervalMillis = this.calculateMetricExportInterval(); this.instrumentationOptions = { http: { enabled: true }, + undici: { enabled: true }, azureSdk: { enabled: true }, mongoDb: { enabled: true }, mySql: { enabled: true }, diff --git a/src/types.ts b/src/types.ts index fba9ab1..10513a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import type { AzureMonitorExporterOptions } from "@azure/monitor-opentelemetry-exporter"; import type { InstrumentationConfig } from "@opentelemetry/instrumentation"; +import type { UndiciInstrumentationConfig } from "@opentelemetry/instrumentation-undici"; import type { Resource } from "@opentelemetry/resources"; import type { LogRecordProcessor } from "@opentelemetry/sdk-logs"; import type { MetricReader, ViewOptions } from "@opentelemetry/sdk-metrics"; @@ -84,6 +85,13 @@ export interface InstrumentationOptions { azureSdk?: InstrumentationConfig; /** HTTP Instrumentation Config */ http?: InstrumentationConfig; + /** + * Undici / global `fetch` Instrumentation Config. + * Instruments outgoing requests made via the global `fetch` API (undici), + * which the Node `http`/`https` instrumentation does not cover. Required for + * HTTP client spans from `fetch`-based clients such as the OpenAI SDK. + */ + undici?: UndiciInstrumentationConfig; /** MongoDB Instrumentation Config */ mongoDb?: InstrumentationConfig; /** MySQL Instrumentation Config */ diff --git a/test/internal/unit/distro/instrumentations.test.ts b/test/internal/unit/distro/instrumentations.test.ts new file mode 100644 index 0000000..331bbf5 --- /dev/null +++ b/test/internal/unit/distro/instrumentations.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, assert, beforeEach, afterEach } from "vitest"; +import type { + UndiciInstrumentationConfig, + UndiciRequest, +} from "@opentelemetry/instrumentation-undici"; +import type { Instrumentation } from "@opentelemetry/instrumentation"; +import { createInstrumentations } from "../../../../src/distro/instrumentations.js"; +import { _resolveA365ExporterOrigins } from "../../../../src/distro/distro.js"; +import { InternalConfig } from "../../../../src/shared/index.js"; +import { A365Configuration, A365_ENV_VARS } from "../../../../src/a365/index.js"; + +const UNDICI_NAME = "@opentelemetry/instrumentation-undici"; +const HTTP_NAME = "@opentelemetry/instrumentation-http"; +const A365_PROD_ORIGIN = "https://agent365.svc.cloud.microsoft"; + +function findByName( + instrumentations: Instrumentation[], + name: string, +): Instrumentation | undefined { + return instrumentations.find((i) => i.instrumentationName === name); +} + +function getUndiciConfig(instrumentations: Instrumentation[]): UndiciInstrumentationConfig { + const undici = findByName(instrumentations, UNDICI_NAME); + assert.ok(undici, "undici instrumentation should be registered"); + return undici!.getConfig() as UndiciInstrumentationConfig; +} + +/** Minimal UndiciRequest with just the fields the ignore hook inspects. */ +function fakeUndiciRequest(origin: string): UndiciRequest { + return { + origin, + method: "POST", + path: "/v1/chat/completions", + headers: [], + addHeader: () => {}, + throwOnError: false, + completed: false, + aborted: false, + idempotent: true, + contentLength: null, + contentType: null, + body: null, + } as UndiciRequest; +} + +describe("createInstrumentations — undici / fetch HTTP client spans", () => { + it("registers the undici (fetch) instrumentation by default", () => { + const config = new InternalConfig(); + const instrumentations = createInstrumentations(config); + + assert.ok(findByName(instrumentations, UNDICI_NAME), "undici should be registered by default"); + assert.ok(findByName(instrumentations, HTTP_NAME), "http should still be registered"); + }); + + it("does not register undici when explicitly disabled", () => { + const config = new InternalConfig(); + config.instrumentationOptions.undici = { enabled: false }; + const instrumentations = createInstrumentations(config); + + assert.isUndefined( + findByName(instrumentations, UNDICI_NAME), + "undici should not be registered when disabled", + ); + }); + + it("does not add an ignoreRequestHook when no exporter origins are provided", () => { + const config = new InternalConfig(); + const instrumentations = createInstrumentations(config); + + assert.isUndefined( + getUndiciConfig(instrumentations).ignoreRequestHook, + "no ignoreRequestHook should be added without origins to filter", + ); + }); + + it("ignores requests to the provided exporter origins but traces others", () => { + const config = new InternalConfig(); + const instrumentations = createInstrumentations(config, { + ignoreUndiciOrigins: [A365_PROD_ORIGIN], + }); + const hook = getUndiciConfig(instrumentations).ignoreRequestHook; + assert.ok(hook, "ignoreRequestHook should be added when origins are provided"); + + assert.strictEqual( + hook!(fakeUndiciRequest(A365_PROD_ORIGIN)), + true, + "exporter-origin request should be ignored (not traced)", + ); + assert.strictEqual( + hook!(fakeUndiciRequest("https://api.openai.com")), + false, + "a real fetch (e.g. LLM call) should still be traced", + ); + }); + + it("delegates to a caller-provided ignoreRequestHook for non-exporter origins", () => { + const config = new InternalConfig(); + config.instrumentationOptions.undici = { + enabled: true, + ignoreRequestHook: (request: UndiciRequest) => request.origin === "https://blocked.example", + }; + const instrumentations = createInstrumentations(config, { + ignoreUndiciOrigins: [A365_PROD_ORIGIN], + }); + const hook = getUndiciConfig(instrumentations).ignoreRequestHook!; + + assert.strictEqual(hook(fakeUndiciRequest(A365_PROD_ORIGIN)), true, "exporter origin ignored"); + assert.strictEqual( + hook(fakeUndiciRequest("https://blocked.example")), + true, + "caller hook should still apply", + ); + assert.strictEqual( + hook(fakeUndiciRequest("https://api.openai.com")), + false, + "unrelated origin should be traced", + ); + }); + + it("does not mutate the caller-provided undici config object", () => { + const config = new InternalConfig(); + const callerUndiciConfig: UndiciInstrumentationConfig = { enabled: true }; + config.instrumentationOptions.undici = callerUndiciConfig; + + createInstrumentations(config, { ignoreUndiciOrigins: [A365_PROD_ORIGIN] }); + + assert.isUndefined( + callerUndiciConfig.ignoreRequestHook, + "caller config must be cloned, not mutated, when adding the ignore hook", + ); + }); +}); + +describe("_resolveA365ExporterOrigins", () => { + const savedEnv: Record = {}; + const managedVars = [ + A365_ENV_VARS.DOMAIN, + A365_ENV_VARS.CLUSTER_CATEGORY, + A365_ENV_VARS.EXPORTER_ENABLED, + ]; + + beforeEach(() => { + for (const key of managedVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of managedVars) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it("returns [] when A365 is disabled", () => { + const a365 = new A365Configuration({ enabled: false, enableObservabilityExporter: true }); + assert.deepStrictEqual(_resolveA365ExporterOrigins(a365), []); + }); + + it("returns [] when the A365 observability exporter is disabled", () => { + const a365 = new A365Configuration({ enabled: true, enableObservabilityExporter: false }); + assert.deepStrictEqual(_resolveA365ExporterOrigins(a365), []); + }); + + it("returns the prod endpoint origin when the exporter is active", () => { + const a365 = new A365Configuration({ + enabled: true, + enableObservabilityExporter: true, + clusterCategory: "prod", + }); + assert.deepStrictEqual(_resolveA365ExporterOrigins(a365), [A365_PROD_ORIGIN]); + }); + + it("returns the domainOverride origin (host:port only) when set", () => { + const a365 = new A365Configuration({ + enabled: true, + enableObservabilityExporter: true, + domainOverride: "https://custom-a365.example.com:8443/some/path", + }); + assert.deepStrictEqual(_resolveA365ExporterOrigins(a365), [ + "https://custom-a365.example.com:8443", + ]); + }); +}); diff --git a/test/internal/unit/main.test.ts b/test/internal/unit/main.test.ts index d5b5384..93d601f 100644 --- a/test/internal/unit/main.test.ts +++ b/test/internal/unit/main.test.ts @@ -362,7 +362,8 @@ describe("Main functions", () => { assert.ok(instrumentations & SdkStatsInstrumentation.MYSQL, "MYSQL not set"); assert.ok(instrumentations & SdkStatsInstrumentation.POSTGRES, "POSTGRES not set"); assert.ok(instrumentations & SdkStatsInstrumentation.REDIS, "REDIS not set"); - assert.strictEqual(instrumentations, 31); + assert.ok(instrumentations & SdkStatsInstrumentation.UNDICI, "UNDICI not set"); + assert.strictEqual(instrumentations, 31 | SdkStatsInstrumentation.UNDICI); }); it("should set shim feature in SDK Stats if env var is populated", () => { @@ -589,6 +590,7 @@ describe("Main functions", () => { instrumentationOptions: { azureSdk: { enabled: false }, http: { enabled: false }, + undici: { enabled: false }, mongoDb: { enabled: false }, mySql: { enabled: false }, postgreSql: { enabled: false },