From d5dd4a748fff261b9fbbbc767f618b85f1666473 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Wed, 17 Jun 2026 14:33:49 -0700 Subject: [PATCH 1/2] 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/2] 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 () => {