diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 7f01f8dc211..86b959f769e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to this project will be documented in this file. ### :boom: Breaking Change +* feat(api) Add delegating no-op meter provider [#4858](https://github.com/open-telemetry/opentelemetry-js/pull/4858) @hectorhdzg + * Proxy meters now upgrade previously created instruments and batch callbacks once an SDK registers, mirroring the behavior of the tracing and logging APIs. + ### :rocket: (Enhancement) * feat(api): improve isValidSpanId, isValidTraceId performance [#5714](https://github.com/open-telemetry/opentelemetry-js/pull/5714) @seemk diff --git a/api/src/api/metrics.ts b/api/src/api/metrics.ts index 186e7cce4b2..1e2f350d2bb 100644 --- a/api/src/api/metrics.ts +++ b/api/src/api/metrics.ts @@ -16,13 +16,13 @@ import { Meter, MeterOptions } from '../metrics/Meter'; import { MeterProvider } from '../metrics/MeterProvider'; -import { NOOP_METER_PROVIDER } from '../metrics/NoopMeterProvider'; import { getGlobal, registerGlobal, unregisterGlobal, } from '../internal/global-utils'; import { DiagAPI } from './diag'; +import { ProxyMeterProvider } from '../metrics/ProxyMeterProvider'; const API_NAME = 'metrics'; @@ -32,6 +32,8 @@ const API_NAME = 'metrics'; export class MetricsAPI { private static _instance?: MetricsAPI; + private _proxyMeterProvider = new ProxyMeterProvider(); + /** Empty private constructor prevents end users from constructing a new instance of the API */ private constructor() {} @@ -49,14 +51,22 @@ export class MetricsAPI { * Returns true if the meter provider was successfully registered, else false. */ public setGlobalMeterProvider(provider: MeterProvider): boolean { - return registerGlobal(API_NAME, provider, DiagAPI.instance()); + const success = registerGlobal( + API_NAME, + this._proxyMeterProvider, + DiagAPI.instance() + ); + if (success) { + this._proxyMeterProvider.setDelegate(provider); + } + return success; } /** * Returns the global meter provider. */ public getMeterProvider(): MeterProvider { - return getGlobal(API_NAME) || NOOP_METER_PROVIDER; + return getGlobal(API_NAME) || this._proxyMeterProvider; } /** @@ -73,5 +83,6 @@ export class MetricsAPI { /** Remove the global meter provider */ public disable(): void { unregisterGlobal(API_NAME, DiagAPI.instance()); + this._proxyMeterProvider = new ProxyMeterProvider(); } } diff --git a/api/src/index.ts b/api/src/index.ts index 0b4de61811d..c8ac1f8dda6 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -45,6 +45,7 @@ export type { export type { DiagAPI } from './api/diag'; // Metrics APIs +export { ProxyMeterProvider } from './metrics/ProxyMeterProvider'; export { createNoopMeter } from './metrics/NoopMeter'; export type { MeterOptions, Meter } from './metrics/Meter'; export type { MeterProvider } from './metrics/MeterProvider'; diff --git a/api/src/metrics/ProxyMeter.ts b/api/src/metrics/ProxyMeter.ts new file mode 100644 index 00000000000..9d60f96f2fc --- /dev/null +++ b/api/src/metrics/ProxyMeter.ts @@ -0,0 +1,459 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Meter, MeterOptions } from './Meter'; +import { + NOOP_COUNTER_METRIC, + NOOP_GAUGE_METRIC, + NOOP_HISTOGRAM_METRIC, + NOOP_METER, + NOOP_OBSERVABLE_COUNTER_METRIC, + NOOP_OBSERVABLE_GAUGE_METRIC, + NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC, + NOOP_UP_DOWN_COUNTER_METRIC, +} from './NoopMeter'; +import { + BatchObservableCallback, + Counter, + Gauge, + Histogram, + MetricOptions, + Observable, + ObservableCallback, + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +} from './Metric'; + +const INTERNAL_NOOP_METER = NOOP_METER; + +/** + * Proxy meter provided by the proxy meter provider + */ +export class ProxyMeter implements Meter { + private _delegate?: Meter; + private readonly _instruments = new Set>(); + private readonly _batchCallbacks = new Map< + BatchObservableCallback, + Observable[] + >(); + + constructor( + private readonly _provider: MeterDelegator, + private readonly _name: string, + private readonly _version?: string, + private readonly _options?: MeterOptions + ) {} + + /** + * @see {@link Meter.createGauge} + */ + createGauge(name: string, options?: MetricOptions): Gauge { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createGauge(name, options); + } + + const instrument = new ProxyGauge(() => + this._delegate?.createGauge(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createHistogram} + */ + createHistogram(name: string, options?: MetricOptions): Histogram { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createHistogram(name, options); + } + + const instrument = new ProxyHistogram(() => + this._delegate?.createHistogram(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createCounter} + */ + createCounter(name: string, options?: MetricOptions): Counter { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createCounter(name, options); + } + + const instrument = new ProxyCounter(() => + this._delegate?.createCounter(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createUpDownCounter} + */ + createUpDownCounter(name: string, options?: MetricOptions): UpDownCounter { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createUpDownCounter(name, options); + } + + const instrument = new ProxyUpDownCounter(() => + this._delegate?.createUpDownCounter(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createObservableGauge} + */ + createObservableGauge( + name: string, + options?: MetricOptions + ): ObservableGauge { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createObservableGauge(name, options); + } + + const instrument = new ProxyObservableGauge(() => + this._delegate?.createObservableGauge(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createObservableCounter} + */ + createObservableCounter( + name: string, + options?: MetricOptions + ): ObservableCounter { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createObservableCounter(name, options); + } + + const instrument = new ProxyObservableCounter(() => + this._delegate?.createObservableCounter(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.createObservableUpDownCounter} + */ + createObservableUpDownCounter( + name: string, + options?: MetricOptions + ): ObservableUpDownCounter { + const delegate = this._getDelegateOrUndefined(); + if (delegate) { + return delegate.createObservableUpDownCounter(name, options); + } + + const instrument = new ProxyObservableUpDownCounter(() => + this._delegate?.createObservableUpDownCounter(name, options) + ); + this._trackInstrument(instrument); + return instrument; + } + + /** + * @see {@link Meter.addBatchObservableCallback} + */ + addBatchObservableCallback( + callback: BatchObservableCallback, + observables: Observable[] + ): void { + const delegate = this._getDelegateOrUndefined(); + if (!delegate) { + this._batchCallbacks.set(callback, observables); + return; + } + + delegate.addBatchObservableCallback( + callback, + this._mapObservablesToDelegates(observables) + ); + } + + /** + * @see {@link Meter.removeBatchObservableCallback} + */ + removeBatchObservableCallback( + callback: BatchObservableCallback, + observables: Observable[] + ): void { + this._batchCallbacks.delete(callback); + this._getMeter().removeBatchObservableCallback(callback, observables); + } + + /** + * Ensure this proxy binds to the delegate meter if available. + */ + _bindDelegate(): void { + this._getDelegateOrUndefined(); + } + + private _trackInstrument(instrument: ProxyInstrumentBase) { + if (instrument.hasDelegate()) { + return; + } + this._instruments.add(instrument); + } + + private _mapObservablesToDelegates(observables: Observable[]): Observable[] { + return observables.map(observable => { + if (observable instanceof ProxyInstrumentBase) { + observable.bindDelegate(); + return (observable.getDelegateIfBound() ?? observable) as Observable; + } + + return observable; + }); + } + + private _flushPendingState() { + this._bindPendingInstruments(); + this._flushBatchObservableCallbacks(); + } + + private _bindPendingInstruments() { + if (!this._delegate) { + return; + } + + for (const instrument of this._instruments) { + instrument.bindDelegate(); + if (instrument.hasDelegate()) { + this._instruments.delete(instrument); + } + } + } + + private _flushBatchObservableCallbacks() { + if (!this._delegate || this._batchCallbacks.size === 0) { + return; + } + + for (const [callback, observables] of this._batchCallbacks) { + this._delegate.addBatchObservableCallback( + callback, + this._mapObservablesToDelegates(observables) + ); + } + this._batchCallbacks.clear(); + } + + /** + * Try to get a meter from the proxy meter provider. + * If the proxy meter provider has no delegate, return a noop meter. + */ + private _getMeter() { + return this._getDelegateOrUndefined() ?? INTERNAL_NOOP_METER; + } + + private _getDelegateOrUndefined(): Meter | undefined { + if (this._delegate) { + return this._delegate; + } + + const meter = this._provider.getDelegateMeter( + this._name, + this._version, + this._options + ); + + if (!meter) { + return undefined; + } + + this._delegate = meter; + this._provider._onProxyMeterDelegateBound(this); + this._flushPendingState(); + return this._delegate; + } +} + +abstract class ProxyInstrumentBase { + private _delegate?: T; + + constructor( + private readonly _delegateFactory: () => T | undefined, + private readonly _fallback: T + ) {} + + hasDelegate(): boolean { + return this._delegate != null; + } + + bindDelegate(): void { + if (this._delegate) { + return; + } + + const delegate = this._delegateFactory(); + if (!delegate) { + return; + } + + this._delegate = delegate; + this._onDelegateAttached(delegate); + } + + getDelegateIfBound(): T | undefined { + return this._delegate; + } + + protected _getDelegate(): T { + if (this._delegate) { + return this._delegate; + } + + const delegate = this._delegateFactory(); + if (!delegate) { + return this._fallback; + } + + this._delegate = delegate; + this._onDelegateAttached(delegate); + return delegate; + } + + protected abstract _onDelegateAttached(delegate: T): void; +} + +class ProxyCounter extends ProxyInstrumentBase implements Counter { + constructor(delegateFactory: () => Counter | undefined) { + super(delegateFactory, NOOP_COUNTER_METRIC); + } + + add(...args: Parameters) { + this._getDelegate().add(...args); + } + + protected _onDelegateAttached(): void {} +} + +class ProxyUpDownCounter + extends ProxyInstrumentBase + implements UpDownCounter +{ + constructor(delegateFactory: () => UpDownCounter | undefined) { + super(delegateFactory, NOOP_UP_DOWN_COUNTER_METRIC); + } + + add(...args: Parameters) { + this._getDelegate().add(...args); + } + + protected _onDelegateAttached(): void {} +} + +class ProxyGauge extends ProxyInstrumentBase implements Gauge { + constructor(delegateFactory: () => Gauge | undefined) { + super(delegateFactory, NOOP_GAUGE_METRIC); + } + + record(...args: Parameters) { + this._getDelegate().record(...args); + } + + protected _onDelegateAttached(): void {} +} + +class ProxyHistogram + extends ProxyInstrumentBase + implements Histogram +{ + constructor(delegateFactory: () => Histogram | undefined) { + super(delegateFactory, NOOP_HISTOGRAM_METRIC); + } + + record(...args: Parameters) { + this._getDelegate().record(...args); + } + + protected _onDelegateAttached(): void {} +} + +abstract class ProxyObservableInstrument + extends ProxyInstrumentBase + implements Observable +{ + private readonly _callbacks = new Set(); + + addCallback(callback: ObservableCallback): void { + this._callbacks.add(callback); + this._getDelegate().addCallback(callback); + } + + removeCallback(callback: ObservableCallback): void { + this._callbacks.delete(callback); + this._getDelegate().removeCallback(callback); + } + + protected _onDelegateAttached(delegate: T): void { + for (const callback of this._callbacks) { + delegate.addCallback(callback); + } + } +} + +class ProxyObservableGauge + extends ProxyObservableInstrument + implements ObservableGauge +{ + constructor(delegateFactory: () => ObservableGauge | undefined) { + super(delegateFactory, NOOP_OBSERVABLE_GAUGE_METRIC); + } +} + +class ProxyObservableCounter + extends ProxyObservableInstrument + implements ObservableCounter +{ + constructor(delegateFactory: () => ObservableCounter | undefined) { + super(delegateFactory, NOOP_OBSERVABLE_COUNTER_METRIC); + } +} + +class ProxyObservableUpDownCounter + extends ProxyObservableInstrument + implements ObservableUpDownCounter +{ + constructor(delegateFactory: () => ObservableUpDownCounter | undefined) { + super(delegateFactory, NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC); + } +} + +interface MeterDelegator { + getDelegateMeter( + name: string, + version?: string, + options?: MeterOptions + ): Meter | undefined; + _onProxyMeterDelegateBound(meter: ProxyMeter): void; +} diff --git a/api/src/metrics/ProxyMeterProvider.ts b/api/src/metrics/ProxyMeterProvider.ts new file mode 100644 index 00000000000..24b8a511400 --- /dev/null +++ b/api/src/metrics/ProxyMeterProvider.ts @@ -0,0 +1,77 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MeterProvider } from './MeterProvider'; +import { ProxyMeter } from './ProxyMeter'; +import { NoopMeterProvider } from './NoopMeterProvider'; +import { Meter, MeterOptions } from './Meter'; + +const NOOP_METER_PROVIDER = new NoopMeterProvider(); + +/** + * Meter provider which provides {@link ProxyMeter}s. + * + * Before a delegate is set, meters provided are NoOp. + * When a delegate is set, meters are provided from the delegate. + * When a delegate is set after meters have already been provided, + * all meters already provided will use the provided delegate implementation. + */ +export class ProxyMeterProvider implements MeterProvider { + private _delegate?: MeterProvider; + private readonly _proxyMeters = new Set(); + + /** + * Get a {@link ProxyMeter} + */ + getMeter(name: string, version?: string, options?: MeterOptions): Meter { + const delegate = this.getDelegateMeter(name, version, options); + if (delegate) { + return delegate; + } + + const meter = new ProxyMeter(this, name, version, options); + this._proxyMeters.add(meter); + return meter; + } + + getDelegate(): MeterProvider { + return this._delegate ?? NOOP_METER_PROVIDER; + } + + /** + * Set the delegate meter provider + */ + setDelegate(delegate: MeterProvider) { + this._delegate = delegate; + for (const meter of this._proxyMeters) { + meter._bindDelegate(); + } + this._proxyMeters.clear(); + } + + getDelegateMeter( + name: string, + version?: string, + options?: MeterOptions + ): Meter | undefined { + return this._delegate?.getMeter(name, version, options); + } + + /** @internal */ + _onProxyMeterDelegateBound(meter: ProxyMeter): void { + this._proxyMeters.delete(meter); + } +} diff --git a/api/test/common/proxy-implementations/proxy-meter.test.ts b/api/test/common/proxy-implementations/proxy-meter.test.ts new file mode 100644 index 00000000000..a50d74b1085 --- /dev/null +++ b/api/test/common/proxy-implementations/proxy-meter.test.ts @@ -0,0 +1,658 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { + ProxyMeterProvider, + Meter, + MeterProvider, + Histogram, + UpDownCounter, + ObservableGauge, + ObservableCounter, + Counter, + ObservableUpDownCounter, + Gauge, + Observable, +} from '../../../src'; +import { NoopMeterProvider } from '../../../src/metrics/NoopMeterProvider'; +import { ProxyMeter } from '../../../src/metrics/ProxyMeter'; +import { NoopMeter, NOOP_METER } from '../../../src/metrics/NoopMeter'; + +describe('ProxyMeter', () => { + let provider: ProxyMeterProvider; + const sandbox = sinon.createSandbox(); + + beforeEach(() => { + provider = new ProxyMeterProvider(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getDelegate', () => { + it('returns NoopMeterProvider when delegate is unset', () => { + const delegate = provider.getDelegate(); + assert.ok(delegate instanceof NoopMeterProvider); + }); + + it('returns the configured delegate when set', () => { + const noopDelegate = new NoopMeterProvider(); + provider.setDelegate(noopDelegate); + + const delegate = provider.getDelegate(); + assert.strictEqual(delegate, noopDelegate); + }); + }); + + describe('when no delegate is set', () => { + it('should return proxy meters', () => { + const meter = provider.getMeter('test'); + + assert.ok(meter instanceof ProxyMeter); + }); + + it('creates proxy instruments that act as no-ops before delegation', () => { + const meter = provider.getMeter('test'); + + const counter = meter.createCounter('counter'); + const histogram = meter.createHistogram('histogram'); + const observable = meter.createObservableGauge('gauge'); + + assert.doesNotThrow(() => counter.add(1)); + assert.doesNotThrow(() => histogram.record(1)); + assert.doesNotThrow(() => observable.addCallback(() => {})); + }); + + it('creates additional synchronous instruments that remain no-ops before delegation', () => { + const meter = provider.getMeter('test'); + + const gauge = meter.createGauge('gauge'); + const upDownCounter = meter.createUpDownCounter('upDown'); + + assert.doesNotThrow(() => gauge.record(5)); + assert.doesNotThrow(() => upDownCounter.add(-1)); + }); + + it('creates observable counters that buffer callbacks before delegation', () => { + const meter = provider.getMeter('test'); + + const observableCounter = + meter.createObservableCounter('observable-counter'); + const observableUpDownCounter = meter.createObservableUpDownCounter( + 'observable-up-down-counter' + ); + const counterCallback = sandbox.stub(); + const upDownCallback = sandbox.stub(); + + assert.doesNotThrow(() => observableCounter.addCallback(counterCallback)); + assert.doesNotThrow(() => + observableCounter.removeCallback(counterCallback) + ); + assert.doesNotThrow(() => + observableUpDownCounter.addCallback(upDownCallback) + ); + assert.doesNotThrow(() => + observableUpDownCounter.removeCallback(upDownCallback) + ); + }); + }); + + describe('when delegate is set before getMeter', () => { + let delegate: MeterProvider; + let getMeterStub: sinon.SinonStub; + + beforeEach(() => { + getMeterStub = sandbox.stub().returns(new NoopMeter()); + delegate = { + getMeter: getMeterStub, + }; + provider.setDelegate(delegate); + }); + + it('should return meters directly from the delegate', () => { + const meter = provider.getMeter('test', 'v0'); + + sandbox.assert.calledOnce(getMeterStub); + assert.strictEqual(getMeterStub.firstCall.returnValue, meter); + assert.deepStrictEqual(getMeterStub.firstCall.args, [ + 'test', + 'v0', + undefined, + ]); + }); + + it('should return meters directly from the delegate with schema url', () => { + const meter = provider.getMeter('test', 'v0', { + schemaUrl: 'https://opentelemetry.io/schemas/1.7.0', + }); + + sandbox.assert.calledOnce(getMeterStub); + assert.strictEqual(getMeterStub.firstCall.returnValue, meter); + assert.deepStrictEqual(getMeterStub.firstCall.args, [ + 'test', + 'v0', + { schemaUrl: 'https://opentelemetry.io/schemas/1.7.0' }, + ]); + }); + }); + + describe('when delegate is set after getMeter', () => { + let meter: Meter; + let delegate: MeterProvider; + let delegateMeter: Meter; + let delegateGauge: Gauge; + let delegateHistogram: Histogram; + let delegateCounter: Counter; + let delegateUpDownCounter: UpDownCounter; + let delegateObservableGauge: ObservableGauge; + let delegateObservableCounter: ObservableCounter; + let delegateObservableUpDownCounter: ObservableUpDownCounter; + let addBatchStub: sinon.SinonStub; + let removeBatchStub: sinon.SinonStub; + + beforeEach(() => { + delegateGauge = { record: sandbox.stub() }; + delegateHistogram = { record: sandbox.stub() }; + delegateCounter = { add: sandbox.stub() }; + delegateUpDownCounter = { add: sandbox.stub() }; + delegateObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + delegateObservableCounter = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + delegateObservableUpDownCounter = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + addBatchStub = sandbox.stub(); + removeBatchStub = sandbox.stub(); + + delegateMeter = { + createGauge: sandbox.stub().returns(delegateGauge), + createHistogram: sandbox.stub().returns(delegateHistogram), + createCounter: sandbox.stub().returns(delegateCounter), + createObservableCounter: sandbox + .stub() + .returns(delegateObservableCounter), + createObservableGauge: sandbox.stub().returns(delegateObservableGauge), + createObservableUpDownCounter: sandbox + .stub() + .returns(delegateObservableUpDownCounter), + createUpDownCounter: sandbox.stub().returns(delegateUpDownCounter), + addBatchObservableCallback: addBatchStub, + removeBatchObservableCallback: removeBatchStub, + }; + + meter = provider.getMeter('test'); + + delegate = { + getMeter() { + return delegateMeter; + }, + }; + provider.setDelegate(delegate); + }); + + it('should create gauges using the delegate meter', () => { + const instrument = meter.createGauge('test'); + assert.strictEqual(instrument, delegateGauge); + }); + + it('should create histograms using the delegate meter', () => { + const instrument = meter.createHistogram('test'); + assert.strictEqual(instrument, delegateHistogram); + }); + + it('should create counters using the delegate meter', () => { + const instrument = meter.createCounter('test'); + assert.strictEqual(instrument, delegateCounter); + }); + + it('should create observable counters using the delegate meter', () => { + const instrument = meter.createObservableCounter('test'); + assert.strictEqual(instrument, delegateObservableCounter); + }); + + it('should create observable gauges using the delegate meter', () => { + const instrument = meter.createObservableGauge('test'); + assert.strictEqual(instrument, delegateObservableGauge); + }); + + it('should create observable up down counters using the delegate meter', () => { + const instrument = meter.createObservableUpDownCounter('test'); + assert.strictEqual(instrument, delegateObservableUpDownCounter); + }); + + it('should create up down counters using the delegate meter', () => { + const instrument = meter.createUpDownCounter('test'); + assert.strictEqual(instrument, delegateUpDownCounter); + }); + + it('registers batch callbacks through the delegate once bound', () => { + const proxyObservable = meter.createObservableGauge('proxy'); + const foreignObservable: ObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const callback = sandbox.stub(); + + meter.addBatchObservableCallback(callback, [ + proxyObservable, + foreignObservable, + ]); + + sandbox.assert.calledOnce(addBatchStub); + const [, registeredObservables] = addBatchStub.firstCall.args; + assert.strictEqual(registeredObservables[0], delegateObservableGauge); + assert.strictEqual(registeredObservables[1], foreignObservable); + }); + }); + + describe('when instruments are created before delegate is set', () => { + it('hydrates synchronous instruments once the delegate registers', () => { + const meter = provider.getMeter('test'); + const counter = meter.createCounter('pre-counter'); + const addStub = sandbox.stub(); + const delegateCounter: Counter = { + add: addStub, + }; + const delegateMeter = new NoopMeter(); + sandbox.stub(delegateMeter, 'createCounter').returns(delegateCounter); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + counter.add(7); + sandbox.assert.calledOnceWithExactly(addStub, 7); + }); + + it('hydrates gauges that were created before delegation', () => { + const meter = provider.getMeter('test'); + const gauge = meter.createGauge('pre-gauge'); + const recordStub = sandbox.stub(); + const delegateGauge: Gauge = { + record: recordStub, + } as Gauge; + const delegateMeter = new NoopMeter(); + sandbox.stub(delegateMeter, 'createGauge').returns(delegateGauge); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + gauge.record(9); + sandbox.assert.calledOnceWithExactly(recordStub, 9); + }); + + it('hydrates histograms that were created before delegation', () => { + const meter = provider.getMeter('test'); + const histogram = meter.createHistogram('pre-histogram'); + const recordStub = sandbox.stub(); + const delegateHistogram: Histogram = { + record: recordStub, + } as Histogram; + const delegateMeter = new NoopMeter(); + sandbox.stub(delegateMeter, 'createHistogram').returns(delegateHistogram); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + histogram.record(33); + sandbox.assert.calledOnceWithExactly(recordStub, 33); + }); + + it('hydrates up down counters that were created before delegation', () => { + const meter = provider.getMeter('test'); + const upDownCounter = meter.createUpDownCounter('pre-updown'); + const addStub = sandbox.stub(); + const delegateUpDownCounter: UpDownCounter = { + add: addStub, + } as UpDownCounter; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createUpDownCounter') + .returns(delegateUpDownCounter); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + upDownCounter.add(-11); + sandbox.assert.calledOnceWithExactly(addStub, -11); + }); + + it('hydrates observable counters that were created before delegation', () => { + const meter = provider.getMeter('test'); + const observableCounter = meter.createObservableCounter( + 'pre-observable-counter' + ); + const callback = sandbox.stub(); + observableCounter.addCallback(callback); + + const delegateObservableCounter: ObservableCounter = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableCounter') + .returns(delegateObservableCounter); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + sandbox.assert.calledOnceWithExactly( + delegateObservableCounter.addCallback as sinon.SinonStub, + callback + ); + }); + + it('hydrates observable up down counters that were created before delegation', () => { + const meter = provider.getMeter('test'); + const observableUpDownCounter = meter.createObservableUpDownCounter( + 'pre-observable-updown' + ); + const callback = sandbox.stub(); + observableUpDownCounter.addCallback(callback); + + const delegateObservableUpDownCounter: ObservableUpDownCounter = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableUpDownCounter') + .returns(delegateObservableUpDownCounter); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + sandbox.assert.calledOnceWithExactly( + delegateObservableUpDownCounter.addCallback as sinon.SinonStub, + callback + ); + }); + + it('hydrates observable callbacks that were added before delegation', () => { + const meter = provider.getMeter('test'); + const observable = meter.createObservableGauge('observable'); + const callback = sandbox.stub(); + observable.addCallback(callback); + + const delegateObservable: ObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableGauge') + .returns(delegateObservable); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + sandbox.assert.calledOnceWithExactly( + delegateObservable.addCallback as sinon.SinonStub, + callback + ); + }); + + it('hydrates batch observable callbacks registered before delegation', () => { + const meter = provider.getMeter('test'); + const observable = meter.createObservableGauge('batch'); + const callback = sandbox.stub(); + meter.addBatchObservableCallback(callback, [observable]); + + const delegateObservable: ObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableGauge') + .returns(delegateObservable); + const addBatchStub = sandbox.stub( + delegateMeter, + 'addBatchObservableCallback' + ); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + sandbox.assert.calledOnce(addBatchStub); + const [, registeredObservables] = addBatchStub.firstCall.args; + assert.strictEqual(registeredObservables[0], delegateObservable); + }); + + it('remaps proxy observables when registering batch callbacks after delegation', () => { + const meter = provider.getMeter('test'); + const proxyObservable = meter.createObservableGauge('proxy-batch'); + const callback = sandbox.stub(); + const delegateObservable: ObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableGauge') + .returns(delegateObservable); + const addBatchStub = sandbox.stub( + delegateMeter, + 'addBatchObservableCallback' + ); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + meter.addBatchObservableCallback(callback, [proxyObservable]); + + sandbox.assert.calledOnce(addBatchStub); + const [, registeredObservables] = addBatchStub.firstCall.args; + assert.strictEqual(registeredObservables[0], delegateObservable); + }); + + it('removes batch callbacks via the current delegate before delegation', () => { + const meter = provider.getMeter('test'); + const observable = meter.createObservableGauge('batch'); + const callback = sandbox.stub(); + + meter.addBatchObservableCallback(callback, [observable]); + + const noopMeter = new NoopMeter(); + const getMeterStub = sandbox + .stub(meter as unknown as { _getMeter: () => Meter }, '_getMeter') + .returns(noopMeter); + sandbox.stub(noopMeter, 'removeBatchObservableCallback'); + + assert.doesNotThrow(() => + meter.removeBatchObservableCallback(callback, [observable]) + ); + sandbox.assert.calledOnce(getMeterStub); + sandbox.assert.calledOnceWithExactly( + noopMeter.removeBatchObservableCallback as sinon.SinonStub, + callback, + [observable] + ); + }); + + it('removes batch callbacks by delegating to the noop meter when unset', () => { + const meter = provider.getMeter('test'); + const observable = meter.createObservableGauge('noop-batch'); + const callback = sandbox.stub(); + meter.addBatchObservableCallback(callback, [observable]); + + const noopSpy = sandbox.spy(NOOP_METER, 'removeBatchObservableCallback'); + + meter.removeBatchObservableCallback(callback, [observable]); + + sandbox.assert.calledOnce(noopSpy); + }); + }); + + describe('proxy instrument internals', () => { + it('does not track instruments that already have delegates', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const privateMeter = meter as unknown as { + _trackInstrument: (instrument: unknown) => void; + _instruments: Set; + }; + privateMeter._instruments.clear(); + const instrument = { + hasDelegate: sandbox.stub().returns(true), + bindDelegate: sandbox.stub(), + }; + + privateMeter._trackInstrument(instrument); + + assert.strictEqual(privateMeter._instruments.size, 0); + }); + + it('leaves non-proxy observables untouched when mapping delegates', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const privateMeter = meter as unknown as { + _mapObservablesToDelegates: (observables: Observable[]) => Observable[]; + }; + const observable: Observable = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + + const [result] = privateMeter._mapObservablesToDelegates([observable]); + + assert.strictEqual(result, observable); + }); + + it('maps proxy observables to their delegates after binding', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const privateMeter = meter as unknown as { + _mapObservablesToDelegates: (observables: Observable[]) => Observable[]; + }; + const proxyObservable = meter.createObservableGauge('proxy'); + const delegateObservable: ObservableGauge = { + addCallback: sandbox.stub(), + removeCallback: sandbox.stub(), + }; + const delegateMeter = new NoopMeter(); + sandbox + .stub(delegateMeter, 'createObservableGauge') + .returns(delegateObservable); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + const [result] = privateMeter._mapObservablesToDelegates([ + proxyObservable as unknown as Observable, + ]); + + assert.strictEqual(result, delegateObservable); + }); + + it('does not bind pending instruments when no delegate is present', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const privateMeter = meter as unknown as { + _bindPendingInstruments: () => void; + _instruments: Set; + }; + const instrument = { + hasDelegate: sandbox.stub().returns(false), + bindDelegate: sandbox.stub(), + }; + privateMeter._instruments.add(instrument); + + privateMeter._bindPendingInstruments(); + + sandbox.assert.notCalled(instrument.bindDelegate); + assert.strictEqual(privateMeter._instruments.size, 1); + }); + + it('falls back to NOOP meter when no delegate is available', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const privateMeter = meter as unknown as { _getMeter: () => Meter }; + + const result = privateMeter._getMeter(); + + assert.strictEqual(result, NOOP_METER); + }); + + it('allows proxy instruments to attempt delegate binding before delegation', () => { + const meter = provider.getMeter('test'); + const counter = meter.createCounter('lazy'); + + assert.doesNotThrow(() => + (counter as unknown as { bindDelegate: () => void }).bindDelegate() + ); + }); + + it('hydrates instruments lazily when pending state flush is prevented', () => { + const meter = provider.getMeter('test') as ProxyMeter; + const counter = meter.createCounter('lazy'); + const delegateCounter: Counter = { + add: sandbox.stub(), + } as Counter; + const delegateMeter = new NoopMeter(); + sandbox.stub(delegateMeter, 'createCounter').returns(delegateCounter); + sandbox.stub( + meter as unknown as { _flushPendingState: () => void }, + '_flushPendingState' + ); + + provider.setDelegate({ + getMeter() { + return delegateMeter; + }, + }); + + counter.add(3); + + sandbox.assert.calledOnce(delegateMeter.createCounter as sinon.SinonStub); + sandbox.assert.calledOnce(delegateCounter.add as sinon.SinonStub); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts index 527b1cdedba..6bcc5b0ad05 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -113,11 +113,19 @@ function getValueInMillis(envName: string, defaultValue: number): number { */ function configureMetricProviderFromEnv(): IMetricReader[] { const metricReaders: IMetricReader[] = []; + const metricsExporterEnvDefined = Object.prototype.hasOwnProperty.call( + process.env, + 'OTEL_METRICS_EXPORTER' + ); const enabledExporters = Array.from( new Set(getStringListFromEnv('OTEL_METRICS_EXPORTER') ?? []) ); if (enabledExporters.length === 0) { + if (!metricsExporterEnvDefined) { + return metricReaders; + } + diag.debug('OTEL_METRICS_EXPORTER is empty. Using default otlp exporter.'); enabledExporters.push('otlp'); } diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index 3314f613a20..dff9bf83229 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -18,6 +18,7 @@ import { context, propagation, ProxyTracerProvider, + ProxyMeterProvider, trace, diag, DiagLogLevel, @@ -101,6 +102,7 @@ function assertDefaultPropagatorRegistered() { describe('Node SDK', () => { let delegate: any; + let metricsDelegate: any; let logsDelegate: any; beforeEach(() => { @@ -112,6 +114,9 @@ describe('Node SDK', () => { logs.disable(); delegate = (trace.getTracerProvider() as ProxyTracerProvider).getDelegate(); + metricsDelegate = ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate(); logsDelegate = ( logs.getLoggerProvider() as ProxyLoggerProvider )._getDelegate(); @@ -150,6 +155,12 @@ describe('Node SDK', () => { delegate, 'tracer provider should not have changed' ); + assert.strictEqual( + (metrics.getMeterProvider() as ProxyMeterProvider).getDelegate(), + metricsDelegate, + 'meter provider should not have changed' + ); + assert.ok(!(logs.getLoggerProvider() instanceof LoggerProvider)); assert.ok(!(metrics.getMeterProvider() instanceof MeterProvider)); assert.strictEqual( (logs.getLoggerProvider() as ProxyLoggerProvider)._getDelegate(), @@ -201,6 +212,13 @@ describe('Node SDK', () => { sdk.start(); + assert.ok( + !( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ) + ); assertDefaultContextManagerRegistered(); assertDefaultPropagatorRegistered(); @@ -218,6 +236,13 @@ describe('Node SDK', () => { sdk.start(); + assert.ok( + !( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ) + ); assertDefaultContextManagerRegistered(); assertDefaultPropagatorRegistered(); @@ -241,6 +266,13 @@ describe('Node SDK', () => { sdk.start(); + assert.ok( + !( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ) + ); assertDefaultContextManagerRegistered(); assertDefaultPropagatorRegistered(); @@ -298,7 +330,11 @@ describe('Node SDK', () => { 'tracer provider should not have changed' ); - assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + assert.ok( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ); await sdk.shutdown(); }); @@ -337,8 +373,9 @@ describe('Node SDK', () => { delegate, 'tracer provider should not have changed' ); - - const meterProvider = metrics.getMeterProvider() as MeterProvider; + const meterProvider = ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() as MeterProvider; assert.ok(meterProvider instanceof MeterProvider); // Verify that both metric readers are registered @@ -374,7 +411,11 @@ describe('Node SDK', () => { "The 'metricReader' option is deprecated. Please use 'metricReaders' instead." ); - assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + assert.ok( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ); await sdk.shutdown(); }); @@ -405,7 +446,11 @@ describe('Node SDK', () => { "The 'metricReader' option is deprecated. Please use 'metricReaders' instead." ); - assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + assert.ok( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ); await sdk.shutdown(); }); @@ -619,8 +664,8 @@ describe('Node SDK', () => { 'tracer provider should not have changed' ); - const meterProvider = metrics.getMeterProvider() as MeterProvider; - assert.ok(meterProvider); + const meterProvider = metrics.getMeterProvider() as ProxyMeterProvider; + assert.ok(meterProvider.getDelegate() instanceof MeterProvider); const meter = meterProvider.getMeter('NodeSDKViews', '1.0.0'); const counter = meter.createCounter('test_counter', { @@ -1017,7 +1062,13 @@ describe('Node SDK', () => { }); sdk.start(); - assert.ok(!(metrics.getMeterProvider() instanceof MeterProvider)); + assert.ok( + !( + ( + metrics.getMeterProvider() as ProxyMeterProvider + ).getDelegate() instanceof MeterProvider + ) + ); await sdk.shutdown(); }); @@ -1252,12 +1303,34 @@ describe('Node SDK', () => { await sdk.shutdown(); }); + it('should not configure metrics when OTEL_METRICS_EXPORTER is unset', async () => { + delete process.env.OTEL_METRICS_EXPORTER; + process.env.OTEL_TRACES_EXPORTER = 'none'; + + const sdk = new NodeSDK(); + sdk.start(); + + try { + const meterProvider = metrics.getMeterProvider() as ProxyMeterProvider; + assert.strictEqual((meterProvider as any)._delegate, undefined); + + const meter = metrics.getMeter('proxy-meter-test'); + const counter = meter.createCounter('proxy-counter-test'); + counter.add(1); + } finally { + await sdk.shutdown(); + delete process.env.OTEL_TRACES_EXPORTER; + } + }); + it('should use console with default interval and timeout', async () => { process.env.OTEL_METRICS_EXPORTER = 'console'; const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof ConsoleMetricExporter @@ -1279,7 +1352,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPGrpcMetricExporter @@ -1301,7 +1376,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPProtoMetricExporter @@ -1323,7 +1400,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPHttpMetricExporter @@ -1345,7 +1424,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPGrpcMetricExporter @@ -1367,7 +1448,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPProtoMetricExporter @@ -1389,7 +1472,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPProtoMetricExporter @@ -1413,7 +1498,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPProtoMetricExporter @@ -1435,7 +1522,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK(); sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader instanceof PrometheusMetricExporter @@ -1449,7 +1538,9 @@ describe('Node SDK', () => { sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok( sharedState.metricCollectors[0]._metricReader._exporter instanceof OTLPProtoMetricExporter @@ -1463,7 +1554,9 @@ describe('Node SDK', () => { sdk.start(); const meterProvider = metrics.getMeterProvider(); - const sharedState = (meterProvider as any)['_sharedState']; + const sharedState = ( + (meterProvider as ProxyMeterProvider).getDelegate() as any + )['_sharedState']; assert.ok(sharedState.metricCollectors.length === 2); assert.ok(