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/e2e-tests/verify.mjs b/e2e-tests/verify.mjs index 16bafb7d913..3e22f99ee50 100755 --- a/e2e-tests/verify.mjs +++ b/e2e-tests/verify.mjs @@ -32,9 +32,14 @@ for (const line of lines) { verifiedSpan = true; } if (parsed.resourceMetrics) { - console.log('found metric'); - verifyMetric(parsed.resourceMetrics[0].scopeMetrics[0].metrics[0]); - verifiedMetric = true; + const scopeMetrics = parsed.resourceMetrics[0].scopeMetrics.find( + sm => sm.scope.name === 'example-meter' + ); + if (scopeMetrics) { + console.log('found metric'); + verifyMetric(scopeMetrics.metrics[0]); + verifiedMetric = true; + } } if (parsed.resourceLogs) { console.log('found log'); diff --git a/experimental/packages/opentelemetry-sdk-node/README.md b/experimental/packages/opentelemetry-sdk-node/README.md index 22033184bfc..c38a8e936ae 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: + +```bash +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 84fc35cd0ae..26595cdcb6c 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -326,15 +326,43 @@ 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(); // 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: sdkMetricsEnabled ? this._meterProvider : undefined, spanProcessors, }); trace.setGlobalTracerProvider(this._tracerProvider); @@ -351,28 +379,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..cec88f4409e 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -111,6 +111,12 @@ 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. + process.env.OTEL_TRACES_EXPORTER = 'none'; + process.env.OTEL_LOGS_EXPORTER = 'none'; + process.env.OTEL_METRICS_EXPORTER = 'none'; }); afterEach(() => { @@ -124,13 +130,10 @@ 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 () => { - // 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, @@ -431,8 +420,68 @@ describe('Node SDK', () => { await sdk.shutdown(); }); + 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, + 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 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 () => { - process.env.OTEL_TRACES_EXPORTER = 'none'; const logRecordExporter = new InMemoryLogRecordExporter(); const logRecordProcessor = new SimpleLogRecordProcessor( logRecordExporter @@ -604,9 +653,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 ); @@ -1163,6 +1209,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(() => { @@ -1303,8 +1352,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'; @@ -1355,6 +1402,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(() => { @@ -1684,6 +1734,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(() => { diff --git a/package-lock.json b/package-lock.json index 325ec03bda5..b460bb4be50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24588,6 +24588,7 @@ }, "devDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.2.0", "@types/benchmark": "2.1.5", "@types/mocha": "10.0.10", "@types/node": "18.19.130", @@ -24615,6 +24616,56 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "packages/opentelemetry-sdk-trace-base/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "packages/opentelemetry-sdk-trace-base/node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "packages/opentelemetry-sdk-trace-base/node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "packages/opentelemetry-sdk-trace-node": { "name": "@opentelemetry/sdk-trace-node", "version": "2.5.0", diff --git a/packages/opentelemetry-sdk-trace-base/package.json b/packages/opentelemetry-sdk-trace-base/package.json index 2c8a50e201c..67872e52ddd 100644 --- a/packages/opentelemetry-sdk-trace-base/package.json +++ b/packages/opentelemetry-sdk-trace-base/package.json @@ -65,6 +65,7 @@ }, "devDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.2.0", "@types/benchmark": "2.1.5", "@types/mocha": "10.0.10", "@types/node": "18.19.130", diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts index c9b1cd9cabe..b65384e705d 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Span.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts @@ -72,6 +72,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 _recordEndMetrics?: () => 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._recordEndMetrics = opts.recordEndMetrics; if (opts.attributes != null) { this.setAttributes(opts.attributes); @@ -300,6 +303,7 @@ export class SpanImpl implements Span { this._spanProcessor.onEnding(this); } + this._recordEndMetrics?.(); this._ended = true; this._spanProcessor.onEnd(this); } diff --git a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts index ec587a1eed2..eb84fd2055c 100644 --- a/packages/opentelemetry-sdk-trace-base/src/Tracer.ts +++ b/packages/opentelemetry-sdk-trace-base/src/Tracer.ts @@ -28,6 +28,8 @@ import { Sampler } from './Sampler'; 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. @@ -41,6 +43,7 @@ export class Tracer implements api.Tracer { private readonly _resource: Resource; private readonly _spanProcessor: SpanProcessor; + private readonly _tracerMetrics: TracerMetrics; /** * Constructs a new Tracer instance. @@ -59,6 +62,11 @@ export class Tracer implements api.Tracer { this._resource = resource; this._spanProcessor = spanProcessor; this.instrumentationScope = instrumentationScope; + + const meter = localConfig.meterProvider + ? localConfig.meterProvider.getMeter('@opentelemetry/sdk-trace', VERSION) + : api.createNoopMeter(); + this._tracerMetrics = new TracerMetrics(meter); } /** @@ -120,6 +128,11 @@ export class Tracer implements api.Tracer { links ); + const recordEndMetrics = this._tracerMetrics.startSpan( + parentSpanContext, + samplingResult.decision + ); + traceState = samplingResult.traceState ?? traceState; const traceFlags = @@ -154,6 +167,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/TracerMetrics.ts b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts new file mode 100644 index 00000000000..dd5630ba612 --- /dev/null +++ b/packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts @@ -0,0 +1,88 @@ +/* + * 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, 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. + * https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/#span-metrics + */ +export class TracerMetrics { + private readonly startedSpans: Counter; + private readonly liveSpans: UpDownCounter; + + constructor(meter: Meter) { + this.startedSpans = meter.createCounter(METRIC_OTEL_SDK_SPAN_STARTED, { + unit: '{span}', + description: 'The number of created spans.', + }); + + this.liveSpans = meter.createUpDownCounter(METRIC_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, { + [ATTR_OTEL_SPAN_PARENT_ORIGIN]: parentOrigin(parentSpanCtx), + [ATTR_OTEL_SPAN_SAMPLING_RESULT]: samplingDecisionStr, + }); + + if (samplingDecision === SamplingDecision.NOT_RECORD) { + return () => {}; + } + + const liveSpanAttributes = { + [ATTR_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'; + } +} 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; diff --git a/packages/opentelemetry-sdk-trace-base/src/types.ts b/packages/opentelemetry-sdk-trace-base/src/types.ts index 413a5caa1cc..6cd03cfd5d9 100644 --- a/packages/opentelemetry-sdk-trace-base/src/types.ts +++ b/packages/opentelemetry-sdk-trace-base/src/types.ts @@ -14,11 +14,15 @@ * limitations under the License. */ -import { ContextManager, TextMapPropagator } from '@opentelemetry/api'; -import { Resource } from '@opentelemetry/resources'; -import { IdGenerator } from './IdGenerator'; -import { Sampler } from './Sampler'; -import { SpanProcessor } from './SpanProcessor'; +import type { + ContextManager, + MeterProvider, + TextMapPropagator, +} from '@opentelemetry/api'; +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. @@ -54,6 +58,12 @@ export interface TracerConfig { * List of SpanProcessor for the tracer */ spanProcessors?: SpanProcessor[]; + + /** + * 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; } /** 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..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,6 +27,7 @@ import { defaultResource, resourceFromAttributes, } from '@opentelemetry/resources'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import { @@ -41,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(() => { @@ -564,4 +567,254 @@ describe('BasicTracerProvider', () => { sinon.assert.calledOnce(shutdownStub); }); }); + + describe('TracerMetrics', () => { + 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', + }); + }); + + 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', + }); + }); + + 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); + }); + + 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 dropped spans with local 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: 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(); + } +} diff --git a/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json b/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json index 5c669f84042..1c23f621bf2 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.esm.json @@ -25,6 +25,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 c49b4c14d71..aeedeefeb7b 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.esnext.json @@ -25,6 +25,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 b5736caac3c..fa7ab56e53e 100644 --- a/packages/opentelemetry-sdk-trace-base/tsconfig.json +++ b/packages/opentelemetry-sdk-trace-base/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "../opentelemetry-resources" + }, + { + "path": "../sdk-metrics" } ] }