From d5dd4a748fff261b9fbbbc767f618b85f1666473 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 17 Jun 2026 14:33:49 -0700 Subject: [PATCH 1/5] Add multi-instance SDK support via delegating providers Introduce createMicrosoftOpenTelemetryInstance to run multiple isolated SDK instances in one Node.js runtime. Parent (delegating) Tracer/Meter/Logger providers route per-call to the current child instance, resolved via an AsyncLocalStorage-backed ambient context (runWithInstance) with a default fallback. Each instance owns its own resource, sampler, processors, readers, and exporters. Additive and opt-in; the existing useMicrosoftOpenTelemetry single-instance path is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 1 + package.json | 1 + src/distro/index.ts | 5 + .../multiInstance/delegatingProviders.ts | 167 ++++++++++++++ src/distro/multiInstance/globalSetup.ts | 63 ++++++ src/distro/multiInstance/index.ts | 8 + src/distro/multiInstance/instance.ts | 214 ++++++++++++++++++ src/distro/multiInstance/instanceRegistry.ts | 85 +++++++ src/index.ts | 3 + src/types.ts | 32 +++ .../internal/functional/multiInstance.test.ts | 132 +++++++++++ 11 files changed, 711 insertions(+) create mode 100644 src/distro/multiInstance/delegatingProviders.ts create mode 100644 src/distro/multiInstance/globalSetup.ts create mode 100644 src/distro/multiInstance/index.ts create mode 100644 src/distro/multiInstance/instance.ts create mode 100644 src/distro/multiInstance/instanceRegistry.ts create mode 100644 test/internal/functional/multiInstance.test.ts diff --git a/package-lock.json b/package-lock.json index a41abed..d5d53c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@microsoft/applicationinsights-web-snippet": "^1.2.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/core": "^2.7.1", "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", diff --git a/package.json b/package.json index 78e8bab..80d6140 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@microsoft/applicationinsights-web-snippet": "^1.2.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/context-async-hooks": "^2.7.1", "@opentelemetry/core": "^2.7.1", "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0", diff --git a/src/distro/index.ts b/src/distro/index.ts index b32aeba..9331474 100644 --- a/src/distro/index.ts +++ b/src/distro/index.ts @@ -7,6 +7,11 @@ export type { BrowserSdkLoaderOptions, A365Options, } from "./types.js"; +export type { MicrosoftOpenTelemetryInstance } from "../types.js"; export { MICROSOFT_OPENTELEMETRY_VERSION } from "./types.js"; export { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry } from "./distro.js"; +export { + createMicrosoftOpenTelemetryInstance, + runWithMicrosoftOpenTelemetryInstance, +} from "./multiInstance/index.js"; diff --git a/src/distro/multiInstance/delegatingProviders.ts b/src/distro/multiInstance/delegatingProviders.ts new file mode 100644 index 0000000..ca4e120 --- /dev/null +++ b/src/distro/multiInstance/delegatingProviders.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + Tracer, + TracerProvider, + Span, + SpanOptions, + Context, + Meter, + MeterProvider, + MeterOptions, + MetricOptions, + BatchObservableCallback, + Observable, + Counter, + UpDownCounter, + Gauge, + Histogram, + ObservableGauge, + ObservableCounter, + ObservableUpDownCounter, +} from "@opentelemetry/api"; +import { createNoopMeter, ProxyTracerProvider } from "@opentelemetry/api"; +import type { Logger, LoggerProvider, LoggerOptions, LogRecord } from "@opentelemetry/api-logs"; +import { NOOP_LOGGER } from "@opentelemetry/api-logs"; + +import { resolveInstanceProviders } from "./instanceRegistry.js"; + +// Shared fallbacks used when no instance is registered/resolved yet. They are +// no-ops so that early or out-of-band global API access never throws. +const NOOP_TRACER_PROVIDER = new ProxyTracerProvider(); +const NOOP_METER = createNoopMeter(); + +/** + * A Tracer that resolves the current instance's tracer on every call. Resolution + * MUST be per-call (never cached) because the ambient instance changes with the + * active context. + */ +class DelegatingTracer implements Tracer { + constructor( + private readonly name: string, + private readonly version?: string, + private readonly options?: { schemaUrl?: string }, + ) {} + + private delegate(): Tracer { + const providers = resolveInstanceProviders(); + const provider: TracerProvider = providers?.tracerProvider ?? NOOP_TRACER_PROVIDER; + return provider.getTracer(this.name, this.version, this.options); + } + + startSpan(name: string, options?: SpanOptions, context?: Context): Span { + return this.delegate().startSpan(name, options, context); + } + + // The api defines several overloads for startActiveSpan; forward all args. + startActiveSpan unknown>(name: string, fn: F): ReturnType; + startActiveSpan unknown>( + name: string, + options: SpanOptions, + fn: F, + ): ReturnType; + startActiveSpan unknown>( + name: string, + options: SpanOptions, + context: Context, + fn: F, + ): ReturnType; + startActiveSpan(name: string, ...args: unknown[]): unknown { + return (this.delegate().startActiveSpan as (...a: unknown[]) => unknown)(name, ...args); + } +} + +/** + * Global parent TracerProvider registered once. It owns no pipeline itself; it + * delegates to the resolved child instance's TracerProvider. + */ +export class ParentTracerProvider implements TracerProvider { + getTracer(name: string, version?: string, options?: { schemaUrl?: string }): Tracer { + return new DelegatingTracer(name, version, options); + } +} + +/** A Meter that resolves the current instance's meter on every instrument call. */ +class DelegatingMeter implements Meter { + constructor( + private readonly name: string, + private readonly version?: string, + private readonly options?: MeterOptions, + ) {} + + private delegate(): Meter { + const providers = resolveInstanceProviders(); + const provider: MeterProvider | undefined = providers?.meterProvider; + return provider ? provider.getMeter(this.name, this.version, this.options) : NOOP_METER; + } + + createGauge(name: string, options?: MetricOptions): Gauge { + return this.delegate().createGauge(name, options); + } + createHistogram(name: string, options?: MetricOptions): Histogram { + return this.delegate().createHistogram(name, options); + } + createCounter(name: string, options?: MetricOptions): Counter { + return this.delegate().createCounter(name, options); + } + createUpDownCounter(name: string, options?: MetricOptions): UpDownCounter { + return this.delegate().createUpDownCounter(name, options); + } + createObservableGauge(name: string, options?: MetricOptions): ObservableGauge { + return this.delegate().createObservableGauge(name, options); + } + createObservableCounter(name: string, options?: MetricOptions): ObservableCounter { + return this.delegate().createObservableCounter(name, options); + } + createObservableUpDownCounter(name: string, options?: MetricOptions): ObservableUpDownCounter { + return this.delegate().createObservableUpDownCounter(name, options); + } + addBatchObservableCallback(callback: BatchObservableCallback, observables: Observable[]): void { + this.delegate().addBatchObservableCallback(callback, observables); + } + removeBatchObservableCallback( + callback: BatchObservableCallback, + observables: Observable[], + ): void { + this.delegate().removeBatchObservableCallback(callback, observables); + } +} + +/** Global parent MeterProvider registered once; delegates to the resolved child. */ +export class ParentMeterProvider implements MeterProvider { + getMeter(name: string, version?: string, options?: MeterOptions): Meter { + return new DelegatingMeter(name, version, options); + } +} + +/** A Logger that resolves the current instance's logger on every emit. */ +class DelegatingLogger implements Logger { + constructor( + private readonly name: string, + private readonly version?: string, + private readonly options?: LoggerOptions, + ) {} + + private delegate(): Logger { + const providers = resolveInstanceProviders(); + return providers + ? providers.loggerProvider.getLogger(this.name, this.version, this.options) + : NOOP_LOGGER; + } + + emit(logRecord: LogRecord): void { + this.delegate().emit(logRecord); + } + + enabled(options?: Parameters[0]): boolean { + return this.delegate().enabled(options); + } +} + +/** Global parent LoggerProvider registered once; delegates to the resolved child. */ +export class ParentLoggerProvider implements LoggerProvider { + getLogger(name: string, version?: string, options?: LoggerOptions): Logger { + return new DelegatingLogger(name, version, options); + } +} diff --git a/src/distro/multiInstance/globalSetup.ts b/src/distro/multiInstance/globalSetup.ts new file mode 100644 index 0000000..ea4d911 --- /dev/null +++ b/src/distro/multiInstance/globalSetup.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { context, metrics, propagation, trace } from "@opentelemetry/api"; +import { logs } from "@opentelemetry/api-logs"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { + CompositePropagator, + W3CBaggagePropagator, + W3CTraceContextPropagator, +} from "@opentelemetry/core"; + +import { + ParentLoggerProvider, + ParentMeterProvider, + ParentTracerProvider, +} from "./delegatingProviders.js"; + +let globalSetupDone = false; + +/** + * Register the parent (delegating) providers and the shared process-global + * context manager + propagator exactly once. Context and propagation are + * process-wide concerns shared by every instance, so they are NOT duplicated + * per instance. + * + * Idempotent: safe to call on every `useMicrosoftOpenTelemetry()` / + * `createMicrosoftOpenTelemetryInstance()` invocation. + */ +export function ensureGlobalSetup(): void { + if (globalSetupDone) { + return; + } + + // Clear any stale OpenTelemetry API global state to avoid version conflicts + // (mirrors the cleanup performed by the single-instance distro path). + trace.disable(); + metrics.disable(); + logs.disable(); + const globalOpentelemetryApiKey = Symbol.for("opentelemetry.js.api.1"); + delete (globalThis as Record)[globalOpentelemetryApiKey]; + + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); + + propagation.setGlobalPropagator( + new CompositePropagator({ + propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()], + }), + ); + + trace.setGlobalTracerProvider(new ParentTracerProvider()); + metrics.setGlobalMeterProvider(new ParentMeterProvider()); + logs.setGlobalLoggerProvider(new ParentLoggerProvider()); + + globalSetupDone = true; +} + +/** Test helper: allow re-running global setup. @internal */ +export function _resetGlobalSetup(): void { + globalSetupDone = false; +} diff --git a/src/distro/multiInstance/index.ts b/src/distro/multiInstance/index.ts new file mode 100644 index 0000000..e526df4 --- /dev/null +++ b/src/distro/multiInstance/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { createMicrosoftOpenTelemetryInstance } from "./instance.js"; +export { + withInstance as runWithMicrosoftOpenTelemetryInstance, + getCurrentInstanceId as _getCurrentInstanceId, +} from "./instanceRegistry.js"; diff --git a/src/distro/multiInstance/instance.ts b/src/distro/multiInstance/instance.ts new file mode 100644 index 0000000..514a055 --- /dev/null +++ b/src/distro/multiInstance/instance.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meter, Tracer } from "@opentelemetry/api"; +import type { Logger } from "@opentelemetry/api-logs"; +import { + type SpanProcessor, + SimpleSpanProcessor, + ConsoleSpanExporter, +} from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import type { LogRecordProcessor } from "@opentelemetry/sdk-logs"; +import { + LoggerProvider, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, +} from "@opentelemetry/sdk-logs"; +import type { MetricReader, ViewOptions } from "@opentelemetry/sdk-metrics"; +import { + MeterProvider, + ConsoleMetricExporter, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; + +import { InternalConfig } from "../../shared/config.js"; +import { MetricHandler } from "../../azureMonitor/metrics/index.js"; +import { TraceHandler } from "../../azureMonitor/traces/handler.js"; +import { LogHandler } from "../../azureMonitor/logs/index.js"; +import { + hasAzureMonitorConnectionString, + setupAzureMonitorComponents, + validateAzureMonitorConfig, +} from "../../azureMonitor/index.js"; +import type { MicrosoftOpenTelemetryInstance, MicrosoftOpenTelemetryOptions } from "../../types.js"; +import { createSampler, createViews } from "../instrumentations.js"; +import { ensureGlobalSetup } from "./globalSetup.js"; +import { + registerInstance, + setDefaultInstance, + unregisterInstance, + withInstance, +} from "./instanceRegistry.js"; + +let instanceCounter = 0; + +/** + * Build the child telemetry pipeline (providers + processors/readers) for a + * single instance. Unlike the single-instance distro path, this does NOT call + * `NodeSDK.start()` — the child providers are never registered as the global + * providers. Instead they are registered with the instance registry and the + * global parent (delegating) providers route to them. + */ +class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstance { + readonly id: string; + private readonly tracerProvider: NodeTracerProvider; + private readonly meterProvider: MeterProvider; + private readonly loggerProvider: LoggerProvider; + private readonly disposers: Array<() => void | Promise> = []; + private shutdownPromise?: Promise; + + constructor(id: string, options?: MicrosoftOpenTelemetryOptions) { + this.id = id; + const config = new InternalConfig(options); + + const azureMonitorRequested = + options?.azureMonitor?.enabled !== false && + (!!options?.azureMonitor || hasAzureMonitorConnectionString(config)); + const azureMonitorEnabled = azureMonitorRequested && validateAzureMonitorConfig(config); + + if (azureMonitorEnabled) { + this.disposers.push(setupAzureMonitorComponents(config)); + } + + const sampler = createSampler(config); + + // ── Azure Monitor handlers (only when enabled) ────────────────── + let metricHandler: MetricHandler | undefined; + let traceHandler: TraceHandler | undefined; + let logHandler: LogHandler | undefined; + if (azureMonitorEnabled) { + metricHandler = new MetricHandler(config); + traceHandler = new TraceHandler(config, metricHandler); + logHandler = new LogHandler(config, metricHandler); + this.disposers.push(() => metricHandler!.shutdown()); + this.disposers.push(() => traceHandler!.shutdown()); + // LogHandler owns no exporter of its own to dispose; its processors are + // shut down with the LoggerProvider below. + } + + // ── Compose pipelines (Azure Monitor + caller-supplied) ───────── + const spanProcessors: SpanProcessor[] = [ + ...(traceHandler ? [traceHandler.getAzureMonitorSpanProcessor()] : []), + ...(options?.spanProcessors ?? []), + ...(traceHandler ? [traceHandler.getBatchSpanProcessor()] : []), + ]; + const logRecordProcessors: LogRecordProcessor[] = [ + ...(logHandler ? [logHandler.getAzureLogRecordProcessor()] : []), + ...(options?.logRecordProcessors ?? []), + ...(logHandler ? [logHandler.getBatchLogRecordProcessor()] : []), + ]; + const metricReaders: MetricReader[] = [ + ...(metricHandler ? [metricHandler.getMetricReader()] : []), + ...(options?.metricReaders ?? []), + ]; + const views: ViewOptions[] = [ + ...(metricHandler ? metricHandler.getViews() : createViews(config)), + ...(options?.views ?? []), + ]; + + // ── Console fallback when nothing else is configured ──────────── + const hasCustomProcessors = + (options?.spanProcessors?.length ?? 0) > 0 || + (options?.metricReaders?.length ?? 0) > 0 || + (options?.logRecordProcessors?.length ?? 0) > 0; + const consoleEnabled = + options?.enableConsoleExporters ?? (!azureMonitorEnabled && !hasCustomProcessors); + if (consoleEnabled) { + spanProcessors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())); + metricReaders.push( + new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter(), + exportIntervalMillis: config.metricExportIntervalMillis, + }), + ); + logRecordProcessors.push(new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())); + } + + // ── Build child providers (NOT registered globally) ───────────── + this.tracerProvider = new NodeTracerProvider({ + resource: config.resource, + sampler, + spanProcessors, + }); + this.meterProvider = new MeterProvider({ + resource: config.resource, + views, + readers: metricReaders, + }); + this.loggerProvider = new LoggerProvider({ + resource: config.resource, + processors: logRecordProcessors, + }); + + registerInstance(this.id, { + tracerProvider: this.tracerProvider, + meterProvider: this.meterProvider, + loggerProvider: this.loggerProvider, + }); + } + + getTracer(name: string, version?: string): Tracer { + return this.tracerProvider.getTracer(name, version); + } + + getMeter(name: string, version?: string): Meter { + return this.meterProvider.getMeter(name, version); + } + + getLogger(name: string, version?: string): Logger { + return this.loggerProvider.getLogger(name, version); + } + + runWithInstance(fn: () => T): T { + return withInstance(this.id, fn); + } + + async forceFlush(): Promise { + await Promise.all([ + this.tracerProvider.forceFlush(), + this.meterProvider.forceFlush(), + this.loggerProvider.forceFlush(), + ]); + } + + shutdown(): Promise { + if (this.shutdownPromise) { + return this.shutdownPromise; + } + unregisterInstance(this.id); + this.shutdownPromise = (async () => { + await Promise.allSettled(this.disposers.map((d) => d())); + await Promise.allSettled([ + this.tracerProvider.shutdown(), + this.meterProvider.shutdown(), + this.loggerProvider.shutdown(), + ]); + })(); + return this.shutdownPromise; + } +} + +/** + * Create an isolated Microsoft OpenTelemetry SDK instance. + * + * Unlike {@link useMicrosoftOpenTelemetry} (single, global default instance), + * this can be called multiple times in the same Node.js runtime to run + * independent, isolated pipelines side by side — for example two Azure Monitor + * resources with different connection strings. + * + * The first instance created becomes the default for global API access; pass a + * truthy `makeDefault` to override. + */ +export function createMicrosoftOpenTelemetryInstance( + options?: MicrosoftOpenTelemetryOptions, + config?: { makeDefault?: boolean }, +): MicrosoftOpenTelemetryInstance { + ensureGlobalSetup(); + const id = `microsoft-otel-instance-${++instanceCounter}`; + const instance = new MicrosoftOpenTelemetryInstanceImpl(id, options); + if (config?.makeDefault) { + setDefaultInstance(id); + } + return instance; +} diff --git a/src/distro/multiInstance/instanceRegistry.ts b/src/distro/multiInstance/instanceRegistry.ts new file mode 100644 index 0000000..c8090cf --- /dev/null +++ b/src/distro/multiInstance/instanceRegistry.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Context, TracerProvider, MeterProvider } from "@opentelemetry/api"; +import { context, createContextKey } from "@opentelemetry/api"; +import type { LoggerProvider } from "@opentelemetry/api-logs"; + +/** + * The set of child providers owned by a single SDK instance. The parent + * (delegating) providers route to one of these based on the ambient + * "current instance". + */ +export interface InstanceProviders { + readonly tracerProvider: TracerProvider; + readonly meterProvider: MeterProvider; + readonly loggerProvider: LoggerProvider; +} + +const CURRENT_INSTANCE_KEY = createContextKey("microsoft.opentelemetry.current_instance"); + +const registry = new Map(); +let defaultInstanceId: string | undefined; + +/** + * Register a child instance. The first registered instance becomes the default + * so that global API access (e.g. `trace.getTracer(...)`) keeps working exactly + * as it does in the single-instance case. + */ +export function registerInstance(id: string, providers: InstanceProviders): void { + registry.set(id, providers); + if (defaultInstanceId === undefined) { + defaultInstanceId = id; + } +} + +/** + * Remove a child instance from the registry. If it was the default, the next + * remaining instance (if any) is promoted to default. + */ +export function unregisterInstance(id: string): void { + registry.delete(id); + if (defaultInstanceId === id) { + defaultInstanceId = registry.keys().next().value; + } +} + +/** Explicitly mark an already-registered instance as the default. */ +export function setDefaultInstance(id: string): void { + if (registry.has(id)) { + defaultInstanceId = id; + } +} + +export function getDefaultInstanceId(): string | undefined { + return defaultInstanceId; +} + +export function getInstanceProviders(id: string): InstanceProviders | undefined { + return registry.get(id); +} + +/** Bind `id` as the ambient current instance for the duration of `fn`. */ +export function withInstance(id: string, fn: () => T): T { + return context.with(context.active().setValue(CURRENT_INSTANCE_KEY, id), fn); +} + +/** Read the current instance id bound to a context (defaults to the active one). */ +export function getCurrentInstanceId(ctx: Context = context.active()): string | undefined { + return ctx.getValue(CURRENT_INSTANCE_KEY) as string | undefined; +} + +/** + * Resolve the providers of the instance that should handle the current + * operation: the ambient instance if one is bound, otherwise the default. + */ +export function resolveInstanceProviders(): InstanceProviders | undefined { + const id = getCurrentInstanceId() ?? defaultInstanceId; + return id ? registry.get(id) : undefined; +} + +/** Test helper: clear all registry state. @internal */ +export function _resetRegistry(): void { + registry.clear(); + defaultInstanceId = undefined; +} diff --git a/src/index.ts b/src/index.ts index 95ebbc9..f2ec59e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ export type { AzureMonitorOpenTelemetryOptions }; export { useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry, + createMicrosoftOpenTelemetryInstance, + runWithMicrosoftOpenTelemetryInstance, MICROSOFT_OPENTELEMETRY_VERSION, } from "./distro/index.js"; export type { @@ -15,6 +17,7 @@ export type { InstrumentationOptions, BrowserSdkLoaderOptions, A365Options, + MicrosoftOpenTelemetryInstance, } from "./distro/index.js"; // ── Re-exports from A365 configuration ────────────────────────────────────── diff --git a/src/types.ts b/src/types.ts index 6672ef9..60ba109 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import type { AzureMonitorExporterOptions } from "@azure/monitor-opentelemetry-exporter"; +import type { Meter, Tracer } from "@opentelemetry/api"; +import type { Logger } from "@opentelemetry/api-logs"; import type { InstrumentationConfig } from "@opentelemetry/instrumentation"; import type { Resource } from "@opentelemetry/resources"; import type { LogRecordProcessor } from "@opentelemetry/sdk-logs"; @@ -52,6 +54,36 @@ export interface MicrosoftOpenTelemetryOptions { enableConsoleExporters?: boolean; } +/** + * A handle to a single, isolated Microsoft OpenTelemetry SDK instance. + * + * Multiple instances can be created in the same Node.js runtime via + * {@link createMicrosoftOpenTelemetryInstance}. Each instance owns its own + * exporter pipeline (resource, sampler, processors, readers). Telemetry created + * through this handle — or within {@link MicrosoftOpenTelemetryInstance.runWithInstance} — + * is routed only to this instance's pipeline. + */ +export interface MicrosoftOpenTelemetryInstance { + /** A stable identifier for this instance. */ + readonly id: string; + /** Get a tracer bound to this instance's pipeline. */ + getTracer(name: string, version?: string): Tracer; + /** Get a meter bound to this instance's pipeline. */ + getMeter(name: string, version?: string): Meter; + /** Get a logger bound to this instance's pipeline. */ + getLogger(name: string, version?: string): Logger; + /** + * Run `fn` with this instance bound as the ambient "current instance" so that + * code using the global OpenTelemetry API (e.g. `trace.getTracer(...)`) routes + * to this instance's pipeline. + */ + runWithInstance(fn: () => T): T; + /** Flush this instance's pipeline. */ + forceFlush(): Promise; + /** Shut down and detach only this instance, leaving other instances active. */ + shutdown(): Promise; +} + /** * Azure Monitor scoped options. * diff --git a/test/internal/functional/multiInstance.test.ts b/test/internal/functional/multiInstance.test.ts new file mode 100644 index 0000000..e0e1ee2 --- /dev/null +++ b/test/internal/functional/multiInstance.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as opentelemetry from "@opentelemetry/api"; +import type { HttpClient, PipelineRequest } from "@azure/core-rest-pipeline"; + +import { + createMicrosoftOpenTelemetryInstance, + runWithMicrosoftOpenTelemetryInstance, +} from "../../../src/distro/index.js"; +import type { MicrosoftOpenTelemetryInstance } from "../../../src/distro/index.js"; +import { _resetRegistry } from "../../../src/distro/multiInstance/instanceRegistry.js"; +import { _resetGlobalSetup } from "../../../src/distro/multiInstance/globalSetup.js"; +import { successfulBreezeResponse } from "../../utils/breezeTestUtils.js"; +import type { TelemetryItem as Envelope } from "../../utils/models/index.js"; + +const IKEY_A = "11111111-1111-1111-1111-111111111111"; +const IKEY_B = "22222222-2222-2222-2222-222222222222"; +const CONNECTION_STRING_A = `InstrumentationKey=${IKEY_A};IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=11111111-1111-1111-1111-aaaaaaaaaaaa`; +const CONNECTION_STRING_B = `InstrumentationKey=${IKEY_B};IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=22222222-2222-2222-2222-bbbbbbbbbbbb`; + +/** Build an HttpClient that records every breeze envelope it receives. */ +function recordingHttpClient(sink: Envelope[]): HttpClient { + return { + sendRequest: vi.fn().mockImplementation((request: PipelineRequest) => { + const envelopes = JSON.parse(request.body as string) as Envelope[]; + sink.push(...envelopes); + return Promise.resolve({ + headers: request.headers, + request, + status: 200, + bodyAsText: JSON.stringify(successfulBreezeResponse(envelopes.length)), + }); + }), + }; +} + +/** Names of the span (Request/RemoteDependency) envelopes captured by a sink. */ +function spanNames(envelopes: Envelope[]): string[] { + return envelopes + .filter((e) => e.name?.endsWith("Request") || e.name?.endsWith("RemoteDependency")) + .map((e) => (e.data?.baseData as { name?: string } | undefined)?.name) + .filter((n): n is string => typeof n === "string"); +} + +function makeInstance( + connectionString: string, + httpClient: HttpClient, +): MicrosoftOpenTelemetryInstance { + return createMicrosoftOpenTelemetryInstance({ + // Use the deterministic ratio sampler (always-on) instead of the default + // rate limiter so the test is reliable. + tracesPerSecond: 0, + samplingRatio: 1, + // Keep only the span pipeline active so the test is deterministic and offline. + azureMonitor: { + enableLiveMetrics: false, + enableStandardMetrics: false, + enablePerformanceCounters: false, + azureMonitorExporterOptions: { connectionString, httpClient }, + }, + }); +} + +describe("Multiple SDK instances in one runtime", () => { + let instanceA: MicrosoftOpenTelemetryInstance | undefined; + let instanceB: MicrosoftOpenTelemetryInstance | undefined; + + afterEach(async () => { + await instanceA?.shutdown(); + await instanceB?.shutdown(); + instanceA = undefined; + instanceB = undefined; + _resetRegistry(); + _resetGlobalSetup(); + opentelemetry.trace.disable(); + opentelemetry.metrics.disable(); + }); + + it("routes each instance's telemetry only to its own Azure Monitor resource", async () => { + const ingestA: Envelope[] = []; + const ingestB: Envelope[] = []; + + instanceA = makeInstance(CONNECTION_STRING_A, recordingHttpClient(ingestA)); + instanceB = makeInstance(CONNECTION_STRING_B, recordingHttpClient(ingestB)); + + // Spans created via each instance's own tracer. + instanceA.getTracer("test").startSpan("alpha-span").end(); + instanceB.getTracer("test").startSpan("beta-span").end(); + + await instanceA.forceFlush(); + await instanceB.forceFlush(); + + // Each sink saw only its own span. + expect(spanNames(ingestA)).toContain("alpha-span"); + expect(spanNames(ingestA)).not.toContain("beta-span"); + expect(spanNames(ingestB)).toContain("beta-span"); + expect(spanNames(ingestB)).not.toContain("alpha-span"); + + // Each sink's envelopes are tagged only with its own instrumentation key. + expect(ingestA.length).toBeGreaterThan(0); + expect(ingestB.length).toBeGreaterThan(0); + expect(ingestA.every((e) => e.iKey === IKEY_A)).toBe(true); + expect(ingestB.every((e) => e.iKey === IKEY_B)).toBe(true); + }); + + it("routes global-API telemetry to the ambient instance bound via runWithInstance", async () => { + const ingestA: Envelope[] = []; + const ingestB: Envelope[] = []; + + instanceA = makeInstance(CONNECTION_STRING_A, recordingHttpClient(ingestA)); + instanceB = makeInstance(CONNECTION_STRING_B, recordingHttpClient(ingestB)); + + // Code that uses the global OpenTelemetry API (no handle) routes to whichever + // instance is bound as the ambient current instance. + runWithMicrosoftOpenTelemetryInstance(instanceA.id, () => { + opentelemetry.trace.getTracer("global").startSpan("global-into-a").end(); + }); + runWithMicrosoftOpenTelemetryInstance(instanceB.id, () => { + opentelemetry.trace.getTracer("global").startSpan("global-into-b").end(); + }); + + await instanceA.forceFlush(); + await instanceB.forceFlush(); + + expect(spanNames(ingestA)).toContain("global-into-a"); + expect(spanNames(ingestA)).not.toContain("global-into-b"); + expect(spanNames(ingestB)).toContain("global-into-b"); + expect(spanNames(ingestB)).not.toContain("global-into-a"); + }); +}); From bda7f892d31257ce765ca60d768c31da20be1be7 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 17 Jun 2026 14:55:11 -0700 Subject: [PATCH 2/5] Address PR review feedback on multi-instance SDK - withInstance: skip binding unknown/stale ids so resolution falls back to the default instance instead of producing silent no-op telemetry. - instance.shutdown: wrap disposers via Promise.resolve().then so a synchronous throw is captured and does not abort the rest of shutdown. - multiInstance test: also disable the logs provider and the global context manager in afterEach to prevent cross-test contamination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/distro/multiInstance/instance.ts | 4 +++- src/distro/multiInstance/instanceRegistry.ts | 11 ++++++++++- test/internal/functional/multiInstance.test.ts | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/distro/multiInstance/instance.ts b/src/distro/multiInstance/instance.ts index 514a055..04b1d26 100644 --- a/src/distro/multiInstance/instance.ts +++ b/src/distro/multiInstance/instance.ts @@ -178,7 +178,9 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan } unregisterInstance(this.id); this.shutdownPromise = (async () => { - await Promise.allSettled(this.disposers.map((d) => d())); + // Wrap each disposer so a synchronous throw is captured and does not + // abort the rest of shutdown. + await Promise.allSettled(this.disposers.map((d) => Promise.resolve().then(d))); await Promise.allSettled([ this.tracerProvider.shutdown(), this.meterProvider.shutdown(), diff --git a/src/distro/multiInstance/instanceRegistry.ts b/src/distro/multiInstance/instanceRegistry.ts index c8090cf..ec6cf6f 100644 --- a/src/distro/multiInstance/instanceRegistry.ts +++ b/src/distro/multiInstance/instanceRegistry.ts @@ -59,8 +59,17 @@ export function getInstanceProviders(id: string): InstanceProviders | undefined return registry.get(id); } -/** Bind `id` as the ambient current instance for the duration of `fn`. */ +/** + * Bind `id` as the ambient current instance for the duration of `fn`. + * + * If `id` is not a registered instance (e.g. an unknown or stale id, or one + * used after `shutdown()`), the binding is skipped so resolution falls back to + * the default instance rather than silently producing no-op telemetry. + */ export function withInstance(id: string, fn: () => T): T { + if (!registry.has(id)) { + return fn(); + } return context.with(context.active().setValue(CURRENT_INSTANCE_KEY, id), fn); } diff --git a/test/internal/functional/multiInstance.test.ts b/test/internal/functional/multiInstance.test.ts index e0e1ee2..e63a9a1 100644 --- a/test/internal/functional/multiInstance.test.ts +++ b/test/internal/functional/multiInstance.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as opentelemetry from "@opentelemetry/api"; +import { logs } from "@opentelemetry/api-logs"; import type { HttpClient, PipelineRequest } from "@azure/core-rest-pipeline"; import { @@ -74,8 +75,13 @@ describe("Multiple SDK instances in one runtime", () => { instanceB = undefined; _resetRegistry(); _resetGlobalSetup(); + // Disable every global the multi-instance setup installs (trace, metrics, + // logs, and the AsyncLocalStorage context manager) so state does not leak + // into other tests sharing this Vitest worker. opentelemetry.trace.disable(); opentelemetry.metrics.disable(); + opentelemetry.context.disable(); + logs.disable(); }); it("routes each instance's telemetry only to its own Azure Monitor resource", async () => { From f05c718c6b98a5c9e400ac1ddd87fa6bce74739f Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:41:32 -0700 Subject: [PATCH 3/5] feat(multiInstance): bind per-instance instrumentations and A365 export Each createMicrosoftOpenTelemetryInstance() now builds its own OpenTelemetry instrumentation set from its instrumentationOptions and binds it directly to that instance's providers via registerInstrumentations, so different exporters (e.g. Azure Monitor vs. A365) can run different instrumentations and settings in the same process. Adds A365 export support and per-instance A365 GenAI instrumentation defaults, tracks per-instance registration in SDKStats, and shares _applyA365InstrumentationDefaults between the single- and multi-instance paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + src/distro/distro.ts | 74 +---------- src/distro/instrumentations.ts | 73 +++++++++++ src/distro/multiInstance/globalSetup.ts | 6 + src/distro/multiInstance/instance.ts | 120 ++++++++++++++++-- src/types.ts | 6 +- .../internal/functional/multiInstance.test.ts | 56 ++++++++ 7 files changed, 256 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff482f..f63f4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Features Added +- Support multiple isolated SDK instances, each with its own instrumentations, settings, and exporter (e.g. different instrumentations for Azure Monitor vs. A365) + ## [1.1.0] - 2026-05-29 ### Features Added diff --git a/src/distro/distro.ts b/src/distro/distro.ts index 4ab7a3d..1b9eda5 100644 --- a/src/distro/distro.ts +++ b/src/distro/distro.ts @@ -53,8 +53,12 @@ import { SdkStatsFeature, } from "../types.js"; import { createInstrumentations, createSampler, createViews } from "./instrumentations.js"; +import { _applyA365InstrumentationDefaults } from "./instrumentations.js"; import { Logger } from "../shared/logging/index.js"; +// Re-export to preserve the existing import path used by tests/consumers. +export { _applyA365InstrumentationDefaults }; + process.env["AZURE_MONITOR_DISTRO_VERSION"] = AZURE_MONITOR_OPENTELEMETRY_VERSION; process.env["MICROSOFT_OPENTELEMETRY_VERSION"] = MICROSOFT_OPENTELEMETRY_VERSION; @@ -62,76 +66,6 @@ let sdk: NodeSDK; let disposeAzureMonitor: (() => void) | undefined; let isShutdown = false; -const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray = [ - "http", - "azureSdk", - "mongoDb", - "mySql", - "postgreSql", - "redis", - "redis4", - "bunyan", - "winston", -]; - -/** - * Redis and redis4 share the same underlying instrumentation. If a caller - * explicitly configures either key, treat both as explicitly configured so - * the other is not inadvertently disabled. - */ -const REDIS_LINKED_KEYS: ReadonlyArray = ["redis", "redis4"]; - -/** - * When A365 export is enabled, default to GenAI-focused telemetry by disabling - * non-GenAI instrumentations unless callers explicitly configure them. - * - * @internal - */ -export function _applyA365InstrumentationDefaults( - instrumentationOptions: InstrumentationOptions, - userInstrumentationOptions: unknown, - a365Enabled: boolean, -): void { - if (!a365Enabled) { - return; - } - - const userOptionsRecord = - userInstrumentationOptions && typeof userInstrumentationOptions === "object" - ? (userInstrumentationOptions as Record) - : undefined; - - // Pre-compute whether any Redis-linked key was explicitly configured so - // that configuring `redis4` alone does not inadvertently disable `redis` - // (and vice-versa), which would break the underlying shared instrumentation. - const redisLinkedExplicit = - !!userOptionsRecord && - REDIS_LINKED_KEYS.some((k) => Object.prototype.hasOwnProperty.call(userOptionsRecord, k)); - - for (const instrumentationKey of A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT) { - const isExplicitlyConfigured = - !!userOptionsRecord && - Object.prototype.hasOwnProperty.call(userOptionsRecord, instrumentationKey); - - // Treat redis/redis4 as a linked pair: if either was set by the caller, - // skip disabling both keys. - if ( - isExplicitlyConfigured || - (redisLinkedExplicit && - REDIS_LINKED_KEYS.includes(instrumentationKey as (typeof REDIS_LINKED_KEYS)[number])) - ) { - continue; - } - - const currentValue = instrumentationOptions[instrumentationKey]; - if (currentValue && typeof currentValue === "object") { - (currentValue as Record).enabled = false; - } else { - instrumentationOptions[instrumentationKey] = { enabled: false }; - } - } -} - /** * Initialize Microsoft OpenTelemetry. * diff --git a/src/distro/instrumentations.ts b/src/distro/instrumentations.ts index e4479b2..fdbf4d4 100644 --- a/src/distro/instrumentations.ts +++ b/src/distro/instrumentations.ts @@ -26,10 +26,83 @@ import type { Instrumentation } from "@opentelemetry/instrumentation"; import type { ViewOptions } from "@opentelemetry/sdk-metrics"; import type { InternalConfig } from "../shared/config.js"; +import type { InstrumentationOptions } from "../types.js"; import { ignoreOutgoingRequestHook } from "../azureMonitor/utils/common.js"; import { ApplicationInsightsSampler } from "../azureMonitor/traces/sampler.js"; import { logLevelToSeverityNumber } from "../azureMonitor/utils/logUtils.js"; +// ── A365 instrumentation defaults ─────────────────────────────────── + +const A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT: ReadonlyArray = [ + "http", + "azureSdk", + "mongoDb", + "mySql", + "postgreSql", + "redis", + "redis4", + "bunyan", + "winston", +]; + +/** + * Redis and redis4 share the same underlying instrumentation. If a caller + * explicitly configures either key, treat both as explicitly configured so + * the other is not inadvertently disabled. + */ +const REDIS_LINKED_KEYS: ReadonlyArray = ["redis", "redis4"]; + +/** + * When A365 export is enabled, default to GenAI-focused telemetry by disabling + * non-GenAI instrumentations unless callers explicitly configure them. + * + * @internal + */ +export function _applyA365InstrumentationDefaults( + instrumentationOptions: InstrumentationOptions, + userInstrumentationOptions: unknown, + a365Enabled: boolean, +): void { + if (!a365Enabled) { + return; + } + + const userOptionsRecord = + userInstrumentationOptions && typeof userInstrumentationOptions === "object" + ? (userInstrumentationOptions as Record) + : undefined; + + // Pre-compute whether any Redis-linked key was explicitly configured so + // that configuring `redis4` alone does not inadvertently disable `redis` + // (and vice-versa), which would break the underlying shared instrumentation. + const redisLinkedExplicit = + !!userOptionsRecord && + REDIS_LINKED_KEYS.some((k) => Object.prototype.hasOwnProperty.call(userOptionsRecord, k)); + + for (const instrumentationKey of A365_DISABLED_INSTRUMENTATIONS_BY_DEFAULT) { + const isExplicitlyConfigured = + !!userOptionsRecord && + Object.prototype.hasOwnProperty.call(userOptionsRecord, instrumentationKey); + + // Treat redis/redis4 as a linked pair: if either was set by the caller, + // skip disabling both keys. + if ( + isExplicitlyConfigured || + (redisLinkedExplicit && + REDIS_LINKED_KEYS.includes(instrumentationKey as (typeof REDIS_LINKED_KEYS)[number])) + ) { + continue; + } + + const currentValue = instrumentationOptions[instrumentationKey]; + if (currentValue && typeof currentValue === "object") { + (currentValue as Record).enabled = false; + } else { + instrumentationOptions[instrumentationKey] = { enabled: false }; + } + } +} + // ── Instrumentations ──────────────────────────────────────────────── /** diff --git a/src/distro/multiInstance/globalSetup.ts b/src/distro/multiInstance/globalSetup.ts index ea4d911..c459036 100644 --- a/src/distro/multiInstance/globalSetup.ts +++ b/src/distro/multiInstance/globalSetup.ts @@ -15,6 +15,7 @@ import { ParentMeterProvider, ParentTracerProvider, } from "./delegatingProviders.js"; +import { patchOpenTelemetryInstrumentationEnable } from "../../utils/opentelemetryInstrumentationPatcher.js"; let globalSetupDone = false; @@ -40,6 +41,11 @@ export function ensureGlobalSetup(): void { const globalOpentelemetryApiKey = Symbol.for("opentelemetry.js.api.1"); delete (globalThis as Record)[globalOpentelemetryApiKey]; + // Track per-instance instrumentation registration in the SDKStats env var, + // matching the single-instance distro path. Patched once because it wraps the + // OTel autoLoader's enableInstrumentations. + patchOpenTelemetryInstrumentationEnable(); + const contextManager = new AsyncLocalStorageContextManager(); contextManager.enable(); context.setGlobalContextManager(contextManager); diff --git a/src/distro/multiInstance/instance.ts b/src/distro/multiInstance/instance.ts index 04b1d26..cf4866a 100644 --- a/src/distro/multiInstance/instance.ts +++ b/src/distro/multiInstance/instance.ts @@ -5,6 +5,7 @@ import type { Meter, Tracer } from "@opentelemetry/api"; import type { Logger } from "@opentelemetry/api-logs"; import { type SpanProcessor, + BatchSpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter, } from "@opentelemetry/sdk-trace-base"; @@ -21,6 +22,7 @@ import { ConsoleMetricExporter, PeriodicExportingMetricReader, } from "@opentelemetry/sdk-metrics"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; import { InternalConfig } from "../../shared/config.js"; import { MetricHandler } from "../../azureMonitor/metrics/index.js"; @@ -31,8 +33,15 @@ import { setupAzureMonitorComponents, validateAzureMonitorConfig, } from "../../azureMonitor/index.js"; +import { A365Configuration, Agent365Exporter, A365SpanProcessor } from "../../a365/index.js"; +import { configureA365Logger } from "../../a365/logging.js"; import type { MicrosoftOpenTelemetryInstance, MicrosoftOpenTelemetryOptions } from "../../types.js"; -import { createSampler, createViews } from "../instrumentations.js"; +import { + _applyA365InstrumentationDefaults, + createInstrumentations, + createSampler, + createViews, +} from "../instrumentations.js"; import { ensureGlobalSetup } from "./globalSetup.js"; import { registerInstance, @@ -44,11 +53,17 @@ import { let instanceCounter = 0; /** - * Build the child telemetry pipeline (providers + processors/readers) for a - * single instance. Unlike the single-instance distro path, this does NOT call - * `NodeSDK.start()` — the child providers are never registered as the global - * providers. Instead they are registered with the instance registry and the - * global parent (delegating) providers route to them. + * Build the child telemetry pipeline (providers + processors/readers + + * instrumentations) for a single instance. Unlike the single-instance distro + * path, this does NOT call `NodeSDK.start()` — the child providers are never + * registered as the global providers. Instead they are registered with the + * instance registry, the global parent (delegating) providers route to them, + * and each instance's instrumentations are bound directly to its own providers. + * + * Binding instrumentations per instance is what allows a customer to register a + * **different** set of OpenTelemetry instrumentations (and settings) for the + * Azure Monitor exporter than for the A365 exporter in the same runtime: each + * instrumentation emits to its instance's provider → its instance's exporter. */ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstance { readonly id: string; @@ -56,6 +71,7 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan private readonly meterProvider: MeterProvider; private readonly loggerProvider: LoggerProvider; private readonly disposers: Array<() => void | Promise> = []; + private readonly unloadInstrumentations?: () => void; private shutdownPromise?: Promise; constructor(id: string, options?: MicrosoftOpenTelemetryOptions) { @@ -67,10 +83,33 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan (!!options?.azureMonitor || hasAzureMonitorConnectionString(config)); const azureMonitorEnabled = azureMonitorRequested && validateAzureMonitorConfig(config); + // ── A365 export ───────────────────────────────────────────────── + const a365Config = new A365Configuration(options?.a365); + if (a365Config.logLevel !== undefined) { + configureA365Logger({ logLevel: a365Config.logLevel }); + } + + // When this instance targets A365 only (no Azure Monitor), default to + // GenAI-focused telemetry by disabling non-GenAI instrumentations unless + // the caller explicitly configured them — mirroring the single-instance + // distro path. This is applied per instance, so an Azure Monitor instance + // and an A365 instance in the same runtime keep independent instrumentation + // sets. + const applyA365Defaults = a365Config.enabled && !azureMonitorEnabled; + _applyA365InstrumentationDefaults( + config.instrumentationOptions, + options?.instrumentationOptions, + applyA365Defaults, + ); + if (azureMonitorEnabled) { this.disposers.push(setupAzureMonitorComponents(config)); } + // ── Per-instance instrumentations & sampler ───────────────────── + const instrumentations = createInstrumentations(config, { + filterAzureMonitorRequests: azureMonitorEnabled, + }); const sampler = createSampler(config); // ── Azure Monitor handlers (only when enabled) ────────────────── @@ -87,12 +126,51 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan // shut down with the LoggerProvider below. } - // ── Compose pipelines (Azure Monitor + caller-supplied) ───────── + // ── Compose pipelines (Azure Monitor + caller-supplied + A365) ── const spanProcessors: SpanProcessor[] = [ ...(traceHandler ? [traceHandler.getAzureMonitorSpanProcessor()] : []), ...(options?.spanProcessors ?? []), - ...(traceHandler ? [traceHandler.getBatchSpanProcessor()] : []), ]; + + // A365: enrich spans with baggage/telemetry.sdk attributes, then (when the + // observability exporter is enabled) batch-export them to A365. + if (a365Config.enabled) { + spanProcessors.push(new A365SpanProcessor()); + if (a365Config.enableObservabilityExporter) { + const a365Exporter = new Agent365Exporter({ + clusterCategory: a365Config.clusterCategory, + domainOverride: a365Config.domainOverride, + authScopes: a365Config.authScopes, + tokenResolver: a365Config.tokenResolver, + contextualTokenResolver: a365Config.contextualTokenResolver, + useS2SEndpoint: a365Config.useS2SEndpoint, + ...(a365Config.maxQueueSize !== undefined && { + maxQueueSize: a365Config.maxQueueSize, + }), + ...(a365Config.scheduledDelayMilliseconds !== undefined && { + scheduledDelayMilliseconds: a365Config.scheduledDelayMilliseconds, + }), + ...(a365Config.exporterTimeoutMilliseconds !== undefined && { + exporterTimeoutMilliseconds: a365Config.exporterTimeoutMilliseconds, + }), + ...(a365Config.httpRequestTimeoutMilliseconds !== undefined && { + httpRequestTimeoutMilliseconds: a365Config.httpRequestTimeoutMilliseconds, + }), + ...(a365Config.maxExportBatchSize !== undefined && { + maxExportBatchSize: a365Config.maxExportBatchSize, + }), + ...(a365Config.maxPayloadBytes !== undefined && { + maxPayloadBytes: a365Config.maxPayloadBytes, + }), + }); + spanProcessors.push(new BatchSpanProcessor(a365Exporter)); + } + } + + if (traceHandler) { + spanProcessors.push(traceHandler.getBatchSpanProcessor()); + } + const logRecordProcessors: LogRecordProcessor[] = [ ...(logHandler ? [logHandler.getAzureLogRecordProcessor()] : []), ...(options?.logRecordProcessors ?? []), @@ -108,12 +186,14 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan ]; // ── Console fallback when nothing else is configured ──────────── + const a365Exporting = a365Config.enabled && a365Config.enableObservabilityExporter; const hasCustomProcessors = (options?.spanProcessors?.length ?? 0) > 0 || (options?.metricReaders?.length ?? 0) > 0 || (options?.logRecordProcessors?.length ?? 0) > 0; const consoleEnabled = - options?.enableConsoleExporters ?? (!azureMonitorEnabled && !hasCustomProcessors); + options?.enableConsoleExporters ?? + (!azureMonitorEnabled && !a365Exporting && !hasCustomProcessors); if (consoleEnabled) { spanProcessors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())); metricReaders.push( @@ -141,6 +221,20 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan processors: logRecordProcessors, }); + // ── Bind this instance's instrumentations to its own providers ── + // Each instrumentation is given THIS instance's providers, so the spans, + // metrics, and logs it produces flow to this instance's exporter only. + // Two instances with different `instrumentationOptions` therefore feed + // their respective exporters with different instrumentation sets. + if (instrumentations.length > 0) { + this.unloadInstrumentations = registerInstrumentations({ + instrumentations, + tracerProvider: this.tracerProvider, + meterProvider: this.meterProvider, + loggerProvider: this.loggerProvider, + }); + } + registerInstance(this.id, { tracerProvider: this.tracerProvider, meterProvider: this.meterProvider, @@ -177,6 +271,9 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan return this.shutdownPromise; } unregisterInstance(this.id); + // Detach this instance's instrumentations so they stop emitting once it is + // shut down. Other instances keep their own instrumentations registered. + this.unloadInstrumentations?.(); this.shutdownPromise = (async () => { // Wrap each disposer so a synchronous throw is captured and does not // abort the rest of shutdown. @@ -196,8 +293,9 @@ class MicrosoftOpenTelemetryInstanceImpl implements MicrosoftOpenTelemetryInstan * * Unlike {@link useMicrosoftOpenTelemetry} (single, global default instance), * this can be called multiple times in the same Node.js runtime to run - * independent, isolated pipelines side by side — for example two Azure Monitor - * resources with different connection strings. + * independent, isolated pipelines side by side — for example an Azure Monitor + * resource and an A365 exporter, each with its own set of OpenTelemetry + * instrumentations and settings. * * The first instance created becomes the default for global API access; pass a * truthy `makeDefault` to override. diff --git a/src/types.ts b/src/types.ts index 60ba109..66fd284 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,7 +59,11 @@ export interface MicrosoftOpenTelemetryOptions { * * Multiple instances can be created in the same Node.js runtime via * {@link createMicrosoftOpenTelemetryInstance}. Each instance owns its own - * exporter pipeline (resource, sampler, processors, readers). Telemetry created + * exporter pipeline (resource, sampler, processors, readers) **and its own set + * of OpenTelemetry instrumentations**, bound to that instance's providers. This + * lets a customer register different instrumentations and settings for the + * Azure Monitor exporter than for the A365 exporter in the same process — each + * instrumentation emits only to its instance's exporter. Telemetry created * through this handle — or within {@link MicrosoftOpenTelemetryInstance.runWithInstance} — * is routed only to this instance's pipeline. */ diff --git a/test/internal/functional/multiInstance.test.ts b/test/internal/functional/multiInstance.test.ts index e63a9a1..69fa4b6 100644 --- a/test/internal/functional/multiInstance.test.ts +++ b/test/internal/functional/multiInstance.test.ts @@ -4,8 +4,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as opentelemetry from "@opentelemetry/api"; import { logs } from "@opentelemetry/api-logs"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; import type { HttpClient, PipelineRequest } from "@azure/core-rest-pipeline"; +// Wrap registerInstrumentations so the test can observe how each instance binds +// its own instrumentation set to its own providers, while still performing the +// real registration. +vi.mock("@opentelemetry/instrumentation", async (importActual) => { + const actual = await importActual(); + return { ...actual, registerInstrumentations: vi.fn(actual.registerInstrumentations) }; +}); + import { createMicrosoftOpenTelemetryInstance, runWithMicrosoftOpenTelemetryInstance, @@ -48,12 +57,14 @@ function spanNames(envelopes: Envelope[]): string[] { function makeInstance( connectionString: string, httpClient: HttpClient, + instrumentationOptions?: Record, ): MicrosoftOpenTelemetryInstance { return createMicrosoftOpenTelemetryInstance({ // Use the deterministic ratio sampler (always-on) instead of the default // rate limiter so the test is reliable. tracesPerSecond: 0, samplingRatio: 1, + ...(instrumentationOptions ? { instrumentationOptions: instrumentationOptions as never } : {}), // Keep only the span pipeline active so the test is deterministic and offline. azureMonitor: { enableLiveMetrics: false, @@ -135,4 +146,49 @@ describe("Multiple SDK instances in one runtime", () => { expect(spanNames(ingestB)).toContain("global-into-b"); expect(spanNames(ingestB)).not.toContain("global-into-a"); }); + + it("binds a different instrumentation set per exporter", async () => { + const mockRegister = vi.mocked(registerInstrumentations); + mockRegister.mockClear(); + + const ingestA: Envelope[] = []; + const ingestB: Envelope[] = []; + + // Instance A enables the HTTP instrumentation; instance B disables it. This + // models the prioritized scenario: a customer registers different + // OpenTelemetry instrumentations for the Azure Monitor exporter than for the + // second (A365/OTLP) exporter, in the same runtime. + instanceA = makeInstance(CONNECTION_STRING_A, recordingHttpClient(ingestA), { + http: { enabled: true }, + }); + instanceB = makeInstance(CONNECTION_STRING_B, recordingHttpClient(ingestB), { + http: { enabled: false }, + }); + + // Each instance registered its own instrumentation set against its own + // providers. + const registrations = mockRegister.mock.calls.map((args) => ({ + names: (args[0].instrumentations ?? []) + .flat() + .map((i) => (i as { instrumentationName: string }).instrumentationName), + tracerProvider: args[0].tracerProvider, + })); + expect(registrations.length).toBe(2); + + const HTTP = "@opentelemetry/instrumentation-http"; + const withHttp = registrations.filter((r) => r.names.includes(HTTP)); + const withoutHttp = registrations.filter((r) => !r.names.includes(HTTP)); + + // Exactly one instance (A) bound the HTTP instrumentation; the other (B) + // did not — different instrumentation sets per exporter. + expect(withHttp.length).toBe(1); + expect(withoutHttp.length).toBe(1); + + // The two instances bound their instrumentations to distinct tracer + // providers, so each instrumentation's telemetry reaches only its own + // exporter. + expect(registrations[0].tracerProvider).toBeDefined(); + expect(registrations[1].tracerProvider).toBeDefined(); + expect(registrations[0].tracerProvider).not.toBe(registrations[1].tracerProvider); + }); }); From 661887d0b6777e29dd0387e1a47d97885b4266a7 Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:00:05 -0700 Subject: [PATCH 4/5] fix(multiInstance): route synchronous metric measurements by ambient instance DelegatingMeter previously resolved the instance at instrument creation time, pinning .add()/.record() to whichever instance was current then (usually the default). Synchronous instruments now re-resolve the current instance on every measurement so metrics follow runWithInstance like traces/logs. Observable instruments remain bound at creation (collected async, outside any scope). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../multiInstance/delegatingProviders.ts | 80 +++++++++++++++++-- .../delegatingMeterRouting.test.ts | 75 +++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 test/internal/unit/multiInstance/delegatingMeterRouting.test.ts diff --git a/src/distro/multiInstance/delegatingProviders.ts b/src/distro/multiInstance/delegatingProviders.ts index 32938e6..de39d44 100644 --- a/src/distro/multiInstance/delegatingProviders.ts +++ b/src/distro/multiInstance/delegatingProviders.ts @@ -83,7 +83,65 @@ export class ParentTracerProvider implements TracerProvider { } } -/** A Meter that resolves the current instance's meter on every instrument call. */ +/** + * Base for synchronous instruments (counter / up-down-counter / histogram / + * gauge). It re-resolves the current instance's `Meter` on every measurement so + * `.add()` / `.record()` route by the ambient context at call time — not by the + * instance that happened to be current when the instrument was created. The + * concrete instrument is created once per resolved `Meter` and cached. + */ +class DelegatingSyncInstrument { + private readonly perMeter = new WeakMap(); + constructor( + private readonly resolveMeter: () => Meter, + private readonly create: (meter: Meter) => T, + ) {} + + protected target(): T { + const meter = this.resolveMeter(); + let instrument = this.perMeter.get(meter); + if (!instrument) { + instrument = this.create(meter); + this.perMeter.set(meter, instrument); + } + return instrument; + } +} + +class DelegatingCounter extends DelegatingSyncInstrument implements Counter { + add(...args: Parameters): void { + this.target().add(...args); + } +} +class DelegatingUpDownCounter + extends DelegatingSyncInstrument + implements UpDownCounter +{ + add(...args: Parameters): void { + this.target().add(...args); + } +} +class DelegatingHistogram extends DelegatingSyncInstrument implements Histogram { + record(...args: Parameters): void { + this.target().record(...args); + } +} +class DelegatingGauge extends DelegatingSyncInstrument implements Gauge { + record(...args: Parameters): void { + this.target().record(...args); + } +} + +/** + * A Meter that routes measurements to the current instance. + * + * Synchronous instruments re-resolve the instance on every `.add()`/`.record()` + * so they follow the ambient context. Observable instruments and batch + * callbacks are collected asynchronously by a provider's reader, outside any + * `runWithInstance` scope, so they cannot be ambient-routed — they bind to the + * instance current at creation/registration time (the default when none is + * active). + */ class DelegatingMeter implements Meter { constructor( private readonly name: string, @@ -98,16 +156,28 @@ class DelegatingMeter implements Meter { } createGauge(name: string, options?: MetricOptions): Gauge { - return this.delegate().createGauge(name, options); + return new DelegatingGauge( + () => this.delegate(), + (meter) => meter.createGauge(name, options), + ); } createHistogram(name: string, options?: MetricOptions): Histogram { - return this.delegate().createHistogram(name, options); + return new DelegatingHistogram( + () => this.delegate(), + (meter) => meter.createHistogram(name, options), + ); } createCounter(name: string, options?: MetricOptions): Counter { - return this.delegate().createCounter(name, options); + return new DelegatingCounter( + () => this.delegate(), + (meter) => meter.createCounter(name, options), + ); } createUpDownCounter(name: string, options?: MetricOptions): UpDownCounter { - return this.delegate().createUpDownCounter(name, options); + return new DelegatingUpDownCounter( + () => this.delegate(), + (meter) => meter.createUpDownCounter(name, options), + ); } createObservableGauge(name: string, options?: MetricOptions): ObservableGauge { return this.delegate().createObservableGauge(name, options); diff --git a/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts new file mode 100644 index 0000000..93d37f8 --- /dev/null +++ b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import type { Counter, Meter, MeterProvider } from "@opentelemetry/api"; +import { context } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; + +import { ParentMeterProvider } from "../../../../src/distro/multiInstance/delegatingProviders.js"; +import type { InstanceProviders } from "../../../../src/distro/multiInstance/instanceRegistry.js"; +import { + _resetRegistry, + registerInstance, + withInstance, +} from "../../../../src/distro/multiInstance/instanceRegistry.js"; + +interface Add { + instance: string; + value: number; +} + +/** + * A fake MeterProvider whose counters record every `.add()` into `sink`, tagged + * with the owning instance, so a test can see which instance a measurement + * routed to. + */ +function recordingProviders(instance: string, sink: Add[]): InstanceProviders { + const counter: Counter = { + add: (value: number) => sink.push({ instance, value }), + } as Counter; + const meter: Meter = { createCounter: () => counter } as unknown as Meter; + const meterProvider: MeterProvider = { getMeter: () => meter } as MeterProvider; + return { + tracerProvider: {} as InstanceProviders["tracerProvider"], + meterProvider, + loggerProvider: {} as InstanceProviders["loggerProvider"], + }; +} + +describe("DelegatingMeter metric routing", () => { + beforeAll(() => { + // withInstance relies on the async context, so a real context manager must + // be active for the ambient instance id to propagate. + const cm = new AsyncLocalStorageContextManager(); + cm.enable(); + context.setGlobalContextManager(cm); + }); + + afterEach(() => { + _resetRegistry(); + }); + + it("routes each .add() to the ambient instance, not the one current at creation", () => { + const sink: Add[] = []; + registerInstance("a", recordingProviders("a", sink)); // first → default + registerInstance("b", recordingProviders("b", sink)); + + // Create the counter once with no ambient instance: it resolves to the + // default (a) at creation time — mirroring how instrumentations create + // instruments once at init. + const counter = new ParentMeterProvider().getMeter("test").createCounter("requests"); + + // Later measurements must follow the ambient context, not the creation-time + // instance. + withInstance("b", () => counter.add(1)); + withInstance("a", () => counter.add(2)); + counter.add(3); // no ambient → default (a) + + expect(sink).toEqual([ + { instance: "b", value: 1 }, + { instance: "a", value: 2 }, + { instance: "a", value: 3 }, + ]); + }); +}); From 7b930364813e37b22250194ccd79b1f7fb73db8f Mon Sep 17 00:00:00 2001 From: Jackson Weber <47067795+JacksonWeber@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:56:49 -0700 Subject: [PATCH 5/5] test(multiInstance): restore global context state after metric routing test Add afterAll cleanup (cm.disable() + context.disable()) so the AsyncLocalStorageContextManager installed in beforeAll does not leak into other tests in the shared Vitest worker. Also document the multi-instance public API in the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 59 +++++++++++++++++++ .../delegatingMeterRouting.test.ts | 13 +++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0100056..0459bcd 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,65 @@ useMicrosoftOpenTelemetry(); That's it — traces, metrics, and logs are collected automatically with built-in instrumentations for HTTP, databases, and more. +### Multiple instances + +`useMicrosoftOpenTelemetry()` configures a single, global SDK. If you need several **isolated** SDKs in one process — for example one exporting to Azure Monitor and another to A365, each with its own instrumentations and sampling — use `createMicrosoftOpenTelemetryInstance()` instead. It accepts the same `MicrosoftOpenTelemetryOptions` and returns a handle: + +```typescript +import { createMicrosoftOpenTelemetryInstance } from "@microsoft/opentelemetry"; + +// Instance A -> Azure Monitor, HTTP instrumentation on +const azmon = createMicrosoftOpenTelemetryInstance({ + azureMonitor: { + azureMonitorExporterOptions: { + connectionString: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING, + }, + }, + instrumentationOptions: { http: { enabled: true } }, + samplingRatio: 1.0, +}); + +// Instance B -> A365, HTTP instrumentation off, different sampling +const a365 = createMicrosoftOpenTelemetryInstance({ + a365: { enabled: true, tokenResolver: (agentId, tenantId) => getToken(agentId, tenantId) }, + instrumentationOptions: { http: { enabled: false } }, + samplingRatio: 0.25, +}); +``` + +Each instance owns its own exporter, instrumentation set, and sampler; telemetry from one never reaches another. + +#### Routing telemetry to an instance + +You do **not** manage OpenTelemetry context yourself. There are two automatic paths and one opt-in path: + +1. **Use the handle** — `instance.getTracer(name)`, `instance.getMeter(name)`, and `instance.getLogger(name)` are bound to that instance's pipeline. No context work. +2. **Instrumentations auto-route** — instrumentations you enable on an instance are bound to it at creation, so their telemetry always flows to that instance automatically. +3. **`runWithInstance(fn)`** — only needed when code uses the *global* OpenTelemetry API (e.g. `trace.getTracer(...)` from `@opentelemetry/api`), such as a shared library or a globally-registered third-party instrumentation. Wrap it to choose the target instance: + +```typescript +import { trace } from "@opentelemetry/api"; + +// Global-API code has no handle; steer it to a chosen instance. +azmon.runWithInstance(() => trace.getTracer("shared-lib").startSpan("work").end()); +``` + +The first instance created is the default target for global-API code that isn't wrapped; pass `{ makeDefault: true }` as the second argument to override. The standalone `runWithMicrosoftOpenTelemetryInstance(instance.id, fn)` function is equivalent to the `runWithInstance` method. + +> **Metrics note:** synchronous instruments (counter, histogram, etc.) route per measurement, so `.add()`/`.record()` follow the ambient instance. Observable instruments are collected asynchronously outside any `runWithInstance` scope, so they bind to the instance current when the observable was created — create them via that instance's `getMeter(...)` or inside `runWithInstance`. + +#### Instance handle + +| Member | Description | +| ----------------------------- | ----------------------------------------------------------------------------- | +| `id` | Stable identifier for this instance. | +| `getTracer(name, version?)` | Tracer bound to this instance's pipeline. | +| `getMeter(name, version?)` | Meter bound to this instance's pipeline. | +| `getLogger(name, version?)` | Logger bound to this instance's pipeline. | +| `runWithInstance(fn)` | Run `fn` with this instance bound as the ambient target for the global API. | +| `forceFlush()` | Flush this instance's pipeline. | +| `shutdown()` | Shut down and detach only this instance, leaving other instances active. | + ## Configuration ### `MicrosoftOpenTelemetryOptions` diff --git a/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts index 93d37f8..5fb9ac5 100644 --- a/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts +++ b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import type { Counter, Meter, MeterProvider } from "@opentelemetry/api"; import { context } from "@opentelemetry/api"; import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; @@ -38,10 +38,12 @@ function recordingProviders(instance: string, sink: Add[]): InstanceProviders { } describe("DelegatingMeter metric routing", () => { + let cm: AsyncLocalStorageContextManager; + beforeAll(() => { // withInstance relies on the async context, so a real context manager must // be active for the ambient instance id to propagate. - const cm = new AsyncLocalStorageContextManager(); + cm = new AsyncLocalStorageContextManager(); cm.enable(); context.setGlobalContextManager(cm); }); @@ -50,6 +52,13 @@ describe("DelegatingMeter metric routing", () => { _resetRegistry(); }); + afterAll(() => { + // Restore global context state so it does not leak into other tests sharing + // this Vitest worker. + cm.disable(); + context.disable(); + }); + it("routes each .add() to the ambient instance, not the one current at creation", () => { const sink: Add[] = []; registerInstance("a", recordingProviders("a", sink)); // first → default