Skip to content
Draft
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [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
- 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
- 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`

Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 65 additions & 2 deletions src/distro/distro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +63,7 @@ let isShutdown = false;

const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray<keyof InstrumentationOptions> = [
"http",
"undici",
"azureSdk",
"mongoDb",
"mySql",
Expand All @@ -79,6 +81,33 @@ const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray<keyof Instrumenta
*/
const REDIS_LINKED_KEYS: ReadonlyArray<keyof InstrumentationOptions> = ["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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -462,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<void> = Promise.resolve();

/**
* Await the completion of GenAI instrumentation setup (LangChain / OpenAI
* Agents). Resolves immediately when no GenAI instrumentation is active.
*/
export function whenGenAIInstrumentationsReady(): Promise<void> {
return genAIInstrumentationsReady;
}

function initializeGenAIInstrumentations(options?: InstrumentationOptions): void {
const pending: Promise<void>[] = [];

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);
}

/**
Expand Down Expand Up @@ -500,6 +556,13 @@ async function initializeOpenAIAgentsInstrumentation(
async function initializeLangChainInstrumentation(
_options: LangChainInstrumentationConfig,
): Promise<void> {
// 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"),
Expand Down
6 changes: 5 additions & 1 deletion src/distro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
34 changes: 33 additions & 1 deletion src/distro/instrumentations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ class LangChainTraceInstrumentorImpl extends InstrumentationBase<Instrumentation
// in that window (typically the first compiled-graph `invoke` after distro
// startup) silently fell through with no tracer attached, dropping the
// outer `invoke_agent LangGraph` wrapper span and fragmenting the trace.
// A static import is safe: by the time `patch()` runs, the callbacks
// manager module is already loaded (it is the `module` argument).
private _tracerCtor: LangChainTracerCtor = LangChainTracer;

private constructor() {
Expand Down
45 changes: 43 additions & 2 deletions src/genai/instrumentations/langchain/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -102,10 +111,19 @@ export class LangChainTracer extends BaseTracer {
let kind: SpanKind = SpanKind.INTERNAL;
if (operation === "invoke_agent") {
spanName = `${operation} ${run.name}`;
kind = SpanKind.SERVER;
// In-process agent orchestration (e.g. LangGraph) maps to the GenAI
// "invoke agent internal span" (SpanKind.INTERNAL), not SERVER. The Azure
// Monitor exporter turns SERVER spans into requests, which the
// Application Insights "AI agents (preview)" experience does not treat as
// agent calls; INTERNAL exports them as dependencies so they surface in
// the agents graph. Matches the OTel GenAI semconv and the Python distro.
kind = SpanKind.INTERNAL;
} else if (operation === "execute_tool") {
spanName = `${operation} ${run.name}`;
kind = SpanKind.CLIENT;
// Tool execution runs in-process, so per the GenAI "execute tool" semantic
// convention (and matching the Python distro) the span kind is INTERNAL,
// not CLIENT — it is not an outbound/remote dependency.
kind = SpanKind.INTERNAL;
} else if (operation === "chat") {
spanName = `${operation} ${Utils.getModel(run) || run.name}`.trim();
kind = SpanKind.CLIENT;
Expand Down Expand Up @@ -146,6 +164,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<T>(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.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type { AzureMonitorOpenTelemetryOptions };
export {
useMicrosoftOpenTelemetry,
shutdownMicrosoftOpenTelemetry,
whenGenAIInstrumentationsReady,
MICROSOFT_OPENTELEMETRY_VERSION,
} from "./distro/index.js";
export type {
Expand Down
3 changes: 2 additions & 1 deletion src/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 },
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading