From 53d90a74863a926be6fa6b206c8c07b6c8b2c4fa Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 11 Dec 2025 15:38:41 +0900 Subject: [PATCH 01/17] feat: implement span start/end metrics --- package-lock.json | 1 + .../opentelemetry-sdk-trace-base/package.json | 1 + .../opentelemetry-sdk-trace-base/src/Span.ts | 3 ++ .../src/Tracer.ts | 11 +++++ .../opentelemetry-sdk-trace-base/src/types.ts | 9 +++- .../test/common/BasicTracerProvider.test.ts | 47 +++++++++++++++++++ .../tsconfig.esm.json | 3 ++ .../tsconfig.esnext.json | 3 ++ .../tsconfig.json | 3 ++ 9 files changed, 80 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 1fe20ee0d95..3271941ad79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25526,6 +25526,7 @@ }, "devDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.2.0", "@types/mocha": "10.0.10", "@types/node": "18.6.5", "@types/sinon": "17.0.4", diff --git a/packages/opentelemetry-sdk-trace-base/package.json b/packages/opentelemetry-sdk-trace-base/package.json index e7dd9757a5c..95076d4a4d7 100644 --- a/packages/opentelemetry-sdk-trace-base/package.json +++ b/packages/opentelemetry-sdk-trace-base/package.json @@ -64,6 +64,7 @@ }, "devDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.2.0", "@types/mocha": "10.0.10", "@types/node": "18.6.5", "@types/sinon": "17.0.4", diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index 294bf52fad5..f3df16672c9 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -73,6 +73,7 @@ interface SpanOptions { attributes?: Attributes; spanLimits: SpanLimits; spanProcessor: SpanProcessor; + recordEndMetrics: () => void; } /** @@ -105,6 +106,7 @@ export class SpanImpl implements Span { private readonly _spanProcessor: SpanProcessor; private readonly _spanLimits: SpanLimits; private readonly _attributeValueLengthLimit: number; + private readonly _record_end_metrics: () => void; private readonly _performanceStartTime: number; private readonly _performanceOffset: number; @@ -133,6 +135,7 @@ export class SpanImpl implements Span { this.startTime = this._getTime(opts.startTime ?? now); this.resource = opts.resource; this.instrumentationScope = opts.scope; + this._record_end_metrics = opts.recordEndMetrics; if (opts.attributes != null) { this.setAttributes(opts.attributes); diff --git a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts index ec587a1eed2..dc6c140f543 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts @@ -28,6 +28,7 @@ import { Sampler } from './Sampler'; import { IdGenerator } from './IdGenerator'; import { RandomIdGenerator } from './platform'; import { Resource } from '@opentelemetry/resources'; +import { TracerMetrics } from './TracerMetrics'; /** * This class represents a basic tracer. @@ -41,6 +42,7 @@ export class Tracer implements api.Tracer { private readonly _resource: Resource; private readonly _spanProcessor: SpanProcessor; + private readonly _tracerMetrics: TracerMetrics; /** * Constructs a new Tracer instance. @@ -58,6 +60,9 @@ export class Tracer implements api.Tracer { this._idGenerator = config.idGenerator || new RandomIdGenerator(); this._resource = resource; this._spanProcessor = spanProcessor; + this._tracerMetrics = new TracerMetrics( + localConfig.meterProvider || api.metrics.getMeterProvider() + ); this.instrumentationScope = instrumentationScope; } @@ -120,6 +125,11 @@ export class Tracer implements api.Tracer { links ); + const recordEndMetrics = this._tracerMetrics.startSpan( + parentSpanContext, + samplingResult.decision + ); + traceState = samplingResult.traceState ?? traceState; const traceFlags = @@ -154,6 +164,7 @@ export class Tracer implements api.Tracer { startTime: options.startTime, spanProcessor: this._spanProcessor, spanLimits: this._spanLimits, + recordEndMetrics, }); return span; } diff --git a/packages/opentelemetry-sdk-trace-base/src/types.ts b/packages/opentelemetry-sdk-trace-base/src/types.ts index 413a5caa1cc..29851bef745 100644 --- a/packages/opentelemetry-sdk-trace-base/src/types.ts +++ b/packages/opentelemetry-sdk-trace-base/src/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ContextManager, TextMapPropagator } from '@opentelemetry/api'; +import { + ContextManager, + MeterProvider, + TextMapPropagator, +} from '@opentelemetry/api'; import { Resource } from '@opentelemetry/resources'; import { IdGenerator } from './IdGenerator'; import { Sampler } from './Sampler'; @@ -54,6 +58,9 @@ export interface TracerConfig { * List of SpanProcessor for the tracer */ spanProcessors?: SpanProcessor[]; + + /** A meter provider to record trace SDK metrics to. */ + meterProvider?: MeterProvider; } /** diff --git a/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts b/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts index 6a3e86efe99..45bef543e0b 100644 --- a/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts +++ b/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts @@ -27,6 +27,12 @@ import { defaultResource, resourceFromAttributes, } from '@opentelemetry/resources'; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import { @@ -564,4 +570,45 @@ describe('BasicTracerProvider', () => { sinon.assert.calledOnce(shutdownStub); }); }); + + describe('TracerMetrics', () => { + const metricExporter = new InMemoryMetricExporter( + AggregationTemporality.CUMULATIVE + ); + const metricReader = new PeriodicExportingMetricReader({ + exporter: metricExporter, + }); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + + afterEach(() => { + metricExporter.reset(); + }); + + after(async () => { + await meterProvider.shutdown(); + }); + + it('should record metrics for sampled spans', async () => { + const tracerProvider = new BasicTracerProvider({ + meterProvider, + sampler: new AlwaysOnSampler(), + }); + const tracer = tracerProvider.getTracer('test'); + const span = tracer.startSpan('span'); + await meterProvider.forceFlush(); + const exportedMetrics = metricExporter.getMetrics(); + assert.strictEqual(exportedMetrics.length, 1); + const resourceMetrics = exportedMetrics[0]; + assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); + const scopeMetrics = resourceMetrics.scopeMetrics[0]; + assert.strictEqual(scopeMetrics.metrics.length, 2); + const spansStartedMetric = scopeMetrics.metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + assert.ok(spansStartedMetric); + span.end(); + }); + }); }); diff --git a/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json b/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json index 784ec6d5ab8..49564838ff3 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json @@ -20,6 +20,9 @@ }, { "path": "../opentelemetry-resources" + }, + { + "path": "../sdk-metrics" } ] } diff --git a/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json b/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json index 784ffcc8264..7ec48616963 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json @@ -20,6 +20,9 @@ }, { "path": "../opentelemetry-resources" + }, + { + "path": "../sdk-metrics" } ] } diff --git a/packages/opentelemetry-sdk-trace-base/tsconfig.json b/packages/opentelemetry-sdk-trace-base/tsconfig.json index 17e40e21a32..db9a9ecc960 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.json @@ -21,6 +21,9 @@ }, { "path": "../opentelemetry-resources" + }, + { + "path": "../sdk-metrics" } ] } From 8f7d498e1ceb589b8d77cd388253959412de50b8 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 11 Dec 2025 15:39:37 +0900 Subject: [PATCH 02/17] Style --- packages/opentelemetry-sdk-trace-base/src/Span.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index f3df16672c9..90dac5b8330 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -106,7 +106,7 @@ export class SpanImpl implements Span { private readonly _spanProcessor: SpanProcessor; private readonly _spanLimits: SpanLimits; private readonly _attributeValueLengthLimit: number; - private readonly _record_end_metrics: () => void; + private readonly _recordEndMetrics: () => void; private readonly _performanceStartTime: number; private readonly _performanceOffset: number; @@ -135,7 +135,7 @@ export class SpanImpl implements Span { this.startTime = this._getTime(opts.startTime ?? now); this.resource = opts.resource; this.instrumentationScope = opts.scope; - this._record_end_metrics = opts.recordEndMetrics; + this._recordEndMetrics = opts.recordEndMetrics; if (opts.attributes != null) { this.setAttributes(opts.attributes); From 3ef44da5d4b71d3eed99802a711e0b08c790e934 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 11 Dec 2025 15:53:56 +0900 Subject: [PATCH 03/17] Commit --- .../src/TracerMetrics.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts diff --git a/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts new file mode 100644 index 00000000000..b97178405c1 --- /dev/null +++ b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts @@ -0,0 +1,87 @@ +/* + * 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 { + Counter, + MeterProvider, + SpanContext, + UpDownCounter, +} from '@opentelemetry/api'; +import { SamplingDecision } from './Sampler'; + +export class TracerMetrics { + private readonly startedSpans: Counter; + private readonly liveSpans: UpDownCounter; + + constructor(meterProvider: MeterProvider) { + const meter = meterProvider.getMeter('@opentelemetry/sdk-trace'); + + this.startedSpans = meter.createCounter('otel.sdk.span.started', { + unit: '{span}', + description: 'The number of created spans.', + }); + + this.liveSpans = meter.createUpDownCounter('otel.sdk.span.live', { + unit: '{span}', + description: 'The number of currently live spans.', + }); + } + + startSpan( + parentSpanCtx: SpanContext | undefined, + samplingDecision: SamplingDecision + ): () => void { + const samplingDecisionStr = samplingDecisionToString(samplingDecision); + this.startedSpans.add(1, { + 'otel.span.parent.origin': parentOrigin(parentSpanCtx), + 'otel.span.sampling_result': samplingDecisionStr, + }); + + if (samplingDecision === SamplingDecision.NOT_RECORD) { + return () => {}; + } + + const liveSpanAttributes = { + 'otel.span.sampling_result': samplingDecisionStr, + }; + this.liveSpans.add(1, liveSpanAttributes); + return () => { + this.liveSpans.add(-1, liveSpanAttributes); + }; + } +} + +function parentOrigin(parentSpanContext: SpanContext | undefined): string { + if (!parentSpanContext) { + return 'none'; + } + if (parentSpanContext.isRemote) { + return 'remote'; + } + return 'local'; +} + +function samplingDecisionToString(decision: SamplingDecision): string { + switch (decision) { + case SamplingDecision.RECORD_AND_SAMPLED: + return 'RECORD_AND_SAMPLE'; + case SamplingDecision.RECORD: + return 'RECORD_ONLY'; + case SamplingDecision.NOT_RECORD: + return 'DROP'; + default: + return 'unknown'; + } +} From d5730887ea6339dc44742d3165061dee57e10817 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 11 Dec 2025 16:51:43 +0900 Subject: [PATCH 04/17] Commit --- packages/opentelemetry-sdk-trace-base/src/Span.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index 90dac5b8330..bc4bfb7cf27 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -295,6 +295,7 @@ export class SpanImpl implements Span { this._spanProcessor.onEnding(this); } + this._recordEndMetrics(); this._ended = true; this._spanProcessor.onEnd(this); } From 2af11e693fb53f7420aa5536b08ad1689d1a0865 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 11 Dec 2025 16:53:01 +0900 Subject: [PATCH 05/17] Compile --- packages/opentelemetry-sdk-trace-base/src/Span.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index bc4bfb7cf27..3046be6116d 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -73,7 +73,7 @@ interface SpanOptions { attributes?: Attributes; spanLimits: SpanLimits; spanProcessor: SpanProcessor; - recordEndMetrics: () => void; + recordEndMetrics?: () => void; } /** @@ -106,7 +106,7 @@ export class SpanImpl implements Span { private readonly _spanProcessor: SpanProcessor; private readonly _spanLimits: SpanLimits; private readonly _attributeValueLengthLimit: number; - private readonly _recordEndMetrics: () => void; + private readonly _recordEndMetrics?: () => void; private readonly _performanceStartTime: number; private readonly _performanceOffset: number; @@ -295,7 +295,7 @@ export class SpanImpl implements Span { this._spanProcessor.onEnding(this); } - this._recordEndMetrics(); + this._recordEndMetrics?.(); this._ended = true; this._spanProcessor.onEnd(this); } From 0fcaed860ec524db49c4701cc94be532b8496aa0 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 12 Dec 2025 13:48:02 +0900 Subject: [PATCH 06/17] Finish --- .../test/common/BasicTracerProvider.test.ts | 262 ++++++++++++++++-- .../test/common/util.ts | 11 + 2 files changed, 245 insertions(+), 28 deletions(-) diff --git a/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts b/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts index 45bef543e0b..e283047568c 100644 --- a/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts +++ b/packages/opentelemetry-sdk-trace-base/test/common/BasicTracerProvider.test.ts @@ -27,12 +27,7 @@ import { defaultResource, resourceFromAttributes, } from '@opentelemetry/resources'; -import { - AggregationTemporality, - InMemoryMetricExporter, - MeterProvider, - PeriodicExportingMetricReader, -} from '@opentelemetry/sdk-metrics'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import { @@ -47,6 +42,8 @@ import { import { SpanImpl } from '../../src/Span'; import { MultiSpanProcessor } from '../../src/MultiSpanProcessor'; import { Tracer } from '../../src/Tracer'; +import { TestRecordOnlySampler } from './export/TestRecordOnlySampler'; +import { TestMetricReader } from './util'; describe('BasicTracerProvider', () => { beforeEach(() => { @@ -572,43 +569,252 @@ describe('BasicTracerProvider', () => { }); describe('TracerMetrics', () => { - const metricExporter = new InMemoryMetricExporter( - AggregationTemporality.CUMULATIVE - ); - const metricReader = new PeriodicExportingMetricReader({ - exporter: metricExporter, + it('should record metrics for sampled spans', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const tracerProvider = new BasicTracerProvider({ + meterProvider, + sampler: new AlwaysOnSampler(), + }); + const tracer = tracerProvider.getTracer('test'); + const span = tracer.startSpan('span'); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + let spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'RECORD_AND_SAMPLE', + }); + assert.ok(spansLiveMetric); + assert.strictEqual(spansLiveMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansLiveMetric.dataPoints[0].attributes, { + 'otel.span.sampling_result': 'RECORD_AND_SAMPLE', + }); + span.end(); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'RECORD_AND_SAMPLE', + }); + assert.ok(spansLiveMetric); + assert.strictEqual(spansLiveMetric.dataPoints[0].value, 0); + assert.deepStrictEqual(spansLiveMetric.dataPoints[0].attributes, { + 'otel.span.sampling_result': 'RECORD_AND_SAMPLE', + }); }); - const meterProvider = new MeterProvider({ - readers: [metricReader], + + it('should record metrics for record-only spans', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const tracerProvider = new BasicTracerProvider({ + meterProvider, + sampler: new TestRecordOnlySampler(), + }); + const tracer = tracerProvider.getTracer('test'); + const span = tracer.startSpan('span'); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + let spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'RECORD_ONLY', + }); + assert.ok(spansLiveMetric); + assert.strictEqual(spansLiveMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansLiveMetric.dataPoints[0].attributes, { + 'otel.span.sampling_result': 'RECORD_ONLY', + }); + span.end(); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'RECORD_ONLY', + }); + assert.ok(spansLiveMetric); + assert.strictEqual(spansLiveMetric.dataPoints[0].value, 0); + assert.deepStrictEqual(spansLiveMetric.dataPoints[0].attributes, { + 'otel.span.sampling_result': 'RECORD_ONLY', + }); }); - afterEach(() => { - metricExporter.reset(); + it('should record metrics for dropped spans', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const tracerProvider = new BasicTracerProvider({ + meterProvider, + sampler: new AlwaysOffSampler(), + }); + const tracer = tracerProvider.getTracer('test'); + const span = tracer.startSpan('span'); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + let spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); + span.end(); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'none', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); }); - after(async () => { - await meterProvider.shutdown(); + it('should record metrics for dropped spans with remote parent', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const tracerProvider = new BasicTracerProvider({ + meterProvider, + sampler: new AlwaysOffSampler(), + }); + const tracer = tracerProvider.getTracer('test'); + const parentContext = trace.setSpanContext(context.active(), { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }); + const span = tracer.startSpan('span', undefined, parentContext); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + let spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'remote', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); + span.end(); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'remote', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); }); - it('should record metrics for sampled spans', async () => { + it('should record metrics for dropped spans with local parent', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const tracerProvider = new BasicTracerProvider({ meterProvider, - sampler: new AlwaysOnSampler(), + sampler: new AlwaysOffSampler(), }); const tracer = tracerProvider.getTracer('test'); - const span = tracer.startSpan('span'); - await meterProvider.forceFlush(); - const exportedMetrics = metricExporter.getMetrics(); - assert.strictEqual(exportedMetrics.length, 1); - const resourceMetrics = exportedMetrics[0]; - assert.strictEqual(resourceMetrics.scopeMetrics.length, 1); - const scopeMetrics = resourceMetrics.scopeMetrics[0]; - assert.strictEqual(scopeMetrics.metrics.length, 2); - const spansStartedMetric = scopeMetrics.metrics.find( + const parentContext = trace.setSpanContext(context.active(), { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + isRemote: false, + }); + const span = tracer.startSpan('span', undefined, parentContext); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let spansStartedMetric = metrics.find( metric => metric.descriptor.name === 'otel.sdk.span.started' ); + let spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'local', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); span.end(); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + spansStartedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.started' + ); + spansLiveMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.span.live' + ); + assert.ok(spansStartedMetric); + assert.strictEqual(spansStartedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(spansStartedMetric.dataPoints[0].attributes, { + 'otel.span.parent.origin': 'local', + 'otel.span.sampling_result': 'DROP', + }); + assert.strictEqual(spansLiveMetric, undefined); }); }); }); diff --git a/packages/opentelemetry-sdk-trace-base/test/common/util.ts b/packages/opentelemetry-sdk-trace-base/test/common/util.ts index ab34f2ab1be..60bc657c0cb 100644 --- a/packages/opentelemetry-sdk-trace-base/test/common/util.ts +++ b/packages/opentelemetry-sdk-trace-base/test/common/util.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { MetricReader } from '@opentelemetry/sdk-metrics'; + export const validAttributes = { string: 'string', number: 0, @@ -33,3 +35,12 @@ export const invalidAttributes = { }; export function assertAssignable(val: T): asserts val is T {} + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} From d21242465633c17ec6010df56bb76590dcf3edb2 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 12 Dec 2025 14:29:38 +0900 Subject: [PATCH 07/17] Tweak test --- e2e-tests/verify.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e-tests/verify.mjs b/e2e-tests/verify.mjs index 16bafb7d913..6291cd893df 100755 --- a/e2e-tests/verify.mjs +++ b/e2e-tests/verify.mjs @@ -33,7 +33,11 @@ for (const line of lines) { } if (parsed.resourceMetrics) { console.log('found metric'); - verifyMetric(parsed.resourceMetrics[0].scopeMetrics[0].metrics[0]); + verifyMetric( + parsed.resourceMetrics[0].scopeMetrics[0].metrics.find( + m => m.name === 'example_counter' + ) + ); verifiedMetric = true; } if (parsed.resourceLogs) { From f336a00f7315621a62a924a5eea44d3b34f1aa9e Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 12 Dec 2025 14:49:22 +0900 Subject: [PATCH 08/17] Tweak better --- e2e-tests/verify.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e-tests/verify.mjs b/e2e-tests/verify.mjs index 6291cd893df..62d78e7872d 100755 --- a/e2e-tests/verify.mjs +++ b/e2e-tests/verify.mjs @@ -32,13 +32,14 @@ for (const line of lines) { verifiedSpan = true; } if (parsed.resourceMetrics) { - console.log('found metric'); - verifyMetric( - parsed.resourceMetrics[0].scopeMetrics[0].metrics.find( - m => m.name === 'example_counter' - ) + const metric = parsed.resourceMetrics[0].scopeMetrics[0].metrics.find( + m => m.name === 'example_counter' ); - verifiedMetric = true; + if (metric) { + console.log('found metric'); + verifyMetric(metric); + verifiedMetric = true; + } } if (parsed.resourceLogs) { console.log('found log'); From 12dc1610db8f844d425e9cd12c9f43f6711e9967 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 12 Dec 2025 14:59:05 +0900 Subject: [PATCH 09/17] Proper fix --- e2e-tests/verify.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e-tests/verify.mjs b/e2e-tests/verify.mjs index 62d78e7872d..3e22f99ee50 100755 --- a/e2e-tests/verify.mjs +++ b/e2e-tests/verify.mjs @@ -32,12 +32,12 @@ for (const line of lines) { verifiedSpan = true; } if (parsed.resourceMetrics) { - const metric = parsed.resourceMetrics[0].scopeMetrics[0].metrics.find( - m => m.name === 'example_counter' + const scopeMetrics = parsed.resourceMetrics[0].scopeMetrics.find( + sm => sm.scope.name === 'example-meter' ); - if (metric) { + if (scopeMetrics) { console.log('found metric'); - verifyMetric(metric); + verifyMetric(scopeMetrics.metrics[0]); verifiedMetric = true; } } From 28328d4abc2f95d753ab12618e375c59780b5975 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 12 Dec 2025 14:59:49 +0900 Subject: [PATCH 10/17] Changelog --- api/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b9c38e2293d..1b5da7cd0c4 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. * feat(api): improve isValidSpanId, isValidTraceId performance [#5714](https://github.com/open-telemetry/opentelemetry-js/pull/5714) @seemk * feat(diag): change types in `DiagComponentLogger` from `any` to `unknown`[#5478](https://github.com/open-telemetry/opentelemetry-js/pull/5478) @loganrosen +* feat(sdk-trace): implement span start/end metrics [#1851](https://github.com/open-telemetry/opentelemetry-js/pull/6213) @anuraaga ### :bug: (Bug Fix) From 568aef464b189d94bffef8279e79d8a507bb8ae0 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Feb 2026 12:48:13 +0900 Subject: [PATCH 11/17] Cleanup --- CHANGELOG.md | 2 ++ api/CHANGELOG.md | 1 - .../opentelemetry-sdk-trace-base/src/Tracer.ts | 8 +++++--- .../src/TracerMetrics.ts | 17 ++++++----------- .../opentelemetry-sdk-trace-base/src/types.ts | 10 +++++----- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0653c181d..cf57261570d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(sdk-trace): implement span start/end metrics [#1851](https://github.com/open-telemetry/opentelemetry-js/pull/6213) @anuraaga + ### :bug: Bug Fixes * fix(opentelemetry-sdk-node): the custom value from env variable for service.instance.id should take priority over random uuid as backup [#6345](https://github.com/open-telemetry/opentelemetry-js/pull/6345) @maryliag diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b2c4f9a45c8..a565ef6bc0b 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,7 +11,6 @@ All notable changes to this project will be documented in this file. * feat(api): improve isValidSpanId, isValidTraceId performance [#5714](https://github.com/open-telemetry/opentelemetry-js/pull/5714) @seemk * feat(diag): change types in `DiagComponentLogger` from `any` to `unknown`[#5478](https://github.com/open-telemetry/opentelemetry-js/pull/5478) @loganrosen -* feat(sdk-trace): implement span start/end metrics [#1851](https://github.com/open-telemetry/opentelemetry-js/pull/6213) @anuraaga ### :bug: (Bug Fix) diff --git a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts index dc6c140f543..2694f872fc1 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts @@ -60,10 +60,12 @@ export class Tracer implements api.Tracer { this._idGenerator = config.idGenerator || new RandomIdGenerator(); this._resource = resource; this._spanProcessor = spanProcessor; - this._tracerMetrics = new TracerMetrics( - localConfig.meterProvider || api.metrics.getMeterProvider() - ); this.instrumentationScope = instrumentationScope; + + const meter = localConfig.meterProvider + ? localConfig.meterProvider.getMeter('@opentelemetry/sdk-trace') + : api.createNoopMeter(); + this._tracerMetrics = new TracerMetrics(meter); } /** diff --git a/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts index b97178405c1..331c069c143 100644 --- a/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts +++ b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts @@ -13,21 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Counter, - MeterProvider, - SpanContext, - UpDownCounter, -} from '@opentelemetry/api'; +import { Counter, Meter, SpanContext, UpDownCounter } from '@opentelemetry/api'; import { SamplingDecision } from './Sampler'; +/** + * Generates `otel.sdk.span.*` metrics. + * https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/#span-metrics + */ export class TracerMetrics { private readonly startedSpans: Counter; private readonly liveSpans: UpDownCounter; - constructor(meterProvider: MeterProvider) { - const meter = meterProvider.getMeter('@opentelemetry/sdk-trace'); - + constructor(meter: Meter) { this.startedSpans = meter.createCounter('otel.sdk.span.started', { unit: '{span}', description: 'The number of created spans.', @@ -81,7 +78,5 @@ function samplingDecisionToString(decision: SamplingDecision): string { return 'RECORD_ONLY'; case SamplingDecision.NOT_RECORD: return 'DROP'; - default: - return 'unknown'; } } diff --git a/packages/opentelemetry-sdk-trace-base/src/types.ts b/packages/opentelemetry-sdk-trace-base/src/types.ts index 29851bef745..2079325e693 100644 --- a/packages/opentelemetry-sdk-trace-base/src/types.ts +++ b/packages/opentelemetry-sdk-trace-base/src/types.ts @@ -14,15 +14,15 @@ * limitations under the License. */ -import { +import type { ContextManager, MeterProvider, TextMapPropagator, } from '@opentelemetry/api'; -import { Resource } from '@opentelemetry/resources'; -import { IdGenerator } from './IdGenerator'; -import { Sampler } from './Sampler'; -import { SpanProcessor } from './SpanProcessor'; +import type { Resource } from '@opentelemetry/resources'; +import type { IdGenerator } from './IdGenerator'; +import type { Sampler } from './Sampler'; +import type { SpanProcessor } from './SpanProcessor'; /** * TracerConfig provides an interface for configuring a Basic Tracer. From 6379c36f27d18b5404e060e8b925f0255fc81dae Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Feb 2026 12:56:38 +0900 Subject: [PATCH 12/17] Initialize tracer metrics in NodeSDK --- .../opentelemetry-sdk-node/src/sdk.ts | 45 ++++++++++--------- .../opentelemetry-sdk-node/test/sdk.test.ts | 31 +++++++++++++ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts index 84fc35cd0ae..966e74192b0 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -326,6 +326,28 @@ export class NodeSDK { }) ); + if ( + this._meterProviderConfig?.readers && + // only register if there is a reader, otherwise we waste compute/memory. + this._meterProviderConfig.readers.length > 0 + ) { + const meterProvider = new MeterProvider({ + resource: this._resource, + views: this._meterProviderConfig?.views ?? [], + readers: this._meterProviderConfig.readers, + }); + + this._meterProvider = meterProvider; + metrics.setGlobalMeterProvider(meterProvider); + + // TODO: This is a workaround to fix https://github.com/open-telemetry/opentelemetry-js/issues/3609 + // If the MeterProvider is not yet registered when instrumentations are registered, all metrics are dropped. + // This code is obsolete once https://github.com/open-telemetry/opentelemetry-js/issues/3622 is implemented. + for (const instrumentation of this._instrumentations) { + instrumentation.setMeterProvider(metrics.getMeterProvider()); + } + } + const spanProcessors = this._tracerProviderConfig ? this._tracerProviderConfig.spanProcessors : getSpanProcessorsFromEnv(); @@ -335,6 +357,7 @@ export class NodeSDK { this._tracerProvider = new NodeTracerProvider({ ...this._configuration, resource: this._resource, + meterProvider: this._meterProvider, spanProcessors, }); trace.setGlobalTracerProvider(this._tracerProvider); @@ -351,28 +374,6 @@ export class NodeSDK { logs.setGlobalLoggerProvider(loggerProvider); } - - if ( - this._meterProviderConfig?.readers && - // only register if there is a reader, otherwise we waste compute/memory. - this._meterProviderConfig.readers.length > 0 - ) { - const meterProvider = new MeterProvider({ - resource: this._resource, - views: this._meterProviderConfig?.views ?? [], - readers: this._meterProviderConfig.readers, - }); - - this._meterProvider = meterProvider; - metrics.setGlobalMeterProvider(meterProvider); - - // TODO: This is a workaround to fix https://github.com/open-telemetry/opentelemetry-js/issues/3609 - // If the MeterProvider is not yet registered when instrumentations are registered, all metrics are dropped. - // This code is obsolete once https://github.com/open-telemetry/opentelemetry-js/issues/3622 is implemented. - for (const instrumentation of this._instrumentations) { - instrumentation.setMeterProvider(metrics.getMeterProvider()); - } - } } public shutdown(): Promise { diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index dd4a23c44d0..634f7f383d6 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -431,6 +431,37 @@ describe('Node SDK', () => { await sdk.shutdown(); }); + it('should register a meter provider to the tracer provider if both initialized', async () => { + const exporter = new ConsoleMetricExporter(); + const metricReader = new PeriodicExportingMetricReader({ + exporter: exporter, + exportIntervalMillis: 100, + exportTimeoutMillis: 100, + }); + + const sdk = new NodeSDK({ + metricReader: metricReader, + traceExporter: new ConsoleSpanExporter(), + autoDetectResources: false, + }); + + sdk.start(); + + assertDefaultContextManagerRegistered(); + assertDefaultPropagatorRegistered(); + + assert.strictEqual(setGlobalTracerProviderSpy.callCount, 1); + const tracerProvider = setGlobalTracerProviderSpy.lastCall.args[0]; + assert.ok(tracerProvider instanceof NodeTracerProvider); + assert.ok( + (tracerProvider as any)._config.meterProvider instanceof MeterProvider + ); + + assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + + await sdk.shutdown(); + }); + it('should register a logger provider if a log record processor is provided', async () => { process.env.OTEL_TRACES_EXPORTER = 'none'; const logRecordExporter = new InMemoryLogRecordExporter(); From 8ba3cf47ba49fd92169d15766b1880bac4ee55cb Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Feb 2026 12:59:22 +0900 Subject: [PATCH 13/17] semconv --- .../src/TracerMetrics.ts | 16 ++++-- .../src/semconv.ts | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 packages/opentelemetry-sdk-trace-base/src/semconv.ts diff --git a/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts index 331c069c143..dd5630ba612 100644 --- a/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts +++ b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts @@ -15,6 +15,12 @@ */ import { Counter, Meter, SpanContext, UpDownCounter } from '@opentelemetry/api'; import { SamplingDecision } from './Sampler'; +import { + ATTR_OTEL_SPAN_PARENT_ORIGIN, + ATTR_OTEL_SPAN_SAMPLING_RESULT, + METRIC_OTEL_SDK_SPAN_LIVE, + METRIC_OTEL_SDK_SPAN_STARTED, +} from './semconv'; /** * Generates `otel.sdk.span.*` metrics. @@ -25,12 +31,12 @@ export class TracerMetrics { private readonly liveSpans: UpDownCounter; constructor(meter: Meter) { - this.startedSpans = meter.createCounter('otel.sdk.span.started', { + this.startedSpans = meter.createCounter(METRIC_OTEL_SDK_SPAN_STARTED, { unit: '{span}', description: 'The number of created spans.', }); - this.liveSpans = meter.createUpDownCounter('otel.sdk.span.live', { + this.liveSpans = meter.createUpDownCounter(METRIC_OTEL_SDK_SPAN_LIVE, { unit: '{span}', description: 'The number of currently live spans.', }); @@ -42,8 +48,8 @@ export class TracerMetrics { ): () => void { const samplingDecisionStr = samplingDecisionToString(samplingDecision); this.startedSpans.add(1, { - 'otel.span.parent.origin': parentOrigin(parentSpanCtx), - 'otel.span.sampling_result': samplingDecisionStr, + [ATTR_OTEL_SPAN_PARENT_ORIGIN]: parentOrigin(parentSpanCtx), + [ATTR_OTEL_SPAN_SAMPLING_RESULT]: samplingDecisionStr, }); if (samplingDecision === SamplingDecision.NOT_RECORD) { @@ -51,7 +57,7 @@ export class TracerMetrics { } const liveSpanAttributes = { - 'otel.span.sampling_result': samplingDecisionStr, + [ATTR_OTEL_SPAN_SAMPLING_RESULT]: samplingDecisionStr, }; this.liveSpans.add(1, liveSpanAttributes); return () => { diff --git a/packages/opentelemetry-sdk-trace-base/src/semconv.ts b/packages/opentelemetry-sdk-trace-base/src/semconv.ts new file mode 100644 index 00000000000..7f83cc7ee54 --- /dev/null +++ b/packages/opentelemetry-sdk-trace-base/src/semconv.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by this package. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +/** + * Determines whether the span has a parent span, and if so, [whether it is a remote parent](https://opentelemetry.io/docs/specs/otel/trace/api/#isremote) + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_SPAN_PARENT_ORIGIN = 'otel.span.parent.origin' as const; + +/** + * The result value of the sampler for this span + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_SPAN_SAMPLING_RESULT = + 'otel.span.sampling_result' as const; + +/** + * The number of created spans with `recording=true` for which the end operation has not been called yet. + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_OTEL_SDK_SPAN_LIVE = 'otel.sdk.span.live' as const; + +/** + * The number of created spans. + * + * @note Implementations **MUST** record this metric for all spans, even for non-recording ones. + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_OTEL_SDK_SPAN_STARTED = 'otel.sdk.span.started' as const; From ddf281487c9c8f80154a484c26a94270a99fec22 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Feb 2026 13:18:09 +0900 Subject: [PATCH 14/17] Make SDK tests default to no exporter --- .../opentelemetry-sdk-node/test/sdk.test.ts | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index 634f7f383d6..e87888becd0 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -111,6 +111,13 @@ describe('Node SDK', () => { setGlobalTracerProviderSpy = Sinon.spy(trace, 'setGlobalTracerProvider'); setGlobalLoggerProviderSpy = Sinon.spy(logs, 'setGlobalLoggerProvider'); + + // need to set these to none, since the default value is 'otlp'. Tests either + // provide exporters programatically or reset to an appropriate value as + // appropriate. + process.env.OTEL_TRACES_EXPORTER = 'none'; + process.env.OTEL_LOGS_EXPORTER = 'none'; + process.env.OTEL_METRICS_EXPORTER = 'none'; }); afterEach(() => { @@ -127,10 +134,6 @@ describe('Node SDK', () => { }); it('should not register more than the minimal SDK components', async () => { - // need to set these to none, since the default value is 'otlp' - process.env.OTEL_TRACES_EXPORTER = 'none'; - process.env.OTEL_LOGS_EXPORTER = 'none'; - process.env.OTEL_METRICS_EXPORTER = 'none'; const sdk = new NodeSDK({ autoDetectResources: false, }); @@ -272,9 +275,6 @@ describe('Node SDK', () => { }); it('should register a meter provider if a reader is provided', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - // which sets up an exporter and affects the context manager - process.env.OTEL_TRACES_EXPORTER = 'none'; const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ exporter: exporter, @@ -303,9 +303,6 @@ describe('Node SDK', () => { }); it('should register a meter provider if multiple readers are provided', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - // which sets up an exporter and affects the context manager - process.env.OTEL_TRACES_EXPORTER = 'none'; const consoleExporter = new ConsoleMetricExporter(); const inMemoryExporter = new InMemoryMetricExporter( AggregationTemporality.CUMULATIVE @@ -347,9 +344,6 @@ describe('Node SDK', () => { }); it('should show deprecation warning when using metricReader option', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - // which sets up an exporter and affects the context manager - process.env.OTEL_TRACES_EXPORTER = 'none'; const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ exporter: exporter, @@ -378,9 +372,6 @@ describe('Node SDK', () => { }); it('should not show deprecation warning when using metricReaders option', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - // which sets up an exporter and affects the context manager - process.env.OTEL_TRACES_EXPORTER = 'none'; const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ exporter: exporter, @@ -409,8 +400,6 @@ describe('Node SDK', () => { }); it('should not register meter provider when metricReaders is empty array', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - process.env.OTEL_TRACES_EXPORTER = 'none'; const sdk = new NodeSDK({ metricReaders: [], autoDetectResources: false, @@ -463,7 +452,6 @@ describe('Node SDK', () => { }); it('should register a logger provider if a log record processor is provided', async () => { - process.env.OTEL_TRACES_EXPORTER = 'none'; const logRecordExporter = new InMemoryLogRecordExporter(); const logRecordProcessor = new SimpleLogRecordProcessor( logRecordExporter @@ -635,9 +623,6 @@ describe('Node SDK', () => { } it('should register meter views when provided', async () => { - // need to set OTEL_TRACES_EXPORTER to none since default value is otlp - // which sets up an exporter and affects the context manager - process.env.OTEL_TRACES_EXPORTER = 'none'; const exporter = new InMemoryMetricExporter( AggregationTemporality.CUMULATIVE ); @@ -1194,6 +1179,9 @@ describe('Node SDK', () => { beforeEach(() => { stubLogger = Sinon.stub(diag, 'info'); + delete process.env.OTEL_LOGS_EXPORTER; + delete process.env.OTEL_METRICS_EXPORTER; + delete process.env.OTEL_TRACES_EXPORTER; }); afterEach(() => { @@ -1334,8 +1322,6 @@ describe('Node SDK', () => { it('should apply OTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMIT and OTEL_LOGRECORD_ATTRIBUTE_VALUE_LENGTH_LIMIT', async () => { // arrange - process.env.OTEL_TRACES_EXPORTER = 'none'; - process.env.OTEL_METRICS_EXPORTER = 'none'; process.env.OTEL_LOGRECORD_ATTRIBUTE_COUNT_LIMIT = '2'; process.env.OTEL_LOGRECORD_ATTRIBUTE_VALUE_LENGTH_LIMIT = '10'; @@ -1386,6 +1372,9 @@ describe('Node SDK', () => { beforeEach(() => { infoStub = Sinon.stub(diag, 'info'); warnStub = Sinon.stub(diag, 'warn'); + delete process.env.OTEL_LOGS_EXPORTER; + delete process.env.OTEL_METRICS_EXPORTER; + delete process.env.OTEL_TRACES_EXPORTER; }); afterEach(() => { @@ -1715,6 +1704,9 @@ describe('Node SDK', () => { beforeEach(() => { stubLoggerError = Sinon.stub(diag, 'warn'); + delete process.env.OTEL_LOGS_EXPORTER; + delete process.env.OTEL_METRICS_EXPORTER; + delete process.env.OTEL_TRACES_EXPORTER; }); afterEach(() => { From 26a9d38c02d3e3c0093d708c693cbbfef60d0199 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Thu, 5 Feb 2026 13:30:19 +0900 Subject: [PATCH 15/17] Stutter --- experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index e87888becd0..2cb2309facf 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -113,8 +113,7 @@ describe('Node SDK', () => { setGlobalLoggerProviderSpy = Sinon.spy(logs, 'setGlobalLoggerProvider'); // need to set these to none, since the default value is 'otlp'. Tests either - // provide exporters programatically or reset to an appropriate value as - // appropriate. + // provide exporters programatically or reset to an appropriate value. process.env.OTEL_TRACES_EXPORTER = 'none'; process.env.OTEL_LOGS_EXPORTER = 'none'; process.env.OTEL_METRICS_EXPORTER = 'none'; From 49c10d5936a4255da1faed5049c43ee2024e515f Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 13 Feb 2026 15:08:34 +0900 Subject: [PATCH 16/17] Add guard --- .../packages/opentelemetry-sdk-node/README.md | 15 +++++++++ .../opentelemetry-sdk-node/src/sdk.ts | 7 +++- .../opentelemetry-sdk-node/test/sdk.test.ts | 33 ++++++++++++++++++- .../src/Tracer.ts | 3 +- .../opentelemetry-sdk-trace-base/src/types.ts | 5 ++- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/experimental/packages/opentelemetry-sdk-node/README.md b/experimental/packages/opentelemetry-sdk-node/README.md index 22033184bfc..3c49f06be28 100644 --- a/experimental/packages/opentelemetry-sdk-node/README.md +++ b/experimental/packages/opentelemetry-sdk-node/README.md @@ -228,6 +228,21 @@ Additionally, you can specify other applicable environment variables that apply - [OTLP exporter environment configuration](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#configuration-options) - [Zipkin exporter environment configuration](https://github.com/open-telemetry/opentelemetry-specification/blob/6ce62202e5407518e19c56c445c13682ef51a51d/specification/sdk-environment-variables.md#zipkin-exporter) +## Enable OpenTelemetry SDK internal metrics from environment + +OpenTelemetry defines [metrics for monitoring SDK components](https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/). +Until this spec is stabilized, the following environment variable must be used +to enable these metrics: + +``` +OTEL_NODE_EXPERIMENTAL_SDK_METRICS=true +``` + +Currently a subset of the specified metrics are implemented. See the following +linkes for details: + +- Span metrics: [TracerMetrics.ts](../../../packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts) + ## Useful links - For more information on OpenTelemetry, visit: diff --git a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts index 966e74192b0..26595cdcb6c 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -354,10 +354,15 @@ export class NodeSDK { // Only register if there is a span processor if (spanProcessors.length > 0) { + // While SDK metrics are unstable, we require an opt-in. + // https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/ + const sdkMetricsEnabled = getBooleanFromEnv( + 'OTEL_NODE_EXPERIMENTAL_SDK_METRICS' + ); this._tracerProvider = new NodeTracerProvider({ ...this._configuration, resource: this._resource, - meterProvider: this._meterProvider, + meterProvider: sdkMetricsEnabled ? this._meterProvider : undefined, spanProcessors, }); trace.setGlobalTracerProvider(this._tracerProvider); diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index 2cb2309facf..cec88f4409e 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -130,6 +130,7 @@ describe('Node SDK', () => { delete process.env.OTEL_METRICS_EXPORTER; delete process.env.OTEL_PROPAGATORS; delete process.env.OTEL_TRACES_EXPORTER; + delete process.env.OTEL_NODE_EXPERIMENTAL_SDK_METRICS; }); it('should not register more than the minimal SDK components', async () => { @@ -419,7 +420,8 @@ describe('Node SDK', () => { await sdk.shutdown(); }); - it('should register a meter provider to the tracer provider if both initialized', async () => { + it('should register a meter provider to the tracer provider if both initialized and metrics enabled', async () => { + process.env.OTEL_NODE_EXPERIMENTAL_SDK_METRICS = 'true'; const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ exporter: exporter, @@ -450,6 +452,35 @@ describe('Node SDK', () => { await sdk.shutdown(); }); + it('should not register a meter provider to the tracer provider if both initialized but metrics disabled', async () => { + const exporter = new ConsoleMetricExporter(); + const metricReader = new PeriodicExportingMetricReader({ + exporter: exporter, + exportIntervalMillis: 100, + exportTimeoutMillis: 100, + }); + + const sdk = new NodeSDK({ + metricReader: metricReader, + traceExporter: new ConsoleSpanExporter(), + autoDetectResources: false, + }); + + sdk.start(); + + assertDefaultContextManagerRegistered(); + assertDefaultPropagatorRegistered(); + + assert.strictEqual(setGlobalTracerProviderSpy.callCount, 1); + const tracerProvider = setGlobalTracerProviderSpy.lastCall.args[0]; + assert.ok(tracerProvider instanceof NodeTracerProvider); + assert.equal((tracerProvider as any)._config.meterProvider, undefined); + + assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + + await sdk.shutdown(); + }); + it('should register a logger provider if a log record processor is provided', async () => { const logRecordExporter = new InMemoryLogRecordExporter(); const logRecordProcessor = new SimpleLogRecordProcessor( diff --git a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts index 2694f872fc1..eb84fd2055c 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts @@ -29,6 +29,7 @@ import { IdGenerator } from './IdGenerator'; import { RandomIdGenerator } from './platform'; import { Resource } from '@opentelemetry/resources'; import { TracerMetrics } from './TracerMetrics'; +import { VERSION } from './version'; /** * This class represents a basic tracer. @@ -63,7 +64,7 @@ export class Tracer implements api.Tracer { this.instrumentationScope = instrumentationScope; const meter = localConfig.meterProvider - ? localConfig.meterProvider.getMeter('@opentelemetry/sdk-trace') + ? localConfig.meterProvider.getMeter('@opentelemetry/sdk-trace', VERSION) : api.createNoopMeter(); this._tracerMetrics = new TracerMetrics(meter); } diff --git a/packages/opentelemetry-sdk-trace-base/src/types.ts b/packages/opentelemetry-sdk-trace-base/src/types.ts index 2079325e693..6cd03cfd5d9 100644 --- a/packages/opentelemetry-sdk-trace-base/src/types.ts +++ b/packages/opentelemetry-sdk-trace-base/src/types.ts @@ -59,7 +59,10 @@ export interface TracerConfig { */ spanProcessors?: SpanProcessor[]; - /** A meter provider to record trace SDK metrics to. */ + /** + * A meter provider to record trace SDK metrics to. + * @experimental This option is experimental and is subject to breaking changes in minor releases. + */ meterProvider?: MeterProvider; } From feb16217bf1978f9faad0c6baee1cd39baf9b5ca Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal Date: Fri, 13 Feb 2026 15:18:15 +0900 Subject: [PATCH 17/17] Code block --- experimental/packages/opentelemetry-sdk-node/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experimental/packages/opentelemetry-sdk-node/README.md b/experimental/packages/opentelemetry-sdk-node/README.md index 3c49f06be28..c38a8e936ae 100644 --- a/experimental/packages/opentelemetry-sdk-node/README.md +++ b/experimental/packages/opentelemetry-sdk-node/README.md @@ -234,7 +234,7 @@ OpenTelemetry defines [metrics for monitoring SDK components](https://openteleme Until this spec is stabilized, the following environment variable must be used to enable these metrics: -``` +```bash OTEL_NODE_EXPERIMENTAL_SDK_METRICS=true ```