diff --git a/CHANGELOG.md b/CHANGELOG.md index c49dbc7574b..406624d8e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(sdk-logs): implement log creation metrics [#6433](https://github.com/open-telemetry/opentelemetry-js/pull/6433) @anuraaga * feat(sdk-trace): implement span start/end metrics [#6213](https://github.com/open-telemetry/opentelemetry-js/pull/6213) @anuraaga ### :bug: Bug Fixes diff --git a/experimental/packages/opentelemetry-sdk-node/README.md b/experimental/packages/opentelemetry-sdk-node/README.md index c38a8e936ae..9e8c04d6ff2 100644 --- a/experimental/packages/opentelemetry-sdk-node/README.md +++ b/experimental/packages/opentelemetry-sdk-node/README.md @@ -241,6 +241,7 @@ OTEL_NODE_EXPERIMENTAL_SDK_METRICS=true Currently a subset of the specified metrics are implemented. See the following linkes for details: +- Logger metrics: [LoggerMetrics.ts](../sdk-logs/src/LoggerMetrics.ts) - Span metrics: [TracerMetrics.ts](../../../packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts) ## Useful links diff --git a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts index d58fe5be5f6..d9e865515ed 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -312,6 +312,12 @@ export class NodeSDK { }) ); + // 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' + ); + if ( this._meterProviderConfig?.readers && // only register if there is a reader, otherwise we waste compute/memory. @@ -340,11 +346,6 @@ 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, @@ -359,6 +360,7 @@ export class NodeSDK { ...getLoggerProviderConfigFromEnv(), resource: this._resource, processors: this._loggerProviderConfig.logRecordProcessors, + meterProvider: sdkMetricsEnabled ? this._meterProvider : undefined, }); this._loggerProvider = loggerProvider; diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index 260501f0ab0..212a2a5ef78 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -68,6 +68,7 @@ import { OTLPTraceExporter as OTLPProtoTraceExporter } from '@opentelemetry/expo import { OTLPTraceExporter as OTLPGrpcTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; import { ZipkinExporter } from '@opentelemetry/exporter-zipkin'; +import { NOOP_COUNTER_METRIC } from '../../../../api/src/metrics/NoopMeter'; import { ATTR_HOST_NAME, ATTR_PROCESS_PID } from '../src/semconv'; function assertDefaultContextManagerRegistered() { @@ -408,7 +409,7 @@ describe('Node SDK', () => { await sdk.shutdown(); }); - it('should register a meter provider to the tracer provider if both initialized and metrics enabled', async () => { + it('should configure components for SDK metrics if enabled', async () => { process.env.OTEL_NODE_EXPERIMENTAL_SDK_METRICS = 'true'; const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ @@ -420,6 +421,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK({ metricReader: metricReader, traceExporter: new ConsoleSpanExporter(), + logRecordProcessors: [ + new SimpleLogRecordProcessor(new InMemoryLogRecordExporter()), + ], autoDetectResources: false, }); @@ -435,12 +439,18 @@ describe('Node SDK', () => { (tracerProvider as any)._config.meterProvider instanceof MeterProvider ); + const loggerProvider = setGlobalLoggerProviderSpy.lastCall.args[0]; + assert.notDeepEqual( + (loggerProvider as any)['_sharedState'].loggerMetrics.createdLogs, + NOOP_COUNTER_METRIC + ); + 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 () => { + it('should not configure components for SDK metrics if disabled', async () => { const exporter = new ConsoleMetricExporter(); const metricReader = new PeriodicExportingMetricReader({ exporter: exporter, @@ -451,6 +461,9 @@ describe('Node SDK', () => { const sdk = new NodeSDK({ metricReader: metricReader, traceExporter: new ConsoleSpanExporter(), + logRecordProcessors: [ + new SimpleLogRecordProcessor(new InMemoryLogRecordExporter()), + ], autoDetectResources: false, }); @@ -464,6 +477,12 @@ describe('Node SDK', () => { assert.ok(tracerProvider instanceof NodeTracerProvider); assert.equal((tracerProvider as any)._config.meterProvider, undefined); + const loggerProvider = setGlobalLoggerProviderSpy.lastCall.args[0]; + assert.deepEqual( + (loggerProvider as any)['_sharedState'].loggerMetrics.createdLogs, + NOOP_COUNTER_METRIC + ); + assert.ok(metrics.getMeterProvider() instanceof MeterProvider); await sdk.shutdown(); diff --git a/experimental/packages/sdk-logs/package.json b/experimental/packages/sdk-logs/package.json index 3ed215f15ed..07dbf4d25c5 100644 --- a/experimental/packages/sdk-logs/package.json +++ b/experimental/packages/sdk-logs/package.json @@ -73,6 +73,7 @@ "@babel/core": "7.27.1", "@babel/preset-env": "7.27.2", "@opentelemetry/api": ">=1.4.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.6.0", "@types/mocha": "10.0.10", "@types/node": "18.19.130", "@types/sinon": "17.0.4", diff --git a/experimental/packages/sdk-logs/src/Logger.ts b/experimental/packages/sdk-logs/src/Logger.ts index ab5abb0e898..21197ed4857 100644 --- a/experimental/packages/sdk-logs/src/Logger.ts +++ b/experimental/packages/sdk-logs/src/Logger.ts @@ -86,6 +86,7 @@ export class Logger implements logsAPI.Logger { ...logRecord, } ); + this._sharedState.loggerMetrics.emitLog(); /** * the explicitly passed Context, * the current Context, or an empty Context if the Logger was obtained with include_trace_context=false diff --git a/experimental/packages/sdk-logs/src/LoggerMetrics.ts b/experimental/packages/sdk-logs/src/LoggerMetrics.ts new file mode 100644 index 00000000000..6f7b4419e6d --- /dev/null +++ b/experimental/packages/sdk-logs/src/LoggerMetrics.ts @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ +import type { Counter, Meter } from '@opentelemetry/api'; +import { METRIC_OTEL_SDK_LOG_CREATED } from './semconv'; + +/** + * Generates `otel.sdk.log.*` metrics. + * https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/#log-metrics + */ +export class LoggerMetrics { + private readonly createdLogs: Counter; + + constructor(meter: Meter) { + this.createdLogs = meter.createCounter(METRIC_OTEL_SDK_LOG_CREATED, { + unit: '{log_record}', + description: 'The number of logs submitted to enabled SDK Loggers.', + }); + } + + emitLog() { + this.createdLogs.add(1); + } +} diff --git a/experimental/packages/sdk-logs/src/LoggerProvider.ts b/experimental/packages/sdk-logs/src/LoggerProvider.ts index 4bb088d93e7..e8accc03727 100644 --- a/experimental/packages/sdk-logs/src/LoggerProvider.ts +++ b/experimental/packages/sdk-logs/src/LoggerProvider.ts @@ -33,13 +33,15 @@ export class LoggerProvider implements logsAPI.LoggerProvider { loggerConfigurator: config.loggerConfigurator ?? DEFAULT_LOGGER_CONFIGURATOR, processors: config.processors ?? [], + meterProvider: config.meterProvider, }; this._sharedState = new LoggerProviderSharedState( mergedConfig.resource, mergedConfig.forceFlushTimeoutMillis, mergedConfig.logRecordLimits, mergedConfig.processors, - mergedConfig.loggerConfigurator + mergedConfig.loggerConfigurator, + mergedConfig.meterProvider ); this._shutdownOnce = new BindOnceFuture(this._shutdown, this); } diff --git a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts index 16a6b3644e8..bd7c13037a0 100644 --- a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts +++ b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type MeterProvider, createNoopMeter } from '@opentelemetry/api'; import type { Logger } from '@opentelemetry/api-logs'; import { SeverityNumber } from '@opentelemetry/api-logs'; import type { Resource } from '@opentelemetry/resources'; @@ -16,6 +17,8 @@ import type { import { NoopLogRecordProcessor } from '../export/NoopLogRecordProcessor'; import { MultiLogRecordProcessor } from '../MultiLogRecordProcessor'; import { getInstrumentationScopeKey } from './utils'; +import { LoggerMetrics } from '../LoggerMetrics'; +import { VERSION } from '../version'; const DEFAULT_LOGGER_CONFIG: Required = { disabled: false, @@ -38,6 +41,7 @@ export class LoggerProviderSharedState { readonly forceFlushTimeoutMillis: number; readonly logRecordLimits: Required; readonly processors: LogRecordProcessor[]; + readonly loggerMetrics: LoggerMetrics; private _loggerConfigurator: LoggerConfigurator; private _loggerConfigs: Map> = new Map(); @@ -46,7 +50,8 @@ export class LoggerProviderSharedState { forceFlushTimeoutMillis: number, logRecordLimits: Required, processors: LogRecordProcessor[], - loggerConfigurator?: LoggerConfigurator + loggerConfigurator?: LoggerConfigurator, + meterProvider?: MeterProvider ) { this.resource = resource; this.forceFlushTimeoutMillis = forceFlushTimeoutMillis; @@ -64,6 +69,11 @@ export class LoggerProviderSharedState { this._loggerConfigurator = loggerConfigurator ?? DEFAULT_LOGGER_CONFIGURATOR; + + const meter = meterProvider + ? meterProvider.getMeter('@opentelemetry/sdk-logs', VERSION) + : createNoopMeter(); + this.loggerMetrics = new LoggerMetrics(meter); } /** diff --git a/experimental/packages/sdk-logs/src/semconv.ts b/experimental/packages/sdk-logs/src/semconv.ts new file mode 100644 index 00000000000..80a4462e6d3 --- /dev/null +++ b/experimental/packages/sdk-logs/src/semconv.ts @@ -0,0 +1,17 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * 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 + */ + +/** + * The number of logs submitted to enabled SDK Loggers. + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_OTEL_SDK_LOG_CREATED = 'otel.sdk.log.created' as const; diff --git a/experimental/packages/sdk-logs/src/types.ts b/experimental/packages/sdk-logs/src/types.ts index c8da449a2e7..010e8b687ef 100644 --- a/experimental/packages/sdk-logs/src/types.ts +++ b/experimental/packages/sdk-logs/src/types.ts @@ -6,6 +6,7 @@ import type { Resource } from '@opentelemetry/resources'; import type { SeverityNumber } from '@opentelemetry/api-logs'; import type { InstrumentationScope } from '@opentelemetry/core'; +import type { MeterProvider } from '@opentelemetry/api'; import type { LogRecordProcessor } from './LogRecordProcessor'; /** @@ -83,6 +84,12 @@ export interface LoggerProviderConfig { * @experimental This feature is in development as per the OpenTelemetry specification. */ loggerConfigurator?: LoggerConfigurator; + + /** + * A meter provider to record logs SDK metrics to. + * @experimental This option is experimental and is subject to breaking changes in minor releases. + */ + meterProvider?: MeterProvider; } export interface LogRecordLimits { diff --git a/experimental/packages/sdk-logs/test/common/LoggerProvider.test.ts b/experimental/packages/sdk-logs/test/common/LoggerProvider.test.ts index d6e69e767a2..689cbe7afde 100644 --- a/experimental/packages/sdk-logs/test/common/LoggerProvider.test.ts +++ b/experimental/packages/sdk-logs/test/common/LoggerProvider.test.ts @@ -8,6 +8,7 @@ import { defaultResource, resourceFromAttributes, } from '@opentelemetry/resources'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; @@ -16,6 +17,7 @@ import { NoopLogRecordProcessor } from '../../src/export/NoopLogRecordProcessor' import { DEFAULT_LOGGER_NAME } from './../../src/LoggerProvider'; import { MultiLogRecordProcessor } from '../../src/MultiLogRecordProcessor'; import { Logger } from '../../src/Logger'; +import { TestMetricReader } from './utils'; describe('LoggerProvider', () => { beforeEach(() => { @@ -303,4 +305,39 @@ describe('LoggerProvider', () => { sinon.assert.calledOnce(warnStub); }); }); + + describe('LoggerMetrics', () => { + it('should record metrics for created logs', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + + const logRecordProcessor = new NoopLogRecordProcessor(); + const provider = new LoggerProvider({ + processors: [logRecordProcessor], + meterProvider, + }); + const logger = provider.getLogger('test'); + logger.emit({ body: 'log 1' }); + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let logsCreatedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.log.created' + ); + assert.ok(logsCreatedMetric); + assert.strictEqual(logsCreatedMetric.dataPoints[0].value, 1); + assert.deepStrictEqual(logsCreatedMetric.dataPoints[0].attributes, {}); + + logger.emit({ body: 'log 1' }); + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + logsCreatedMetric = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.log.created' + ); + assert.ok(logsCreatedMetric); + assert.strictEqual(logsCreatedMetric.dataPoints[0].value, 2); + assert.deepStrictEqual(logsCreatedMetric.dataPoints[0].attributes, {}); + }); + }); }); diff --git a/experimental/packages/sdk-logs/test/common/utils.ts b/experimental/packages/sdk-logs/test/common/utils.ts index 66ce65357ec..1ed7f28db8a 100644 --- a/experimental/packages/sdk-logs/test/common/utils.ts +++ b/experimental/packages/sdk-logs/test/common/utils.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { MetricReader } from '@opentelemetry/sdk-metrics'; + export const validAttributes = { string: 'string', number: 0, @@ -19,3 +21,12 @@ export const invalidAttributes = { // This empty length attribute should not be set '': 'empty-key', }; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/sdk-logs/tsconfig.esm.json b/experimental/packages/sdk-logs/tsconfig.esm.json index 131e715221e..e535b844319 100644 --- a/experimental/packages/sdk-logs/tsconfig.esm.json +++ b/experimental/packages/sdk-logs/tsconfig.esm.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../packages/sdk-metrics" + }, { "path": "../../../semantic-conventions" }, diff --git a/experimental/packages/sdk-logs/tsconfig.esnext.json b/experimental/packages/sdk-logs/tsconfig.esnext.json index 52e36313312..57d99c99191 100644 --- a/experimental/packages/sdk-logs/tsconfig.esnext.json +++ b/experimental/packages/sdk-logs/tsconfig.esnext.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../packages/sdk-metrics" + }, { "path": "../../../semantic-conventions" }, diff --git a/experimental/packages/sdk-logs/tsconfig.json b/experimental/packages/sdk-logs/tsconfig.json index 392e6603dcf..5e0a3328d09 100644 --- a/experimental/packages/sdk-logs/tsconfig.json +++ b/experimental/packages/sdk-logs/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../../packages/opentelemetry-resources" }, + { + "path": "../../../packages/sdk-metrics" + }, { "path": "../../../semantic-conventions" }, diff --git a/package-lock.json b/package-lock.json index 6f45e05d717..66b29f91d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1276,6 +1276,7 @@ "@babel/core": "7.27.1", "@babel/preset-env": "7.27.2", "@opentelemetry/api": ">=1.4.0 <1.10.0", + "@opentelemetry/sdk-metrics": "2.6.0", "@types/mocha": "10.0.10", "@types/node": "18.19.130", "@types/sinon": "17.0.4", @@ -5697,9 +5698,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5714,9 +5712,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5731,9 +5726,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5748,9 +5740,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [