From a3d5558ea260e0eddad5f680c0de01f99e41a12a Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 13 Jul 2026 19:11:25 -0700 Subject: [PATCH 1/5] 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 }, From bd4d391c64aa0c4d753fb6dfa4b46c1b661cf631 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 15 Jul 2026 15:31:10 -0700 Subject: [PATCH 2/5] fix(langchain): close init race and correct span kinds - Add whenGenAIInstrumentationsReady() so ESM apps can await GenAI instrumentation setup before their first invocation, eliminating a startup race that could drop the top-level invoke_agent span - Emit invoke_agent spans as INTERNAL (not SERVER) so Azure Monitor records them as dependencies for the AI agents (preview) experience - Emit execute_tool spans as INTERNAL (not CLIENT) per the GenAI execute tool semantic convention and the Python distro Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 +++ src/distro/distro.ts | 33 +++++++++++++++++-- src/distro/index.ts | 6 +++- .../langchain/langchainTraceInstrumentor.ts | 2 -- .../instrumentations/langchain/tracer.ts | 13 ++++++-- src/index.ts | 1 + test/internal/functional/genai-distro.test.ts | 32 +++++++++++++++++- .../unit/genai/langchain/tracer.test.ts | 18 ++++++++++ 8 files changed, 102 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bd12c..78ca239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### 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 +- Add `whenGenAIInstrumentationsReady()` so ESM apps can await GenAI (LangChain / OpenAI Agents) instrumentation setup before their first invocation, eliminating a startup race that could drop the top-level `invoke_agent` span + +### Bugs Fixed +- LangChain: emit `invoke_agent` spans as `INTERNAL` (not `SERVER`) so Azure Monitor records them as dependencies and they surface in the Application Insights "AI agents (preview)" experience +- LangChain: emit `execute_tool` spans as `INTERNAL` (not `CLIENT`) to match the GenAI "execute tool" semantic convention and the Python distro (in-process tool execution) ### 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/src/distro/distro.ts b/src/distro/distro.ts index 8b887d4..14f02ee 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -496,16 +496,38 @@ export function _getSdkInstance(): NodeSDK | undefined { // is never an error. Here we eagerly import the optional @openai/agents and // @langchain/core packages, so we must tolerate them not being installed. // This will be migrated to upstream OTel instrumentation hooks once they are ready. + +// Resolves once all GenAI instrumentations that were started have finished +// wiring up their module patches. ESM applications can `await` this (e.g. via a +// top-level `await` in their telemetry bootstrap) to guarantee LangChain is +// patched before the first chain/agent invocation — closing the race where the +// top-level CompiledStateGraph run configures its callbacks before the async +// patch lands, which would otherwise drop the outer `invoke_agent` span. +let genAIInstrumentationsReady: Promise = Promise.resolve(); + +/** + * Await the completion of GenAI instrumentation setup (LangChain / OpenAI + * Agents). Resolves immediately when no GenAI instrumentation is active. + */ +export function whenGenAIInstrumentationsReady(): Promise { + return genAIInstrumentationsReady; +} + function initializeGenAIInstrumentations(options?: InstrumentationOptions): void { + const pending: Promise[] = []; + const openAIOptions = options?.openaiAgents; if (openAIOptions?.enabled !== false) { - void initializeOpenAIAgentsInstrumentation(openAIOptions ?? {}); + pending.push(initializeOpenAIAgentsInstrumentation(openAIOptions ?? {})); } const langChainOptions = options?.langchain; if (langChainOptions?.enabled !== false) { - void initializeLangChainInstrumentation(langChainOptions ?? {}); + pending.push(initializeLangChainInstrumentation(langChainOptions ?? {})); } + + // Track overall readiness so ESM apps can await it before their first invoke. + genAIInstrumentationsReady = Promise.allSettled(pending).then(() => undefined); } /** @@ -534,6 +556,13 @@ async function initializeOpenAIAgentsInstrumentation( async function initializeLangChainInstrumentation( _options: LangChainInstrumentationConfig, ): Promise { + // Dynamically import BOTH the instrumentor (its module statically imports the + // tracer, which imports @langchain/core) and the callbacks manager module, so + // a missing @langchain/core is tolerated. Awaiting these imports guarantees + // the tracer constructor is ready before the manual patch is applied. This + // whole promise is tracked by whenGenAIInstrumentationsReady(), which ESM + // apps can await before their first invoke to avoid the init race that would + // otherwise drop the top-level invoke_agent span. try { const [{ LangChainTraceInstrumentor }, callbackManagerModule] = await Promise.all([ import("../genai/instrumentations/langchain/langchainTraceInstrumentor.js"), diff --git a/src/distro/index.ts b/src/distro/index.ts index b32aeba..a738301 100644 --- a/src/distro/index.ts +++ b/src/distro/index.ts @@ -9,4 +9,8 @@ export type { } from "./types.js"; export { MICROSOFT_OPENTELEMETRY_VERSION } from "./types.js"; -export { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "./distro.js"; +export { + useMicrosoftOpenTelemetry, + shutdownMicrosoftOpenTelemetry, + whenGenAIInstrumentationsReady, +} from "./distro.js"; diff --git a/src/genai/instrumentations/langchain/langchainTraceInstrumentor.ts b/src/genai/instrumentations/langchain/langchainTraceInstrumentor.ts index a8bb501..57f3003 100644 --- a/src/genai/instrumentations/langchain/langchainTraceInstrumentor.ts +++ b/src/genai/instrumentations/langchain/langchainTraceInstrumentor.ts @@ -26,8 +26,6 @@ class LangChainTraceInstrumentorImpl extends InstrumentationBase { expect(chatSpan).toBeDefined(); expect(chatSpan?.instrumentationScope.name).toBe("microsoft-otel-langchain"); }); + + it("attaches the LangChain tracer synchronously after awaiting whenGenAIInstrumentationsReady (no polling)", async () => { + useMicrosoftOpenTelemetry({ + tracesPerSecond: 0, + samplingRatio: 1, + azureMonitor: { enabled: false }, + enableConsoleExporters: false, + spanProcessors: [new SimpleSpanProcessor(exporter)], + instrumentationOptions: { + openaiAgents: { enabled: false }, + langchain: { enabled: true, isContentRecordingEnabled: true }, + }, + }); + + // Awaiting the readiness promise must guarantee the patch is applied — with + // NO vi.waitFor/polling. This is the contract that lets ESM apps (via a + // top-level await in their telemetry bootstrap) avoid the init race that + // dropped the top-level invoke_agent span on the first invocation. + await whenGenAIInstrumentationsReady(); + + const manager = CallbackManager.configure([], []); + const hasLangChainTracer = manager.inheritableHandlers.some( + (h: any) => h?.name === "OpenTelemetryLangChainTracer", + ); + expect(hasLangChainTracer).toBe(true); + }); }); diff --git a/test/internal/unit/genai/langchain/tracer.test.ts b/test/internal/unit/genai/langchain/tracer.test.ts index 1e028ab..07d76e5 100644 --- a/test/internal/unit/genai/langchain/tracer.test.ts +++ b/test/internal/unit/genai/langchain/tracer.test.ts @@ -168,6 +168,24 @@ describe("LangChainTracer", () => { assert.ok(spanName.includes("WeatherBot"), "span name should include agent name"); }); + it("sets span kind to INTERNAL for LangGraph agent runs so they export as dependencies", async () => { + const tracer = createMockTracer(); + const lct = new LangChainTracer(tracer); + const run = makeLangGraphRun({ name: "WeatherBot" }); + await lct.onRunCreate(run); + const kind = (tracer.startSpan as ReturnType).mock.calls[0][1]?.kind; + assert.strictEqual(kind, SpanKind.INTERNAL); + }); + + it("sets span kind to INTERNAL for tool runs (in-process execution)", async () => { + const tracer = createMockTracer(); + const lct = new LangChainTracer(tracer); + const run = makeRun({ run_type: "tool", name: "search", serialized: { name: "search" } }); + await lct.onRunCreate(run); + const kind = (tracer.startSpan as ReturnType).mock.calls[0][1]?.kind; + assert.strictEqual(kind, SpanKind.INTERNAL); + }); + it("skips internal runs tagged langsmith:hidden", async () => { const tracer = createMockTracer(); const lct = new LangChainTracer(tracer); From 73110c8e95a19216359468da8c381f9ce98af148 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 15 Jul 2026 16:06:09 -0700 Subject: [PATCH 3/5] fix(langchain): nest client spans under runs via wrapRunExecution Implement the optional wrapRunExecution callback hook on LangChainTracer. LangChain-core invokes it around a run body (chat model _generate, streaming steps, tool _call) after the run's span is opened, so we activate that span as the current OTel context for the duration. Client instrumentations firing inside the body (HTTP/fetch/undici) then nest their spans under the run's span instead of forming disconnected root traces. Relies on the wrapRunExecution hook added in langchain-ai/langchainjs#11211; no-op until that change ships (core skips handlers that don't implement it). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../instrumentations/langchain/tracer.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ca239..88c6ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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 +- LangChain: implement the `wrapRunExecution` callback hook so client spans (HTTP/`fetch`) emitted during a chat model or tool run nest under that run's span instead of forming disconnected root traces (requires langchain-ai/langchainjs#11211) - Add `whenGenAIInstrumentationsReady()` so ESM apps can await GenAI (LangChain / OpenAI Agents) instrumentation setup before their first invocation, eliminating a startup race that could drop the top-level `invoke_agent` span ### Bugs Fixed diff --git a/src/genai/instrumentations/langchain/tracer.ts b/src/genai/instrumentations/langchain/tracer.ts index 6d4b585..55648ab 100644 --- a/src/genai/instrumentations/langchain/tracer.ts +++ b/src/genai/instrumentations/langchain/tracer.ts @@ -155,6 +155,29 @@ export class LangChainTracer extends BaseTracer { this.runs.set(run.id, { run, span, startTime, lastAccessTime: startTime }); } + /** + * LangChain-core run-context hook (`BaseCallbackHandler.wrapRunExecution`). + * + * Core invokes this around the execution of a run *body* — a chat model's + * `_generate`, each step of a streaming response, and a tool's `_call` — after + * the corresponding span has been opened in {@link startTracing}. We make that + * run's span the active OTel span for the duration of `fn` so that any client + * instrumentation firing inside the body (HTTP/`fetch`/undici, DB drivers, + * etc.) reads it as the current context and nests its spans under the run's + * span, instead of emitting disconnected root traces. + * + * If no span is tracked for `runId` (e.g. an internal/suppressed run that was + * skipped), `fn` is invoked directly so behavior is unchanged. + */ + wrapRunExecution(runId: string, fn: () => T): T { + const entry = this.runs.get(runId); + if (!entry) { + return fn(); + } + const runContext = trace.setSpan(context.active(), entry.span); + return context.with(runContext, fn); + } + /** * Called by LangChain when a run finishes. Sets status, enriches the span * with GenAI attributes, and ends it. From 1253ec0b57bc9d8c66c2c9091fa410358cb9a100 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 15 Jul 2026 16:10:56 -0700 Subject: [PATCH 4/5] test(langchain): cover wrapRunExecution run-context hook Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../unit/genai/langchain/tracer.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/internal/unit/genai/langchain/tracer.test.ts b/test/internal/unit/genai/langchain/tracer.test.ts index 07d76e5..313f084 100644 --- a/test/internal/unit/genai/langchain/tracer.test.ts +++ b/test/internal/unit/genai/langchain/tracer.test.ts @@ -9,6 +9,8 @@ import { SpanStatusCode, Tracer, TraceFlags, + context, + trace, } from "@opentelemetry/api"; import type { Run } from "@langchain/core/tracers/base"; import { LangChainTracer } from "../../../../../src/genai/instrumentations/langchain/tracer.js"; @@ -432,4 +434,55 @@ describe("LangChainTracer", () => { assert.strictEqual(span.statusObj?.code, SpanStatusCode.OK); }); }); + + describe("wrapRunExecution", () => { + it("runs the body with the run's span active so client spans nest under it", async () => { + const tracer = createMockTracer(); + const lct = new LangChainTracer(tracer); + const run = makeRun({ name: "gpt-4o" }); + await lct.onRunCreate(run); + + // context.with only propagates the active span when a context manager is + // registered (the real distro registers one via the NodeTracerProvider). + // In isolation we instead assert the contract directly: the run's span is + // placed into the context that `context.with` activates around the body. + let ctxSpanId: string | undefined; + const withSpy = vi + .spyOn(context, "with") + .mockImplementation((ctx, fn, ...rest: unknown[]) => { + ctxSpanId = trace.getSpan(ctx)?.spanContext().spanId; + return (fn as (...a: unknown[]) => unknown)(...rest); + }); + + const result = lct.wrapRunExecution(run.id, () => "body-result"); + + assert.strictEqual(result, "body-result", "should return the body's result"); + assert.strictEqual(withSpy.mock.calls.length, 1, "should activate a context once"); + assert.strictEqual( + ctxSpanId, + tracer.lastSpan!.spanContext().spanId, + "the run's span should be set on the activated context", + ); + }); + + it("invokes the body directly when no span is tracked for the run", () => { + const tracer = createMockTracer(); + const lct = new LangChainTracer(tracer); + const withSpy = vi.spyOn(context, "with"); + + let called = false; + const result = lct.wrapRunExecution("unknown-run-id", () => { + called = true; + return 42; + }); + + assert.strictEqual(called, true, "body should still be invoked"); + assert.strictEqual(result, 42, "should return the body's result"); + assert.strictEqual( + withSpy.mock.calls.length, + 0, + "should not activate a context when no span is tracked", + ); + }); + }); }); From b6172d4b66099dc97f2144ccb82b4e0153966301 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 15 Jul 2026 18:13:31 -0700 Subject: [PATCH 5/5] fix(langchain): await start callbacks so client-span nesting is deterministic Span creation runs in the async onRunCreate callback. With LangChain's default background callbacks (LANGCHAIN_CALLBACKS_BACKGROUND !== 'false') that work is queued, so a run body's fetch could execute before the span existed, leaving wrapRunExecution with no span to activate and client (HTTP/fetch) spans nesting only intermittently. Force awaitHandlers=true on the tracer so the span is registered before the run body runs, making nesting deterministic regardless of the host app's callback configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/genai/instrumentations/langchain/tracer.ts | 9 +++++++++ test/internal/unit/genai/langchain/tracer.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/genai/instrumentations/langchain/tracer.ts b/src/genai/instrumentations/langchain/tracer.ts index 55648ab..fb09f6a 100644 --- a/src/genai/instrumentations/langchain/tracer.ts +++ b/src/genai/instrumentations/langchain/tracer.ts @@ -46,6 +46,15 @@ export class LangChainTracer extends BaseTracer { constructor(tracer: Tracer) { super(); this.tracer = tracer; + // Force LangChain to await this handler's callbacks instead of queuing them + // in the background (the default when LANGCHAIN_CALLBACKS_BACKGROUND !== + // "false"). Span creation happens in the async `onRunCreate` callback; if it + // runs in the background, the run body (e.g. a chat model's `fetch`) can + // execute — and `wrapRunExecution` can be invoked — before the span exists, + // so client spans race and only intermittently nest under the run's span. + // Awaiting guarantees the span is registered before the body runs, making + // context propagation (and thus HTTP/`fetch` span nesting) deterministic. + this.awaitHandlers = true; } name = "OpenTelemetryLangChainTracer"; diff --git a/test/internal/unit/genai/langchain/tracer.test.ts b/test/internal/unit/genai/langchain/tracer.test.ts index 313f084..f733af1 100644 --- a/test/internal/unit/genai/langchain/tracer.test.ts +++ b/test/internal/unit/genai/langchain/tracer.test.ts @@ -135,6 +135,16 @@ describe("LangChainTracer", () => { const lct = new LangChainTracer(tracer); assert.strictEqual(lct.name, "OpenTelemetryLangChainTracer"); }); + + it("forces awaitHandlers so span creation is not backgrounded", () => { + const tracer = createMockTracer(); + const lct = new LangChainTracer(tracer); + assert.strictEqual( + (lct as unknown as { awaitHandlers: boolean }).awaitHandlers, + true, + "awaitHandlers must be true so the run's span exists before wrapRunExecution runs", + ); + }); }); describe("onRunCreate / startTracing", () => {