diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae1583..1639911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Bump `@azure/monitor-opentelemetry-exporter` floor to `1.0.0-beta.43` - Bump `@opentelemetry/*` core/SDK packages to `2.8.0` / `0.219.0` +### 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/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/package-lock.json b/package-lock.json index 43f6896..170e9a2 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.219.0", + "@opentelemetry/context-async-hooks": "^2.8.0", "@opentelemetry/core": "^2.8.0", "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", diff --git a/package.json b/package.json index 3717858..15730d7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@microsoft/applicationinsights-web-snippet": "^1.2.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.219.0", + "@opentelemetry/context-async-hooks": "^2.8.0", "@opentelemetry/core": "^2.8.0", "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", diff --git a/src/distro/distro.ts b/src/distro/distro.ts index d331397..f96e04b 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/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/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/delegatingProviders.ts b/src/distro/multiInstance/delegatingProviders.ts new file mode 100644 index 0000000..de39d44 --- /dev/null +++ b/src/distro/multiInstance/delegatingProviders.ts @@ -0,0 +1,238 @@ +// 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 { createNoopLogger } 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(); +const NOOP_LOGGER = createNoopLogger(); + +/** + * 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); + } +} + +/** + * 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, + 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 new DelegatingGauge( + () => this.delegate(), + (meter) => meter.createGauge(name, options), + ); + } + createHistogram(name: string, options?: MetricOptions): Histogram { + return new DelegatingHistogram( + () => this.delegate(), + (meter) => meter.createHistogram(name, options), + ); + } + createCounter(name: string, options?: MetricOptions): Counter { + return new DelegatingCounter( + () => this.delegate(), + (meter) => meter.createCounter(name, options), + ); + } + createUpDownCounter(name: string, options?: MetricOptions): UpDownCounter { + return new DelegatingUpDownCounter( + () => this.delegate(), + (meter) => meter.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..c459036 --- /dev/null +++ b/src/distro/multiInstance/globalSetup.ts @@ -0,0 +1,69 @@ +// 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"; +import { patchOpenTelemetryInstrumentationEnable } from "../../utils/opentelemetryInstrumentationPatcher.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]; + + // 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); + + 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..cf4866a --- /dev/null +++ b/src/distro/multiInstance/instance.ts @@ -0,0 +1,314 @@ +// 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, + BatchSpanProcessor, + 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 { registerInstrumentations } from "@opentelemetry/instrumentation"; + +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 { A365Configuration, Agent365Exporter, A365SpanProcessor } from "../../a365/index.js"; +import { configureA365Logger } from "../../a365/logging.js"; +import type { MicrosoftOpenTelemetryInstance, MicrosoftOpenTelemetryOptions } from "../../types.js"; +import { + _applyA365InstrumentationDefaults, + createInstrumentations, + 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 + + * 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; + private readonly tracerProvider: NodeTracerProvider; + 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) { + this.id = id; + const config = new InternalConfig(options); + + const azureMonitorRequested = + options?.azureMonitor?.enabled !== false && + (!!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) ────────────────── + 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 + A365) ── + const spanProcessors: SpanProcessor[] = [ + ...(traceHandler ? [traceHandler.getAzureMonitorSpanProcessor()] : []), + ...(options?.spanProcessors ?? []), + ]; + + // 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 ?? []), + ...(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 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 && !a365Exporting && !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, + }); + + // ── 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, + 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); + // 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. + await Promise.allSettled(this.disposers.map((d) => Promise.resolve().then(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 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. + */ +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..ec6cf6f --- /dev/null +++ b/src/distro/multiInstance/instanceRegistry.ts @@ -0,0 +1,94 @@ +// 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`. + * + * 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); +} + +/** 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 c367b6d..f3b4cb4 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,40 @@ 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) **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. + */ +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..69fa4b6 --- /dev/null +++ b/test/internal/functional/multiInstance.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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, +} 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, + 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, + 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(); + // 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 () => { + 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"); + }); + + 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); + }); +}); diff --git a/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts new file mode 100644 index 0000000..5fb9ac5 --- /dev/null +++ b/test/internal/unit/multiInstance/delegatingMeterRouting.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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"; + +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", () => { + 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. + cm = new AsyncLocalStorageContextManager(); + cm.enable(); + context.setGlobalContextManager(cm); + }); + + afterEach(() => { + _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 + 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 }, + ]); + }); +});