diff --git a/CHANGELOG.md b/CHANGELOG.md index 602914230b3..95cd58a7527 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(otlp-exporter): implement exporter metrics [#6480](https://github.com/open-telemetry/opentelemetry-js/pull/6480) @anuraaga + ### :bug: Bug Fixes * fix(opentelemetry-instrumentation): improve `_warnOnPreloadedModules` function not to show warning logs when the module is not marked as loaded [#6095](https://github.com/open-telemetry/opentelemetry-js/pull/6095) @rlj1202 diff --git a/experimental/packages/exporter-logs-otlp-grpc/src/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-grpc/src/OTLPLogExporter.ts index f98dc3e76d7..c78b3576234 100644 --- a/experimental/packages/exporter-logs-otlp-grpc/src/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-grpc/src/OTLPLogExporter.ts @@ -12,8 +12,12 @@ import { convertLegacyOtlpGrpcOptions, createOtlpGrpcExportDelegate, } from '@opentelemetry/otlp-grpc-exporter-base'; -import { ProtobufLogsSerializer } from '@opentelemetry/otlp-transformer'; +import { + LogsSignal, + ProtobufLogsSerializer, +} from '@opentelemetry/otlp-transformer'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_LOG_EXPORTER } from './semconv'; /** * OTLP Logs Exporter for Node @@ -27,6 +31,9 @@ export class OTLPLogExporter createOtlpGrpcExportDelegate( convertLegacyOtlpGrpcOptions(config, 'LOGS'), ProtobufLogsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_LOG_EXPORTER, + LogsSignal, + config.meterProvider, 'LogsExportService', '/opentelemetry.proto.collector.logs.v1.LogsService/Export' ) diff --git a/experimental/packages/exporter-logs-otlp-grpc/src/semconv.ts b/experimental/packages/exporter-logs-otlp-grpc/src/semconv.ts new file mode 100644 index 00000000000..089ef2fe207 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-grpc/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_grpc_log_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP log record exporter over gRPC with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_LOG_EXPORTER = + 'otlp_grpc_log_exporter' as const; diff --git a/experimental/packages/exporter-logs-otlp-grpc/test/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-grpc/test/OTLPLogExporter.test.ts index c05a485aeeb..2f025f82d22 100644 --- a/experimental/packages/exporter-logs-otlp-grpc/test/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-grpc/test/OTLPLogExporter.test.ts @@ -7,9 +7,10 @@ import { LoggerProvider, SimpleLogRecordProcessor, } from '@opentelemetry/sdk-logs'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { OTLPLogExporter } from '../src'; import type { ServerTestContext } from './utils'; -import { startServer } from './utils'; +import { startServer, TestMetricReader } from './utils'; import * as assert from 'assert'; const testServiceDefinition = { @@ -68,11 +69,16 @@ describe('OTLPLogExporter', function () { it('successfully exports data', async () => { // arrange + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const loggerProvider = new LoggerProvider({ processors: [ new SimpleLogRecordProcessor( new OTLPLogExporter({ url: 'http://localhost:1503', + meterProvider, }) ), ], @@ -86,5 +92,12 @@ describe('OTLPLogExporter', function () { // assert assert.strictEqual(serverTestContext.requests.length, 1); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); diff --git a/experimental/packages/exporter-logs-otlp-grpc/test/utils.ts b/experimental/packages/exporter-logs-otlp-grpc/test/utils.ts index c1f09e0037d..235d642d5e4 100644 --- a/experimental/packages/exporter-logs-otlp-grpc/test/utils.ts +++ b/experimental/packages/exporter-logs-otlp-grpc/test/utils.ts @@ -5,6 +5,7 @@ import type { Metadata, ServiceDefinition } from '@grpc/grpc-js'; import { Server, ServerCredentials } from '@grpc/grpc-js'; +import { MetricReader } from '@opentelemetry/sdk-metrics'; export interface ExportedData { request: Buffer; @@ -55,3 +56,12 @@ export function startServer( ); }); } + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts index 13de72233be..dd9d07bafc2 100644 --- a/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts @@ -9,9 +9,14 @@ import type { } from '@opentelemetry/sdk-logs'; import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'; +import { + JsonLogsSerializer, + LogsSignal, +} from '@opentelemetry/otlp-transformer'; import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER } from '../../semconv'; + /** * Collector Logs Exporter for Web */ @@ -24,6 +29,9 @@ export class OTLPLogExporter createLegacyOtlpBrowserExportDelegate( config, JsonLogsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER, + LogsSignal, + config.meterProvider, 'v1/logs', { 'Content-Type': 'application/json' } ) diff --git a/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts index 391a98cb0b6..885527d9fbe 100644 --- a/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts @@ -9,12 +9,17 @@ import type { } from '@opentelemetry/sdk-logs'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'; +import { + JsonLogsSerializer, + LogsSignal, +} from '@opentelemetry/otlp-transformer'; import { convertLegacyHttpOptions, createOtlpHttpExportDelegate, } from '@opentelemetry/otlp-exporter-base/node-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER } from '../../semconv'; + /** * Collector Logs Exporter for Node */ @@ -28,7 +33,10 @@ export class OTLPLogExporter convertLegacyHttpOptions(config, 'LOGS', 'v1/logs', { 'Content-Type': 'application/json', }), - JsonLogsSerializer + JsonLogsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER, + LogsSignal, + config.meterProvider ) ); } diff --git a/experimental/packages/exporter-logs-otlp-http/src/semconv.ts b/experimental/packages/exporter-logs-otlp-http/src/semconv.ts new file mode 100644 index 00000000000..e413682a4a6 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-http/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_log_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP log record exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER = + 'otlp_http_log_exporter' as const; diff --git a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts index 6d7cd0422b0..6e1f563d60d 100644 --- a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts @@ -10,6 +10,8 @@ import { LoggerProvider, SimpleLogRecordProcessor, } from '@opentelemetry/sdk-logs'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -29,8 +31,14 @@ describe('OTLPLogExporter', function () { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + processors: [ + new SimpleLogRecordProcessor(new OTLPLogExporter({ meterProvider })), + ], }); // act @@ -44,6 +52,13 @@ describe('OTLPLogExporter', function () { () => JSON.parse(body), 'expected requestBody to be in JSON format, but parsing failed' ); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); }); diff --git a/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts index 44fd368a16c..12baa71fa63 100644 --- a/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts @@ -12,7 +12,9 @@ import { LoggerProvider, SimpleLogRecordProcessor, } from '@opentelemetry/sdk-logs'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { Stream } from 'stream'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -28,6 +30,11 @@ describe('OTLPLogExporter', () => { }); it('successfully exports data', done => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { value: function (_timeout: number) {}, @@ -35,12 +42,19 @@ describe('OTLPLogExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { const requestBody = buff.toString(); assert.doesNotThrow(() => { JSON.parse(requestBody); }, 'expected requestBody to be in JSON format, but parsing failed'); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + done(); } catch (e) { done(e); @@ -52,7 +66,9 @@ describe('OTLPLogExporter', () => { }); const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + processors: [ + new SimpleLogRecordProcessor(new OTLPLogExporter({ meterProvider })), + ], }); loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); diff --git a/experimental/packages/exporter-logs-otlp-http/test/utils.ts b/experimental/packages/exporter-logs-otlp-http/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-http/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/exporter-logs-otlp-proto/src/platform/browser/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-proto/src/platform/browser/OTLPLogExporter.ts index 545ca5f16ad..6dcae8076a6 100644 --- a/experimental/packages/exporter-logs-otlp-proto/src/platform/browser/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-proto/src/platform/browser/OTLPLogExporter.ts @@ -5,7 +5,10 @@ import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufLogsSerializer } from '@opentelemetry/otlp-transformer'; +import { + LogsSignal, + ProtobufLogsSerializer, +} from '@opentelemetry/otlp-transformer'; import type { ReadableLogRecord, @@ -13,6 +16,8 @@ import type { } from '@opentelemetry/sdk-logs'; import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER } from '../../semconv'; + /** * Collector Trace Exporter for Web */ @@ -25,6 +30,9 @@ export class OTLPLogExporter createLegacyOtlpBrowserExportDelegate( config, ProtobufLogsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER, + LogsSignal, + config.meterProvider, 'v1/logs', { 'Content-Type': 'application/x-protobuf' } ) diff --git a/experimental/packages/exporter-logs-otlp-proto/src/platform/node/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-proto/src/platform/node/OTLPLogExporter.ts index b6c808ddaca..4fb1259c7c5 100644 --- a/experimental/packages/exporter-logs-otlp-proto/src/platform/node/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-proto/src/platform/node/OTLPLogExporter.ts @@ -5,7 +5,10 @@ import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufLogsSerializer } from '@opentelemetry/otlp-transformer'; +import { + LogsSignal, + ProtobufLogsSerializer, +} from '@opentelemetry/otlp-transformer'; import { convertLegacyHttpOptions, createOtlpHttpExportDelegate, @@ -15,6 +18,8 @@ import type { LogRecordExporter, } from '@opentelemetry/sdk-logs'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER } from '../../semconv'; + /** * OTLP Log Protobuf Exporter for Node.js */ @@ -28,7 +33,10 @@ export class OTLPLogExporter convertLegacyHttpOptions(config, 'LOGS', 'v1/logs', { 'Content-Type': 'application/x-protobuf', }), - ProtobufLogsSerializer + ProtobufLogsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER, + LogsSignal, + config.meterProvider ) ); } diff --git a/experimental/packages/exporter-logs-otlp-proto/src/semconv.ts b/experimental/packages/exporter-logs-otlp-proto/src/semconv.ts new file mode 100644 index 00000000000..e413682a4a6 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-proto/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_log_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP log record exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_LOG_EXPORTER = + 'otlp_http_log_exporter' as const; diff --git a/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts index cb61e37ccc9..36c354efc77 100644 --- a/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts @@ -10,6 +10,8 @@ import { LoggerProvider, SimpleLogRecordProcessor, } from '@opentelemetry/sdk-logs'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -29,8 +31,14 @@ describe('OTLPLogExporter', function () { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + processors: [ + new SimpleLogRecordProcessor(new OTLPLogExporter({ meterProvider })), + ], }); // act @@ -44,6 +52,13 @@ describe('OTLPLogExporter', function () { () => JSON.parse(body), 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' ); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); }); diff --git a/experimental/packages/exporter-logs-otlp-proto/test/node/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-proto/test/node/OTLPLogExporter.test.ts index 88b5772850e..8d0bbfdb04b 100644 --- a/experimental/packages/exporter-logs-otlp-proto/test/node/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-proto/test/node/OTLPLogExporter.test.ts @@ -12,7 +12,9 @@ import { LoggerProvider, SimpleLogRecordProcessor, } from '@opentelemetry/sdk-logs'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { Stream } from 'stream'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -28,6 +30,11 @@ describe('OTLPLogExporter', () => { }); it('successfully exports data', done => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { value: function (_timeout: number) {}, @@ -35,12 +42,19 @@ describe('OTLPLogExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { const requestBody = buff.toString(); assert.throws(() => { JSON.parse(requestBody); }, 'expected requestBody to be in protobuf format, but parsing as JSON succeeded'); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + done(); } catch (e) { done(e); @@ -52,7 +66,9 @@ describe('OTLPLogExporter', () => { }); const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + processors: [ + new SimpleLogRecordProcessor(new OTLPLogExporter({ meterProvider })), + ], }); loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); diff --git a/experimental/packages/exporter-logs-otlp-proto/test/utils.ts b/experimental/packages/exporter-logs-otlp-proto/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-proto/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts index 93357785ecc..7c0bd69452d 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts @@ -9,9 +9,14 @@ import { convertLegacyOtlpGrpcOptions, createOtlpGrpcExportDelegate, } from '@opentelemetry/otlp-grpc-exporter-base'; -import { ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { + ProtobufTraceSerializer, + TraceSignal, +} from '@opentelemetry/otlp-transformer'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_SPAN_EXPORTER } from './semconv'; + /** * OTLP Trace Exporter for Node */ @@ -24,6 +29,9 @@ export class OTLPTraceExporter createOtlpGrpcExportDelegate( convertLegacyOtlpGrpcOptions(config, 'TRACES'), ProtobufTraceSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_SPAN_EXPORTER, + TraceSignal, + config.meterProvider, 'TraceExportService', '/opentelemetry.proto.collector.trace.v1.TraceService/Export' ) diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/semconv.ts b/experimental/packages/exporter-trace-otlp-grpc/src/semconv.ts new file mode 100644 index 00000000000..45e99cf0435 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_grpc_span_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP span exporter over gRPC with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_SPAN_EXPORTER = + 'otlp_grpc_span_exporter' as const; diff --git a/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts index fcba08fd353..772d273d1dd 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts @@ -5,8 +5,9 @@ import { OTLPTraceExporter } from '../src'; import type { ServerTestContext } from './utils'; -import { startServer } from './utils'; +import { startServer, TestMetricReader } from './utils'; import * as assert from 'assert'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { SimpleSpanProcessor, BasicTracerProvider, @@ -68,11 +69,16 @@ describe('OTLPTraceExporter', function () { it('successfully exports data', async () => { // arrange + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const tracerProvider = new BasicTracerProvider({ spanProcessors: [ new SimpleSpanProcessor( new OTLPTraceExporter({ url: 'http://localhost:1501', + meterProvider, }) ), ], @@ -84,5 +90,12 @@ describe('OTLPTraceExporter', function () { // assert assert.strictEqual(serverTestContext.requests.length, 1); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); diff --git a/experimental/packages/exporter-trace-otlp-grpc/test/utils.ts b/experimental/packages/exporter-trace-otlp-grpc/test/utils.ts index c1f09e0037d..235d642d5e4 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/test/utils.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/test/utils.ts @@ -5,6 +5,7 @@ import type { Metadata, ServiceDefinition } from '@grpc/grpc-js'; import { Server, ServerCredentials } from '@grpc/grpc-js'; +import { MetricReader } from '@opentelemetry/sdk-metrics'; export interface ExportedData { request: Buffer; @@ -55,3 +56,12 @@ export function startServer( ); }); } + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/exporter-trace-otlp-http/src/platform/browser/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-http/src/platform/browser/OTLPTraceExporter.ts index 89965572c9f..ab39d18476f 100644 --- a/experimental/packages/exporter-trace-otlp-http/src/platform/browser/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-http/src/platform/browser/OTLPTraceExporter.ts @@ -6,9 +6,14 @@ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { + JsonTraceSerializer, + TraceSignal, +} from '@opentelemetry/otlp-transformer'; import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER } from '../../semconv'; + /** * Collector Trace Exporter for Web */ @@ -21,6 +26,9 @@ export class OTLPTraceExporter createLegacyOtlpBrowserExportDelegate( config, JsonTraceSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER, + TraceSignal, + config.meterProvider, 'v1/traces', { 'Content-Type': 'application/json' } ) diff --git a/experimental/packages/exporter-trace-otlp-http/src/platform/node/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-http/src/platform/node/OTLPTraceExporter.ts index 72bf19940be..313cef32de8 100644 --- a/experimental/packages/exporter-trace-otlp-http/src/platform/node/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-http/src/platform/node/OTLPTraceExporter.ts @@ -6,11 +6,15 @@ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { + JsonTraceSerializer, + TraceSignal, +} from '@opentelemetry/otlp-transformer'; import { convertLegacyHttpOptions, createOtlpHttpExportDelegate, } from '@opentelemetry/otlp-exporter-base/node-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER } from '../../semconv'; /** * Collector Trace Exporter for Node @@ -25,7 +29,10 @@ export class OTLPTraceExporter convertLegacyHttpOptions(config, 'TRACES', 'v1/traces', { 'Content-Type': 'application/json', }), - JsonTraceSerializer + JsonTraceSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER, + TraceSignal, + config.meterProvider ) ); } diff --git a/experimental/packages/exporter-trace-otlp-http/src/semconv.ts b/experimental/packages/exporter-trace-otlp-http/src/semconv.ts new file mode 100644 index 00000000000..50ca0fc5da9 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-http/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_span_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP span exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER = + 'otlp_http_span_exporter' as const; diff --git a/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts index e2d37fe2a8e..72b49e895dc 100644 --- a/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts @@ -7,9 +7,11 @@ import { BasicTracerProvider, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import { OTLPTraceExporter } from '../../src/platform/browser/index'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -30,8 +32,14 @@ describe('OTLPTraceExporter', () => { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], + spanProcessors: [ + new SimpleSpanProcessor(new OTLPTraceExporter({ meterProvider })), + ], }); // act @@ -45,6 +53,13 @@ describe('OTLPTraceExporter', () => { () => JSON.parse(body), 'expected requestBody to be in JSON format, but parsing failed' ); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); }); diff --git a/experimental/packages/exporter-trace-otlp-http/test/node/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-http/test/node/OTLPTraceExporter.test.ts index fb7ded1d31d..8a659bb111f 100644 --- a/experimental/packages/exporter-trace-otlp-http/test/node/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-http/test/node/OTLPTraceExporter.test.ts @@ -12,7 +12,9 @@ import { BasicTracerProvider, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { OTLPTraceExporter } from '../../src/platform/node'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -28,6 +30,11 @@ describe('OTLPTraceExporter', () => { }); it('successfully exports data', done => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { value: function (_timeout: number) {}, @@ -35,12 +42,19 @@ describe('OTLPTraceExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { const requestBody = buff.toString(); assert.doesNotThrow(() => { JSON.parse(requestBody); }, 'expected requestBody to be in JSON format, but parsing failed'); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + done(); } catch (e) { done(e); @@ -51,12 +65,13 @@ describe('OTLPTraceExporter', () => { buff = Buffer.concat([buff, chunk]); }); - new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }) - .getTracer('test-tracer') - .startSpan('test-span') - .end(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [ + new SimpleSpanProcessor(new OTLPTraceExporter({ meterProvider })), + ], + }); + + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); }); }); }); diff --git a/experimental/packages/exporter-trace-otlp-http/test/utils.ts b/experimental/packages/exporter-trace-otlp-http/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-http/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/exporter-trace-otlp-proto/src/platform/browser/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-proto/src/platform/browser/OTLPTraceExporter.ts index bfdfd0ffbaf..5b1b21230f3 100644 --- a/experimental/packages/exporter-trace-otlp-proto/src/platform/browser/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-proto/src/platform/browser/OTLPTraceExporter.ts @@ -6,8 +6,12 @@ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { + ProtobufTraceSerializer, + TraceSignal, +} from '@opentelemetry/otlp-transformer'; import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER } from '../../semconv'; const DEFAULT_COLLECTOR_RESOURCE_PATH = 'v1/traces'; @@ -23,6 +27,9 @@ export class OTLPTraceExporter createLegacyOtlpBrowserExportDelegate( config, ProtobufTraceSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER, + TraceSignal, + config.meterProvider, DEFAULT_COLLECTOR_RESOURCE_PATH, { 'Content-Type': 'application/x-protobuf' } ) diff --git a/experimental/packages/exporter-trace-otlp-proto/src/platform/node/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-proto/src/platform/node/OTLPTraceExporter.ts index ccff53c7c70..c40ada87a88 100644 --- a/experimental/packages/exporter-trace-otlp-proto/src/platform/node/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-proto/src/platform/node/OTLPTraceExporter.ts @@ -6,11 +6,15 @@ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { + ProtobufTraceSerializer, + TraceSignal, +} from '@opentelemetry/otlp-transformer'; import { createOtlpHttpExportDelegate, convertLegacyHttpOptions, } from '@opentelemetry/otlp-exporter-base/node-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER } from '../../semconv'; /** * Collector Trace Exporter for Node with protobuf @@ -25,7 +29,10 @@ export class OTLPTraceExporter convertLegacyHttpOptions(config, 'TRACES', 'v1/traces', { 'Content-Type': 'application/x-protobuf', }), - ProtobufTraceSerializer + ProtobufTraceSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER, + TraceSignal, + config.meterProvider ) ); } diff --git a/experimental/packages/exporter-trace-otlp-proto/src/semconv.ts b/experimental/packages/exporter-trace-otlp-proto/src/semconv.ts new file mode 100644 index 00000000000..50ca0fc5da9 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-proto/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_span_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP span exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_SPAN_EXPORTER = + 'otlp_http_span_exporter' as const; diff --git a/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts index eca84e85950..7b219350ad6 100644 --- a/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts @@ -7,9 +7,11 @@ import { BasicTracerProvider, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; import * as sinon from 'sinon'; import { OTLPTraceExporter } from '../../src/platform/browser/index'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -30,8 +32,14 @@ describe('OTLPTraceExporter', () => { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], + spanProcessors: [ + new SimpleSpanProcessor(new OTLPTraceExporter({ meterProvider })), + ], }); // act @@ -45,6 +53,13 @@ describe('OTLPTraceExporter', () => { () => JSON.parse(body), 'expected request body to be in protobuf format, but parsing as JSON succeeded' ); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); }); diff --git a/experimental/packages/exporter-trace-otlp-proto/test/node/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-proto/test/node/OTLPTraceExporter.test.ts index a36d950a9c2..44f389342c0 100644 --- a/experimental/packages/exporter-trace-otlp-proto/test/node/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-proto/test/node/OTLPTraceExporter.test.ts @@ -12,7 +12,9 @@ import { BasicTracerProvider, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { OTLPTraceExporter } from '../../src/platform/node'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -28,6 +30,11 @@ describe('OTLPTraceExporter', () => { }); it('successfully exports data', done => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { value: function (_timeout: number) {}, @@ -35,12 +42,19 @@ describe('OTLPTraceExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { const requestBody = buff.toString(); assert.throws(() => { JSON.parse(requestBody); }, 'expected requestBody to be in protobuf format, but parsing as JSON succeeded'); + + const metrics = await metricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + done(); } catch (e) { done(e); @@ -51,12 +65,13 @@ describe('OTLPTraceExporter', () => { buff = Buffer.concat([buff, chunk]); }); - new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }) - .getTracer('test-tracer') - .startSpan('test-span') - .end(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [ + new SimpleSpanProcessor(new OTLPTraceExporter({ meterProvider })), + ], + }); + + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); }); }); }); diff --git a/experimental/packages/exporter-trace-otlp-proto/test/utils.ts b/experimental/packages/exporter-trace-otlp-proto/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-proto/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/OTLPMetricExporter.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/OTLPMetricExporter.ts index d603f1006bb..ebe6dd145df 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/OTLPMetricExporter.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/OTLPMetricExporter.ts @@ -9,22 +9,48 @@ import type { OTLPGRPCExporterConfigNode } from '@opentelemetry/otlp-grpc-export import { convertLegacyOtlpGrpcOptions, createOtlpGrpcExportDelegate, + createOtlpGrpcExporterMetrics, } from '@opentelemetry/otlp-grpc-exporter-base'; -import { ProtobufMetricsSerializer } from '@opentelemetry/otlp-transformer'; +import { + MetricsSignal, + ProtobufMetricsSerializer, +} from '@opentelemetry/otlp-transformer'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_METRIC_EXPORTER } from './semconv'; +import type { MeterProvider } from '@opentelemetry/api'; /** * OTLP-gRPC metric exporter */ export class OTLPMetricExporter extends OTLPMetricExporterBase { + private readonly _url: string | undefined; constructor(config?: OTLPGRPCExporterConfigNode & OTLPMetricExporterOptions) { super( createOtlpGrpcExportDelegate( convertLegacyOtlpGrpcOptions(config ?? {}, 'METRICS'), ProtobufMetricsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_METRIC_EXPORTER, + MetricsSignal, + config?.meterProvider, 'MetricsExportService', '/opentelemetry.proto.collector.metrics.v1.MetricsService/Export' ), config ); + this._url = config?.url; + } + + /** + * Sets the meter provider to use to collect metrics for this exporter. + * @experimental This method is experimental and is subject to breaking changes in minor releases. + */ + setMeterProvider(meterProvider: MeterProvider) { + this.setMetrics( + createOtlpGrpcExporterMetrics( + OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_METRIC_EXPORTER, + MetricsSignal, + this._url, + meterProvider + ) + ); } } diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/semconv.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/semconv.ts new file mode 100644 index 00000000000..51a5dc7d0f7 --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_grpc_metric_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP metric exporter over gRPC with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_GRPC_METRIC_EXPORTER = + 'otlp_grpc_metric_exporter' as const; diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/OTLPMetricExporter.test.ts index 98a37e4e31e..e016fc0b0fd 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/OTLPMetricExporter.test.ts @@ -5,7 +5,7 @@ import { OTLPMetricExporter } from '../src'; import type { ServerTestContext } from './utils'; -import { startServer } from './utils'; +import { startServer, TestMetricReader } from './utils'; import * as assert from 'assert'; import { MeterProvider, @@ -68,19 +68,30 @@ describe('OTLPMetricsExporter', function () { it('successfully exports data', async () => { // arrange + const testMetricReader = new TestMetricReader(); + const exporter = new OTLPMetricExporter({ url: 'http://localhost:1502' }); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ url: 'http://localhost:1502' }), + exporter, }), + testMetricReader, ], }); + exporter.setMeterProvider(meterProvider); // act meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); - await meterProvider.shutdown(); + await meterProvider.forceFlush(); // assert assert.strictEqual(serverTestContext.requests.length, 1); + + const metrics = await testMetricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/utils.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/utils.ts index c1f09e0037d..235d642d5e4 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/utils.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-grpc/test/utils.ts @@ -5,6 +5,7 @@ import type { Metadata, ServiceDefinition } from '@grpc/grpc-js'; import { Server, ServerCredentials } from '@grpc/grpc-js'; +import { MetricReader } from '@opentelemetry/sdk-metrics'; export interface ExportedData { request: Buffer; @@ -55,3 +56,12 @@ export function startServer( ); }); } + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/browser/OTLPMetricExporter.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/browser/OTLPMetricExporter.ts index a3fa52c62eb..f43c24f3a91 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/browser/OTLPMetricExporter.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/browser/OTLPMetricExporter.ts @@ -3,25 +3,54 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type MeterProvider } from '@opentelemetry/api'; import type { OTLPMetricExporterOptions } from '../../OTLPMetricExporterOptions'; import { OTLPMetricExporterBase } from '../../OTLPMetricExporterBase'; import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonMetricsSerializer } from '@opentelemetry/otlp-transformer'; -import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { + JsonMetricsSerializer, + MetricsSignal, +} from '@opentelemetry/otlp-transformer'; +import { + createLegacyOtlpBrowserExportDelegate, + createLegacyOtlpBrowserExporterMetrics, +} from '@opentelemetry/otlp-exporter-base/browser-http'; + +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER } from '../../semconv'; /** * Collector Metric Exporter for Web */ export class OTLPMetricExporter extends OTLPMetricExporterBase { + private readonly _url: string | undefined; constructor(config?: OTLPExporterConfigBase & OTLPMetricExporterOptions) { super( createLegacyOtlpBrowserExportDelegate( config ?? {}, JsonMetricsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + config?.meterProvider, 'v1/metrics', { 'Content-Type': 'application/json' } ), config ); + this._url = config?.url; + } + + /** + * Sets the meter provider to use to collect metrics for this exporter. + * @experimental This method is experimental and is subject to breaking changes in minor releases. + */ + setMeterProvider(meterProvider: MeterProvider) { + this.setMetrics( + createLegacyOtlpBrowserExporterMetrics( + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + this._url, + meterProvider + ) + ); } } diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/node/OTLPMetricExporter.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/node/OTLPMetricExporter.ts index d354fca03af..02b656b7ad6 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/node/OTLPMetricExporter.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/platform/node/OTLPMetricExporter.ts @@ -3,28 +3,55 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type MeterProvider } from '@opentelemetry/api'; import type { OTLPMetricExporterOptions } from '../../OTLPMetricExporterOptions'; import { OTLPMetricExporterBase } from '../../OTLPMetricExporterBase'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; -import { JsonMetricsSerializer } from '@opentelemetry/otlp-transformer'; +import { + JsonMetricsSerializer, + MetricsSignal, +} from '@opentelemetry/otlp-transformer'; import { convertLegacyHttpOptions, createOtlpHttpExportDelegate, + createOtlpHttpExporterMetrics, } from '@opentelemetry/otlp-exporter-base/node-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER } from '../../semconv'; + /** * OTLP Metric Exporter for Node.js */ export class OTLPMetricExporter extends OTLPMetricExporterBase { + private readonly _url: string | undefined; constructor(config?: OTLPExporterNodeConfigBase & OTLPMetricExporterOptions) { super( createOtlpHttpExportDelegate( convertLegacyHttpOptions(config ?? {}, 'METRICS', 'v1/metrics', { 'Content-Type': 'application/json', }), - JsonMetricsSerializer + JsonMetricsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + config?.meterProvider ), config ); + this._url = config?.url; + } + + /** + * Sets the meter provider to use to collect metrics for this exporter. + * @experimental This method is experimental and is subject to breaking changes in minor releases. + */ + setMeterProvider(meterProvider: MeterProvider) { + this.setMetrics( + createOtlpHttpExporterMetrics( + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + this._url, + meterProvider + ) + ); } } diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/semconv.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/semconv.ts new file mode 100644 index 00000000000..53fb63ecefa --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_metric_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP metric exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER = + 'otlp_http_metric_exporter' as const; diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts index 3d4b13e0248..ae36d57ce44 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts @@ -15,6 +15,7 @@ import { import * as assert from 'assert'; import * as sinon from 'sinon'; import { OTLPMetricExporter } from '../../src/platform/browser'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -34,17 +35,21 @@ describe('OTLPMetricExporter', function () { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const testMetricReader = new TestMetricReader(); + const exporter = new OTLPMetricExporter(); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), + exporter, }), + testMetricReader, ], }); + exporter.setMeterProvider(meterProvider); // act meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); - await meterProvider.shutdown(); + await meterProvider.forceFlush(); // assert const request = new Request(...stubFetch.args[0]); @@ -53,6 +58,13 @@ describe('OTLPMetricExporter', function () { () => JSON.parse(body), 'expected request body to be in JSON format, but parsing failed' ); + + const metrics = await testMetricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/node/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/node/OTLPMetricExporter.test.ts index d7faf7b28d8..5685f0bff2a 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/node/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/node/OTLPMetricExporter.test.ts @@ -18,6 +18,7 @@ import { PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; import { Stream } from 'stream'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -175,6 +176,7 @@ describe('OTLPMetricExporter', () => { }); it('successfully exports data', function (done) { + const testMetricReader = new TestMetricReader(); // arrange const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { @@ -183,13 +185,21 @@ describe('OTLPMetricExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { // assert const requestBody = buff.toString(); assert.doesNotThrow(() => { JSON.parse(requestBody); }, 'expected requestBody to be in JSON format, but parsing failed'); + + const metrics = await testMetricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + meterProvider.shutdown(); + done(); } catch (e) { done(e); @@ -200,17 +210,20 @@ describe('OTLPMetricExporter', () => { buff = Buffer.concat([buff, chunk]); }); + const exporter = new OTLPMetricExporter(); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), + exporter, }), + testMetricReader, ], }); + exporter.setMeterProvider(meterProvider); meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); // act - meterProvider.shutdown(); + meterProvider.forceFlush(); }); }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/utils.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/browser/OTLPMetricExporter.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/browser/OTLPMetricExporter.ts index ec9e7a2000b..32e0fe8bdb1 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/browser/OTLPMetricExporter.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/browser/OTLPMetricExporter.ts @@ -3,13 +3,23 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type MeterProvider } from '@opentelemetry/api'; import type { OTLPMetricExporterOptions } from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPMetricExporterBase } from '@opentelemetry/exporter-metrics-otlp-http'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufMetricsSerializer } from '@opentelemetry/otlp-transformer'; -import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'; +import { + MetricsSignal, + ProtobufMetricsSerializer, +} from '@opentelemetry/otlp-transformer'; +import { + createLegacyOtlpBrowserExportDelegate, + createLegacyOtlpBrowserExporterMetrics, +} from '@opentelemetry/otlp-exporter-base/browser-http'; + +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER } from '../../semconv'; export class OTLPMetricExporter extends OTLPMetricExporterBase { + private readonly _url: string | undefined; constructor( config: OTLPExporterNodeConfigBase & OTLPMetricExporterOptions = {} ) { @@ -17,10 +27,29 @@ export class OTLPMetricExporter extends OTLPMetricExporterBase { createLegacyOtlpBrowserExportDelegate( config, ProtobufMetricsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + config?.meterProvider, 'v1/metrics', { 'Content-Type': 'application/x-protobuf' } ), config ); + this._url = config.url; + } + + /** + * Sets the meter provider to use to collect metrics for this exporter. + * @experimental This method is experimental and is subject to breaking changes in minor releases. + */ + setMeterProvider(meterProvider: MeterProvider) { + this.setMetrics( + createLegacyOtlpBrowserExporterMetrics( + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + this._url, + meterProvider + ) + ); } } diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/node/OTLPMetricExporter.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/node/OTLPMetricExporter.ts index 992e98df78a..e2c31fb8993 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/node/OTLPMetricExporter.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/platform/node/OTLPMetricExporter.ts @@ -3,25 +3,52 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type MeterProvider } from '@opentelemetry/api'; import type { OTLPMetricExporterOptions } from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPMetricExporterBase } from '@opentelemetry/exporter-metrics-otlp-http'; import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; -import { ProtobufMetricsSerializer } from '@opentelemetry/otlp-transformer'; +import { + MetricsSignal, + ProtobufMetricsSerializer, +} from '@opentelemetry/otlp-transformer'; import { convertLegacyHttpOptions, createOtlpHttpExportDelegate, + createOtlpHttpExporterMetrics, } from '@opentelemetry/otlp-exporter-base/node-http'; +import { OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER } from '../../semconv'; + export class OTLPMetricExporter extends OTLPMetricExporterBase { + private readonly _url: string | undefined; constructor(config?: OTLPExporterNodeConfigBase & OTLPMetricExporterOptions) { super( createOtlpHttpExportDelegate( convertLegacyHttpOptions(config ?? {}, 'METRICS', 'v1/metrics', { 'Content-Type': 'application/x-protobuf', }), - ProtobufMetricsSerializer + ProtobufMetricsSerializer, + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + config?.meterProvider ), config ); + this._url = config?.url; + } + + /** + * Sets the meter provider to use to collect metrics for this exporter. + * @experimental This method is experimental and is subject to breaking changes in minor releases. + */ + setMeterProvider(meterProvider: MeterProvider) { + this.setMetrics( + createOtlpHttpExporterMetrics( + OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER, + MetricsSignal, + this._url, + meterProvider + ) + ); } } diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/semconv.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/semconv.ts new file mode 100644 index 00000000000..53fb63ecefa --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * 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 + */ + +/** + * Enum value "otlp_http_metric_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * OTLP metric exporter over HTTP with protobuf serialization + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_OTLP_HTTP_METRIC_EXPORTER = + 'otlp_http_metric_exporter' as const; diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts index 7a93ad4437f..0b752c86109 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts @@ -10,6 +10,7 @@ import { import * as assert from 'assert'; import * as sinon from 'sinon'; import { OTLPMetricExporter } from '../../src/platform/browser'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -30,18 +31,22 @@ describe('OTLPMetricExporter', () => { const stubFetch = sinon .stub(window, 'fetch') .resolves(new Response('', { status: 200 })); + const testMetricReader = new TestMetricReader(); + const exporter = new OTLPMetricExporter(); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), + exporter, }), + testMetricReader, ], }); + exporter.setMeterProvider(meterProvider); // act meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); - await meterProvider.shutdown(); + await meterProvider.forceFlush(); // assert const request = new Request(...stubFetch.args[0]); @@ -50,6 +55,13 @@ describe('OTLPMetricExporter', () => { () => JSON.parse(body), 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' ); + + const metrics = await testMetricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + await meterProvider.shutdown(); }); }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/node/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/node/OTLPMetricExporter.test.ts index b4f659eac1d..b2ac9ad3578 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/node/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/node/OTLPMetricExporter.test.ts @@ -13,6 +13,7 @@ import { PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; import { Stream } from 'stream'; +import { TestMetricReader } from '../utils'; /* * NOTE: Tests here are not intended to test the underlying components directly. They are intended as a quick @@ -28,6 +29,7 @@ describe('OTLPMetricExporter', () => { }); it('successfully exports data', function (done) { + const testMetricReader = new TestMetricReader(); // arrange const fakeRequest = new Stream.PassThrough(); Object.defineProperty(fakeRequest, 'setTimeout', { @@ -36,13 +38,21 @@ describe('OTLPMetricExporter', () => { sinon.stub(http, 'request').returns(fakeRequest as any); let buff = Buffer.from(''); - fakeRequest.on('finish', () => { + fakeRequest.on('finish', async () => { try { // assert const requestBody = buff.toString(); assert.throws(() => { JSON.parse(requestBody); }, 'expected requestBody to be in protobuf format, but parsing as JSON succeeded'); + + const metrics = await testMetricReader.collect(); + const scopeMetrics = metrics.resourceMetrics.scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/otlp-exporter' + ); + assert.ok(scopeMetrics); + meterProvider.shutdown(); + done(); } catch (e) { done(e); @@ -53,17 +63,20 @@ describe('OTLPMetricExporter', () => { buff = Buffer.concat([buff, chunk]); }); + const exporter = new OTLPMetricExporter(); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), + exporter, }), + testMetricReader, ], }); + exporter.setMeterProvider(meterProvider); meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); // act - meterProvider.shutdown(); + meterProvider.forceFlush(); }); }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/utils.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/utils.ts new file mode 100644 index 00000000000..2c7dd435799 --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MetricReader } from '@opentelemetry/sdk-metrics'; + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/otlp-exporter-base/src/ExporterMetrics.ts b/experimental/packages/otlp-exporter-base/src/ExporterMetrics.ts new file mode 100644 index 00000000000..7c2ce13ae61 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/ExporterMetrics.ts @@ -0,0 +1,145 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type Attributes, + type Counter, + type Histogram, + type MeterProvider, + type UpDownCounter, + createNoopMeter, +} from '@opentelemetry/api'; +import { + hrTime, + hrTimeDuration, + hrTimeToMilliseconds, +} from '@opentelemetry/core'; +import { + ATTR_ERROR_TYPE, + ATTR_OTEL_COMPONENT_NAME, + ATTR_OTEL_COMPONENT_TYPE, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from './semconv'; +import { VERSION } from './version'; + +const componentCounter = new Map(); + +export interface ExporterMetricsOptions { + componentType: string; + signal: IExporterSignal; + url: string | undefined; + meterProvider: MeterProvider | undefined; + errorAttributes: (error: unknown) => Attributes; +} + +export interface IExporterSignal { + name: 'span' | 'metric_data_point' | 'log'; + countItems: (request: Internal) => number; +} + +/** + * Generates `otel.sdk.exporter.*` metrics. + * https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics + */ +export class ExporterMetrics { + private readonly inflight: UpDownCounter; + private readonly exported: Counter; + private readonly duration: Histogram; + private readonly standardAttrs: Attributes; + private readonly errorAttributes: (error: unknown) => Attributes; + + private readonly signal: IExporterSignal; + + constructor(options: ExporterMetricsOptions) { + const { componentType, signal, meterProvider, url, errorAttributes } = + options; + this.errorAttributes = errorAttributes; + const meter = meterProvider + ? meterProvider.getMeter('@opentelemetry/otlp-exporter', VERSION) + : createNoopMeter(); + + const counter = componentCounter.get(componentType) ?? 0; + componentCounter.set(componentType, counter + 1); + + this.standardAttrs = { + [ATTR_OTEL_COMPONENT_TYPE]: componentType, + [ATTR_OTEL_COMPONENT_NAME]: `${componentType}/${counter}`, + }; + if (url) { + if (url.includes('://')) { + const parsedUrl = new URL(url); + this.standardAttrs[ATTR_SERVER_ADDRESS] = parsedUrl.hostname; + let port: number | undefined = undefined; + if (parsedUrl.port) { + port = Number(parsedUrl.port); + } else if (parsedUrl.protocol === 'http:') { + port = 80; + } else if (parsedUrl.protocol === 'https:') { + port = 443; + } + this.standardAttrs[ATTR_SERVER_PORT] = port; + } else { + const parts = url.split(':'); + this.standardAttrs[ATTR_SERVER_ADDRESS] = parts[0]; + if (parts[1]) { + this.standardAttrs[ATTR_SERVER_PORT] = Number(parts[1]); + } + } + } + + this.signal = signal; + + this.inflight = meter.createUpDownCounter( + `otel.sdk.exporter.${signal.name}.inflight`, + { + unit: `{${signal.name}}`, + description: `The number of ${signal.name}s which were passed to the exporter, but that have not been exported yet (neither successful, nor failed).`, + } + ); + this.exported = meter.createCounter( + `otel.sdk.exporter.${signal.name}.exported`, + { + unit: `{${signal.name}}`, + description: `The number of ${signal.name}s for which the export has finished, either successful or failed.`, + } + ); + this.duration = meter.createHistogram( + 'otel.sdk.exporter.operation.duration', + { + unit: 's', + description: 'The duration of exporting a batch of telemetry records.', + advice: { + explicitBucketBoundaries: [], + }, + } + ); + } + + startExport(request: Internal): (error: unknown) => void { + const numItems = this.signal.countItems(request); + const startTime = hrTime(); + this.inflight.add(numItems, this.standardAttrs); + return (error: unknown) => { + const endTime = hrTime(); + this.inflight.add(-numItems, this.standardAttrs); + const exportedAttrs = error + ? { + ...this.standardAttrs, + [ATTR_ERROR_TYPE]: + error instanceof Error ? error.name : 'export_failed', + } + : this.standardAttrs; + this.exported.add(numItems, exportedAttrs); + const durationAttrs = { + ...exportedAttrs, + ...this.errorAttributes(error), + }; + const duration = + hrTimeToMilliseconds(hrTimeDuration(startTime, endTime)) / 1000; + this.duration.record(duration, durationAttrs); + }; + } +} diff --git a/experimental/packages/otlp-exporter-base/src/OTLPExporterBase.ts b/experimental/packages/otlp-exporter-base/src/OTLPExporterBase.ts index 082360c02d2..3feb9f67df0 100644 --- a/experimental/packages/otlp-exporter-base/src/OTLPExporterBase.ts +++ b/experimental/packages/otlp-exporter-base/src/OTLPExporterBase.ts @@ -5,6 +5,7 @@ import type { ExportResult } from '@opentelemetry/core'; import type { IOtlpExportDelegate } from './otlp-export-delegate'; +import { type ExporterMetrics } from './ExporterMetrics'; export class OTLPExporterBase { private _delegate: IOtlpExportDelegate; @@ -31,4 +32,8 @@ export class OTLPExporterBase { shutdown(): Promise { return this._delegate.shutdown(); } + + protected setMetrics(metrics: ExporterMetrics) { + this._delegate.setMetrics(metrics); + } } diff --git a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts index 69ee0940011..93e2ea07eed 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts @@ -2,11 +2,50 @@ * Copyright The OpenTelemetry Authors * SPDX-License-Identifier: Apache-2.0 */ + +import type { MeterProvider } from '@opentelemetry/api'; import type { ISerializer } from '@opentelemetry/otlp-transformer'; import { createOtlpFetchExportDelegate } from '../otlp-browser-http-export-delegate'; import { convertLegacyBrowserHttpOptions } from './convert-legacy-browser-http-options'; import type { IOtlpExportDelegate } from '../otlp-export-delegate'; import type { OTLPExporterConfigBase } from './legacy-base-configuration'; +import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '../semconv'; +import { ExporterMetrics, type IExporterSignal } from '../ExporterMetrics'; + +/** + * @deprecated + */ +export function createLegacyOtlpBrowserExporterMetrics( + metricsComponentType: string, + signal: IExporterSignal, + url: string | undefined, + meterProvider: MeterProvider | undefined +): ExporterMetrics { + return new ExporterMetrics({ + componentType: metricsComponentType, + signal, + url, + meterProvider, + errorAttributes: (error: unknown) => { + if (!(error instanceof Error)) { + return {}; + } + if ( + error.message.startsWith( + 'Fetch request failed with non-retryable status ' + ) + ) { + const statusStr = error.message.substring( + 'Fetch request failed with non-retryable status '.length + ); + return { + [ATTR_HTTP_RESPONSE_STATUS_CODE]: Number(statusStr), + }; + } + return {}; + }, + }); +} /** * @deprecated @@ -18,6 +57,9 @@ import type { OTLPExporterConfigBase } from './legacy-base-configuration'; export function createLegacyOtlpBrowserExportDelegate( config: OTLPExporterConfigBase, serializer: ISerializer, + metricsComponentType: string, + signal: IExporterSignal, + meterProvider: MeterProvider | undefined, signalResourcePath: string, requiredHeaders: Record ): IOtlpExportDelegate { @@ -27,5 +69,14 @@ export function createLegacyOtlpBrowserExportDelegate( requiredHeaders ); - return createOtlpFetchExportDelegate(options, serializer); + return createOtlpFetchExportDelegate( + options, + serializer, + createLegacyOtlpBrowserExporterMetrics( + metricsComponentType, + signal, + options.url, + config.meterProvider + ) + ); } diff --git a/experimental/packages/otlp-exporter-base/src/configuration/legacy-base-configuration.ts b/experimental/packages/otlp-exporter-base/src/configuration/legacy-base-configuration.ts index 65eab904173..f43b5ad4117 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/legacy-base-configuration.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/legacy-base-configuration.ts @@ -2,6 +2,7 @@ * Copyright The OpenTelemetry Authors * SPDX-License-Identifier: Apache-2.0 */ +import type { MeterProvider } from '@opentelemetry/api'; import type { HeadersFactory } from './otlp-http-configuration'; export interface OTLPExporterConfigBase { @@ -35,4 +36,10 @@ export interface OTLPExporterConfigBase { /** Maximum time the OTLP exporter will wait for each batch export. * The default value is 10000ms. */ timeoutMillis?: number; + + /** + * MeterProvider to record exporter metrics. + * @experimental This option is experimental and is subject to breaking changes in minor releases. + */ + meterProvider?: MeterProvider; } diff --git a/experimental/packages/otlp-exporter-base/src/index-browser-http.ts b/experimental/packages/otlp-exporter-base/src/index-browser-http.ts index f2d1f296f26..f3247d0fab3 100644 --- a/experimental/packages/otlp-exporter-base/src/index-browser-http.ts +++ b/experimental/packages/otlp-exporter-base/src/index-browser-http.ts @@ -9,4 +9,7 @@ export { createOtlpSendBeaconExportDelegate } from './otlp-browser-http-export-delegate'; export { convertLegacyBrowserHttpOptions } from './configuration/convert-legacy-browser-http-options'; -export { createLegacyOtlpBrowserExportDelegate } from './configuration/create-legacy-browser-delegate'; +export { + createLegacyOtlpBrowserExportDelegate, + createLegacyOtlpBrowserExporterMetrics, +} from './configuration/create-legacy-browser-delegate'; diff --git a/experimental/packages/otlp-exporter-base/src/index-node-http.ts b/experimental/packages/otlp-exporter-base/src/index-node-http.ts index c0224852ed6..0e3afb6d22f 100644 --- a/experimental/packages/otlp-exporter-base/src/index-node-http.ts +++ b/experimental/packages/otlp-exporter-base/src/index-node-http.ts @@ -4,6 +4,9 @@ */ export { httpAgentFactoryFromOptions } from './configuration/otlp-node-http-configuration'; -export { createOtlpHttpExportDelegate } from './otlp-http-export-delegate'; +export { + createOtlpHttpExportDelegate, + createOtlpHttpExporterMetrics, +} from './otlp-http-export-delegate'; export { getSharedConfigurationFromEnvironment } from './configuration/shared-env-configuration'; export { convertLegacyHttpOptions } from './configuration/convert-legacy-node-http-options'; diff --git a/experimental/packages/otlp-exporter-base/src/index.ts b/experimental/packages/otlp-exporter-base/src/index.ts index f994a2da936..a236320d8e5 100644 --- a/experimental/packages/otlp-exporter-base/src/index.ts +++ b/experimental/packages/otlp-exporter-base/src/index.ts @@ -26,3 +26,5 @@ export type { OTLPExporterNodeConfigBase } from './configuration/legacy-node-con export type { OTLPExporterConfigBase } from './configuration/legacy-base-configuration'; export type { IOtlpExportDelegate } from './otlp-export-delegate'; export { createOtlpNetworkExportDelegate } from './otlp-network-export-delegate'; + +export { ExporterMetrics, type IExporterSignal } from './ExporterMetrics'; diff --git a/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts b/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts index 1d2eef0c898..23621d2d6ce 100644 --- a/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts @@ -2,20 +2,24 @@ * Copyright The OpenTelemetry Authors * SPDX-License-Identifier: Apache-2.0 */ + import type { OtlpHttpConfiguration } from './configuration/otlp-http-configuration'; import type { ISerializer } from '@opentelemetry/otlp-transformer'; import type { IOtlpExportDelegate } from './otlp-export-delegate'; import { createRetryingTransport } from './retrying-transport'; import { createOtlpNetworkExportDelegate } from './otlp-network-export-delegate'; import { createFetchTransport } from './transport/fetch-transport'; +import { type ExporterMetrics } from './ExporterMetrics'; export function createOtlpFetchExportDelegate( options: OtlpHttpConfiguration, - serializer: ISerializer + serializer: ISerializer, + metrics: ExporterMetrics ): IOtlpExportDelegate { return createOtlpNetworkExportDelegate( options, serializer, + metrics, createRetryingTransport({ transport: createFetchTransport(options), }) @@ -27,7 +31,8 @@ export function createOtlpFetchExportDelegate( */ export function createOtlpSendBeaconExportDelegate( options: OtlpHttpConfiguration, - serializer: ISerializer + serializer: ISerializer, + metrics: ExporterMetrics ): IOtlpExportDelegate { - return createOtlpFetchExportDelegate(options, serializer); + return createOtlpFetchExportDelegate(options, serializer, metrics); } diff --git a/experimental/packages/otlp-exporter-base/src/otlp-export-delegate.ts b/experimental/packages/otlp-exporter-base/src/otlp-export-delegate.ts index c7734e4da55..7f404be22c0 100644 --- a/experimental/packages/otlp-exporter-base/src/otlp-export-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/otlp-export-delegate.ts @@ -13,6 +13,7 @@ import type { IOtlpResponseHandler } from './response-handler'; import { createLoggingPartialSuccessResponseHandler } from './logging-response-handler'; import type { DiagLogger } from '@opentelemetry/api'; import { diag } from '@opentelemetry/api'; +import { type ExporterMetrics } from './ExporterMetrics'; /** * Internally shared export logic for OTLP. @@ -24,11 +25,13 @@ export interface IOtlpExportDelegate { ): void; forceFlush(): Promise; shutdown(): Promise; + setMetrics(metrics: ExporterMetrics): void; } class OTLPExportDelegate implements IOtlpExportDelegate { + private _metrics: ExporterMetrics; private _diagLogger: DiagLogger; private _transport: IExporterTransport; private _serializer: ISerializer; @@ -41,6 +44,7 @@ class OTLPExportDelegate serializer: ISerializer, responseHandler: IOtlpResponseHandler, promiseQueue: IExportPromiseHandler, + metrics: ExporterMetrics, timeout: number ) { this._transport = transport; @@ -51,6 +55,7 @@ class OTLPExportDelegate this._diagLogger = diag.createComponentLogger({ namespace: 'OTLPExportDelegate', }); + this._metrics = metrics; } export( @@ -80,10 +85,12 @@ class OTLPExportDelegate return; } + const finishExport = this._metrics.startExport(internalRepresentation); this._promiseQueue.pushPromise( this._transport.send(serializedRequest, this._timeout).then( response => { if (response.status === 'success') { + finishExport(undefined); if (response.data != null) { try { this._responseHandler.handleResponse( @@ -103,12 +110,14 @@ class OTLPExportDelegate }); return; } else if (response.status === 'failure' && response.error) { + finishExport(response.error); resultCallback({ code: ExportResultCode.FAILED, error: response.error, }); return; } else if (response.status === 'retryable') { + finishExport('export_max_retries'); resultCallback({ code: ExportResultCode.FAILED, error: @@ -116,17 +125,20 @@ class OTLPExportDelegate new OTLPExporterError('Export failed with retryable status'), }); } else { + finishExport('export_failed'); resultCallback({ code: ExportResultCode.FAILED, error: new OTLPExporterError('Export failed with unknown error'), }); } }, - reason => + reason => { + finishExport(reason); resultCallback({ code: ExportResultCode.FAILED, error: reason, - }) + }); + } ) ); } @@ -135,6 +147,10 @@ class OTLPExportDelegate return this._promiseQueue.awaitAll(); } + setMetrics(metrics: ExporterMetrics) { + this._metrics = metrics; + } + async shutdown(): Promise { this._diagLogger.debug('shutdown started'); await this.forceFlush(); @@ -151,6 +167,7 @@ export function createOtlpExportDelegate( transport: IExporterTransport; serializer: ISerializer; promiseHandler: IExportPromiseHandler; + metrics: ExporterMetrics; }, settings: { timeout: number } ): IOtlpExportDelegate { @@ -159,6 +176,7 @@ export function createOtlpExportDelegate( components.serializer, createLoggingPartialSuccessResponseHandler(), components.promiseHandler, + components.metrics, settings.timeout ); } diff --git a/experimental/packages/otlp-exporter-base/src/otlp-http-export-delegate.ts b/experimental/packages/otlp-exporter-base/src/otlp-http-export-delegate.ts index 1832a132494..a55c6bdb5a7 100644 --- a/experimental/packages/otlp-exporter-base/src/otlp-http-export-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/otlp-http-export-delegate.ts @@ -2,6 +2,9 @@ * Copyright The OpenTelemetry Authors * SPDX-License-Identifier: Apache-2.0 */ + +import type { MeterProvider } from '@opentelemetry/api'; + import type { IOtlpExportDelegate } from './otlp-export-delegate'; import { createOtlpExportDelegate } from './otlp-export-delegate'; import type { ISerializer } from '@opentelemetry/otlp-transformer'; @@ -9,10 +12,38 @@ import { createHttpExporterTransport } from './transport/http-exporter-transport import { createBoundedQueueExportPromiseHandler } from './bounded-queue-export-promise-handler'; import { createRetryingTransport } from './retrying-transport'; import type { OtlpNodeHttpConfiguration } from './configuration/otlp-node-http-configuration'; +import { OTLPExporterError } from './types'; +import { ATTR_HTTP_RESPONSE_STATUS_CODE } from './semconv'; +import { ExporterMetrics, type IExporterSignal } from './ExporterMetrics'; + +export function createOtlpHttpExporterMetrics( + metricsComponentType: string, + signal: IExporterSignal, + url: string | undefined, + meterProvider: MeterProvider | undefined +): ExporterMetrics { + return new ExporterMetrics({ + componentType: metricsComponentType, + signal, + url, + meterProvider, + errorAttributes: (error: unknown) => { + if (!(error instanceof OTLPExporterError)) { + return {}; + } + return { + [ATTR_HTTP_RESPONSE_STATUS_CODE]: error.code, + }; + }, + }); +} export function createOtlpHttpExportDelegate( options: OtlpNodeHttpConfiguration, - serializer: ISerializer + serializer: ISerializer, + metricsComponentType: string, + signal: IExporterSignal, + meterProvider: MeterProvider | undefined ): IOtlpExportDelegate { return createOtlpExportDelegate( { @@ -21,6 +52,12 @@ export function createOtlpHttpExportDelegate( }), serializer: serializer, promiseHandler: createBoundedQueueExportPromiseHandler(options), + metrics: createOtlpHttpExporterMetrics( + metricsComponentType, + signal, + options.url, + meterProvider + ), }, { timeout: options.timeoutMillis } ); diff --git a/experimental/packages/otlp-exporter-base/src/otlp-network-export-delegate.ts b/experimental/packages/otlp-exporter-base/src/otlp-network-export-delegate.ts index eea25fe6a98..00d4494a4e7 100644 --- a/experimental/packages/otlp-exporter-base/src/otlp-network-export-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/otlp-network-export-delegate.ts @@ -9,10 +9,12 @@ import type { ISerializer } from '@opentelemetry/otlp-transformer'; import type { IExporterTransport } from './exporter-transport'; import type { IOtlpExportDelegate } from './otlp-export-delegate'; import { createOtlpExportDelegate } from './otlp-export-delegate'; +import { type ExporterMetrics } from './ExporterMetrics'; export function createOtlpNetworkExportDelegate( options: OtlpSharedConfiguration, serializer: ISerializer, + metrics: ExporterMetrics, transport: IExporterTransport ): IOtlpExportDelegate { return createOtlpExportDelegate( @@ -20,6 +22,7 @@ export function createOtlpNetworkExportDelegate( transport: transport, serializer, promiseHandler: createBoundedQueueExportPromiseHandler(options), + metrics, }, { timeout: options.timeoutMillis } ); diff --git a/experimental/packages/otlp-exporter-base/src/semconv.ts b/experimental/packages/otlp-exporter-base/src/semconv.ts new file mode 100644 index 00000000000..6736c0fcd2b --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/semconv.ts @@ -0,0 +1,107 @@ +/* + * 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 + */ + +/** + * [HTTP response status code](https://tools.ietf.org/html/rfc7231#section-6). + * + * @example 200 + */ +export const ATTR_HTTP_RESPONSE_STATUS_CODE = + 'http.response.status_code' as const; + +/** + * A name uniquely identifying the instance of the OpenTelemetry component within its containing SDK instance. + * + * @example otlp_grpc_span_exporter/0 + * @example custom-name + * + * @note Implementations **SHOULD** ensure a low cardinality for this attribute, even across application or SDK restarts. + * E.g. implementations **MUST NOT** use UUIDs as values for this attribute. + * + * Implementations **MAY** achieve these goals by following a `/` pattern, e.g. `batching_span_processor/0`. + * Hereby `otel.component.type` refers to the corresponding attribute value of the component. + * + * The value of `instance-counter` **MAY** be automatically assigned by the component and uniqueness within the enclosing SDK instance **MUST** be guaranteed. + * For example, `` **MAY** be implemented by using a monotonically increasing counter (starting with `0`), which is incremented every time an + * instance of the given component type is started. + * + * With this implementation, for example the first Batching Span Processor would have `batching_span_processor/0` + * as `otel.component.name`, the second one `batching_span_processor/1` and so on. + * These values will therefore be reused in the case of an application restart. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_COMPONENT_NAME = 'otel.component.name' as const; + +/** + * A name identifying the type of the OpenTelemetry component. + * + * @example batching_span_processor + * @example com.example.MySpanExporter + * + * @note If none of the standardized values apply, implementations **SHOULD** use the language-defined name of the type. + * E.g. for Java the fully qualified classname **SHOULD** be used in this case. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_COMPONENT_TYPE = 'otel.component.type' as const; + +/** + * Server domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name. + * + * @example example.com + * @example 10.1.2.80 + * @example /tmp/my.sock + * + * @note When observed from the client side, and when communicating through an intermediary, `server.address` **SHOULD** represent the server address behind any intermediaries, for example proxies, if it's available. + */ +export const ATTR_SERVER_ADDRESS = 'server.address' as const; + +/** + * Server port number. + * + * @example 80 + * @example 8080 + * @example 443 + * + * @note When observed from the client side, and when communicating through an intermediary, `server.port` **SHOULD** represent the server port behind any intermediaries, for example proxies, if it's available. + */ +export const ATTR_SERVER_PORT = 'server.port' as const; + +/** + * Describes a class of error the operation ended with. + * + * @example timeout + * @example java.net.UnknownHostException + * @example server_certificate_invalid + * @example 500 + * + * @note The `error.type` **SHOULD** be predictable, and **SHOULD** have low cardinality. + * + * When `error.type` is set to a type (e.g., an exception type), its + * canonical class name identifying the type within the artifact **SHOULD** be used. + * + * Instrumentations **SHOULD** document the list of errors they report. + * + * The cardinality of `error.type` within one instrumentation library **SHOULD** be low. + * Telemetry consumers that aggregate data from multiple instrumentation libraries and applications + * should be prepared for `error.type` to have high cardinality at query time when no + * additional filters are applied. + * + * If the operation has completed successfully, instrumentations **SHOULD NOT** set `error.type`. + * + * If a specific domain defines its own set of error identifiers (such as HTTP or RPC status codes), + * it's **RECOMMENDED** to: + * + * - Use a domain-specific attribute + * - Set `error.type` to capture all errors, regardless of whether they are defined within the domain-specific set or not. + */ +export const ATTR_ERROR_TYPE = 'error.type' as const; diff --git a/experimental/packages/otlp-exporter-base/test/common/OTLPExporterBase.test.ts b/experimental/packages/otlp-exporter-base/test/common/OTLPExporterBase.test.ts index bcc1949b05a..0fd67cc7a95 100644 --- a/experimental/packages/otlp-exporter-base/test/common/OTLPExporterBase.test.ts +++ b/experimental/packages/otlp-exporter-base/test/common/OTLPExporterBase.test.ts @@ -14,10 +14,12 @@ describe('OTLPExporterBase', function () { const exportStub = sinon.stub(); const forceFlushStub = sinon.stub(); const shutdownStub = sinon.stub(); + const setMetricsStub = sinon.stub(); const delegateStubs: IOtlpExportDelegate = { export: exportStub, forceFlush: forceFlushStub, shutdown: shutdownStub, + setMetrics: setMetricsStub, }; const exporterBase = new OTLPExporterBase(delegateStubs); @@ -38,10 +40,12 @@ describe('OTLPExporterBase', function () { const exportStub = sinon.stub(); const forceFlushStub = sinon.stub(); const shutdownStub = sinon.stub(); + const setMetricsStub = sinon.stub(); const delegateStubs: IOtlpExportDelegate = { export: exportStub, forceFlush: forceFlushStub, shutdown: shutdownStub, + setMetrics: setMetricsStub, }; const exporterBase = new OTLPExporterBase(delegateStubs); @@ -62,10 +66,12 @@ describe('OTLPExporterBase', function () { const exportStub = sinon.stub(); const forceFlushStub = sinon.stub(); const shutdownStub = sinon.stub(); + const setMetricsStub = sinon.stub(); const delegateStubs: IOtlpExportDelegate = { export: exportStub, forceFlush: forceFlushStub, shutdown: shutdownStub, + setMetrics: setMetricsStub, }; const exporterBase = new OTLPExporterBase(delegateStubs); const expectedExportItem = 'sample-export-item'; diff --git a/experimental/packages/otlp-exporter-base/test/common/otlp-export-delegate.test.ts b/experimental/packages/otlp-exporter-base/test/common/otlp-export-delegate.test.ts index a9d6df28c8e..6727f0b1ed0 100644 --- a/experimental/packages/otlp-exporter-base/test/common/otlp-export-delegate.test.ts +++ b/experimental/packages/otlp-exporter-base/test/common/otlp-export-delegate.test.ts @@ -7,11 +7,14 @@ import * as sinon from 'sinon'; import * as assert from 'assert'; import type { IExporterTransport } from '../../src'; import { ExportResultCode } from '@opentelemetry/core'; +import { type Histogram, MeterProvider } from '@opentelemetry/sdk-metrics'; import { createOtlpExportDelegate } from '../../src/otlp-export-delegate'; import type { ExportResponse } from '../../src'; import type { ISerializer } from '@opentelemetry/otlp-transformer'; import type { IExportPromiseHandler } from '../../src/bounded-queue-export-promise-handler'; -import { registerMockDiagLogger } from './test-utils'; +import { ExporterMetrics } from '../../src'; +import { registerMockDiagLogger, withResolvers } from './test-utils'; +import { TestMetricReader } from '../testHelper'; interface FakeInternalRepresentation { foo: string; @@ -30,6 +33,14 @@ const internalRepresentation: FakeInternalRepresentation = { foo: 'internal', }; +const noopMetrics = new ExporterMetrics({ + componentType: 'test', + signal: { name: 'span', countItems: () => 1 }, + url: 'http://example.com', + meterProvider: undefined, + errorAttributes: () => ({}), +}); + describe('OTLPExportDelegate', function () { describe('forceFlush', function () { afterEach(function () { @@ -63,6 +74,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseQueue, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -106,6 +118,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseQueue, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -149,6 +162,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -205,6 +219,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -263,6 +278,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -321,6 +337,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -379,6 +396,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -439,6 +457,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -504,6 +523,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -565,6 +585,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -637,6 +658,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -696,6 +718,7 @@ describe('OTLPExportDelegate', function () { promiseHandler: promiseHandler, serializer: mockSerializer, transport: mockTransport, + metrics: noopMetrics, }, { timeout: 1000, @@ -722,4 +745,222 @@ describe('OTLPExportDelegate', function () { }); }); }); + + describe('sdk metrics', () => { + it('records metrics for success', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const exportResponse: ExportResponse = { + data: Uint8Array.from([]), + status: 'success', + }; + + const { resolve: resolveSend, promise: sendPromise } = + withResolvers(); + + const transportStubs = { + send: sinon.stub().returns(sendPromise), + shutdown: sinon.stub(), + }; + const mockTransport = transportStubs; + + const serializerStubs = { + // simulate that the serializer returns something to send + serializeRequest: sinon.stub().returns(Uint8Array.from([1])), + // simulate that it returns a full success (empty response) + deserializeResponse: sinon.stub().returns({}), + }; + const mockSerializer = serializerStubs; + + // mock a queue that has not yet reached capacity + const promiseHandlerStubs = { + pushPromise: sinon.stub(), + hasReachedLimit: sinon.stub().returns(false), + awaitAll: sinon.stub(), + }; + const promiseHandler = promiseHandlerStubs; + + const exporter = createOtlpExportDelegate( + { + promiseHandler, + serializer: mockSerializer, + transport: mockTransport, + metrics: new ExporterMetrics({ + componentType: 'test_exporter', + signal: { name: 'span', countItems: () => 5 }, + url: 'http://localhost:12234', + meterProvider, + errorAttributes: () => ({}), + }), + }, + { + timeout: 1000, + } + ); + + const exportPromise = new Promise(resolve => { + exporter.export(internalRepresentation, result => { + assert.strictEqual(result.code, ExportResultCode.SUCCESS); + assert.strictEqual(result.error, undefined); + resolve(); + }); + }); + + let { resourceMetrics } = await metricReader.collect(); + let metrics = resourceMetrics.scopeMetrics[0].metrics; + let inflight = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.exporter.span.inflight' + ); + assert.ok(inflight); + assert.strictEqual(inflight.dataPoints[0].value, 5); + assert.deepStrictEqual(inflight.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/0', + 'server.address': 'localhost', + 'server.port': 12234, + }); + + resolveSend(exportResponse); + await exportPromise; + + ({ resourceMetrics } = await metricReader.collect()); + metrics = resourceMetrics.scopeMetrics[0].metrics; + inflight = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.exporter.span.inflight' + ); + const exported = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.exporter.span.exported' + ); + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(inflight); + assert.strictEqual(inflight.dataPoints[0].value, 0); + assert.deepStrictEqual(inflight.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/0', + 'server.address': 'localhost', + 'server.port': 12234, + }); + + assert.ok(exported); + assert.ok(duration); + assert.strictEqual(exported.dataPoints[0].value, 5); + assert.deepStrictEqual(exported.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/0', + 'server.address': 'localhost', + 'server.port': 12234, + }); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/0', + 'server.address': 'localhost', + 'server.port': 12234, + }); + + await exporter.shutdown(); + }); + + it('records metrics for Error', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const exportResponse: ExportResponse = { + status: 'failure', + error: TypeError('code 123'), + }; + + const transportStubs = { + send: sinon.stub().returns(Promise.resolve(exportResponse)), + shutdown: sinon.stub(), + }; + const mockTransport = transportStubs; + + const serializerStubs = { + // simulate that the serializer returns something to send + serializeRequest: sinon.stub().returns(Uint8Array.from([1])), + // simulate that it returns a full success (empty response) + deserializeResponse: sinon.stub().returns({}), + }; + const mockSerializer = serializerStubs; + + // mock a queue that has not yet reached capacity + const promiseHandlerStubs = { + pushPromise: sinon.stub(), + hasReachedLimit: sinon.stub().returns(false), + awaitAll: sinon.stub(), + }; + const promiseHandler = promiseHandlerStubs; + + const exporter = createOtlpExportDelegate( + { + promiseHandler, + serializer: mockSerializer, + transport: mockTransport, + metrics: new ExporterMetrics({ + componentType: 'test_exporter', + signal: { name: 'span', countItems: () => 5 }, + url: 'http://localhost:12234', + meterProvider, + errorAttributes: (e: unknown) => { + if (e instanceof TypeError) { + return { code: e.message.split(' ')[1] }; + } + return {}; + }, + }), + }, + { + timeout: 1000, + } + ); + + await new Promise(resolve => { + exporter.export(internalRepresentation, result => { + assert.strictEqual(result.code, ExportResultCode.FAILED); + resolve(); + }); + }); + + const { resourceMetrics } = await metricReader.collect(); + const metrics = resourceMetrics.scopeMetrics[0].metrics; + const exported = metrics.find( + metric => metric.descriptor.name === 'otel.sdk.exporter.span.exported' + ); + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(exported); + assert.ok(duration); + assert.strictEqual(exported.dataPoints[0].value, 5); + assert.deepStrictEqual(exported.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/1', + 'server.address': 'localhost', + 'server.port': 12234, + 'error.type': 'TypeError', + }); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_exporter', + 'otel.component.name': 'test_exporter/1', + 'server.address': 'localhost', + 'server.port': 12234, + 'error.type': 'TypeError', + code: '123', + }); + + await exporter.shutdown(); + }); + }); }); diff --git a/experimental/packages/otlp-exporter-base/test/common/test-utils.ts b/experimental/packages/otlp-exporter-base/test/common/test-utils.ts index 1538891ebe1..ae688b0eb27 100644 --- a/experimental/packages/otlp-exporter-base/test/common/test-utils.ts +++ b/experimental/packages/otlp-exporter-base/test/common/test-utils.ts @@ -19,3 +19,25 @@ export function registerMockDiagLogger() { return stubs; } + +interface Resolvers { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: any) => void; +} + +// Use Promise.withResolvers when we can +export function withResolvers(): Resolvers { + let resolve: (value: T) => void; + let reject: (reason: any) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { + promise, + resolve: resolve!, + reject: reject!, + }; +} diff --git a/experimental/packages/otlp-exporter-base/test/node/otlp-http-export-delegate.test.ts b/experimental/packages/otlp-exporter-base/test/node/otlp-http-export-delegate.test.ts index b7eafe83138..38d6845fc1f 100644 --- a/experimental/packages/otlp-exporter-base/test/node/otlp-http-export-delegate.test.ts +++ b/experimental/packages/otlp-exporter-base/test/node/otlp-http-export-delegate.test.ts @@ -9,6 +9,8 @@ import { ExportResultCode } from '@opentelemetry/core'; import * as sinon from 'sinon'; import * as http from 'http'; import * as assert from 'assert'; +import { TestMetricReader } from '../testHelper'; +import { type Histogram, MeterProvider } from '@opentelemetry/sdk-metrics'; // IMPLEMENTATION NOTE: // @@ -19,10 +21,14 @@ import * as assert from 'assert'; describe('createOtlpHttpExportDelegate', function () { let server: http.Server; + let handler: (req: http.IncomingMessage, resp: http.ServerResponse) => void; beforeEach(function (done) { - server = http.createServer((request, response) => { + handler = (request, response) => { response.statusCode = 200; response.end('Test Server Response'); + }; + server = http.createServer((request, response) => { + handler(request, response); }); server.listen(8083); server.once('listening', () => { @@ -50,7 +56,10 @@ describe('createOtlpHttpExportDelegate', function () { headers: async () => ({}), timeoutMillis: 1000, }, - serializer + serializer, + 'test_component', + { name: 'span', countItems: () => 1 }, + undefined ); delegate.export('foo', result => { @@ -62,4 +71,116 @@ describe('createOtlpHttpExportDelegate', function () { } }); }); + + it('records metrics for success', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const serializer: ISerializer = { + serializeRequest: sinon.stub().returns(Buffer.from([1, 2, 3])), + deserializeResponse: sinon.stub().returns('response'), + }; + const delegate = createOtlpHttpExportDelegate( + { + url: 'http://localhost:8083', + agentFactory: () => new http.Agent(), + compression: 'none', + concurrencyLimit: 30, + headers: async () => ({}), + timeoutMillis: 1000, + }, + serializer, + 'test_http_exporter', + { name: 'metric_data_point', countItems: () => 1 }, + meterProvider + ); + + await new Promise((resolve, reject) => + delegate.export('foo', result => { + try { + assert.strictEqual(result.code, ExportResultCode.SUCCESS); + resolve(); + } catch (e) { + reject(e); + } + }) + ); + + const { resourceMetrics } = await metricReader.collect(); + const metrics = resourceMetrics.scopeMetrics[0].metrics; + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(duration); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_http_exporter', + 'otel.component.name': 'test_http_exporter/0', + 'server.address': 'localhost', + 'server.port': 8083, + }); + }); + + it('records metrics for error', async () => { + handler = (request, response) => { + response.statusCode = 501; + response.end(); + }; + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const serializer: ISerializer = { + serializeRequest: sinon.stub().returns(Buffer.from([1, 2, 3])), + deserializeResponse: sinon.stub().returns('response'), + }; + const delegate = createOtlpHttpExportDelegate( + { + url: 'http://localhost:8083', + agentFactory: () => new http.Agent(), + compression: 'none', + concurrencyLimit: 30, + headers: async () => ({}), + timeoutMillis: 1000, + }, + serializer, + 'test_http_exporter', + { name: 'metric_data_point', countItems: () => 1 }, + meterProvider + ); + + await new Promise((resolve, reject) => + delegate.export('foo', result => { + try { + assert.strictEqual(result.code, ExportResultCode.FAILED); + resolve(); + } catch (e) { + reject(e); + } + }) + ); + + const { resourceMetrics } = await metricReader.collect(); + const metrics = resourceMetrics.scopeMetrics[0].metrics; + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(duration); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_http_exporter', + 'otel.component.name': 'test_http_exporter/1', + 'server.address': 'localhost', + 'server.port': 8083, + 'http.response.status_code': 501, + 'error.type': 'OTLPExporterError', + }); + }); }); diff --git a/experimental/packages/otlp-exporter-base/test/testHelper.ts b/experimental/packages/otlp-exporter-base/test/testHelper.ts index 0c49ff13d80..9e95a4cb979 100644 --- a/experimental/packages/otlp-exporter-base/test/testHelper.ts +++ b/experimental/packages/otlp-exporter-base/test/testHelper.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { HrTime } from '@opentelemetry/api'; +import { MetricReader } from '@opentelemetry/sdk-metrics'; import * as assert from 'assert'; export interface SimpleTestObject { @@ -66,3 +67,12 @@ export function ensureHeadersContain( ); }); } + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/otlp-grpc-exporter-base/src/index.ts b/experimental/packages/otlp-grpc-exporter-base/src/index.ts index b71d63b2d20..a714ccba6ac 100644 --- a/experimental/packages/otlp-grpc-exporter-base/src/index.ts +++ b/experimental/packages/otlp-grpc-exporter-base/src/index.ts @@ -4,5 +4,8 @@ */ export { convertLegacyOtlpGrpcOptions } from './configuration/convert-legacy-otlp-grpc-options'; -export { createOtlpGrpcExportDelegate } from './otlp-grpc-export-delegate'; +export { + createOtlpGrpcExportDelegate, + createOtlpGrpcExporterMetrics, +} from './otlp-grpc-export-delegate'; export type { OTLPGRPCExporterConfigNode } from './types'; diff --git a/experimental/packages/otlp-grpc-exporter-base/src/otlp-grpc-export-delegate.ts b/experimental/packages/otlp-grpc-exporter-base/src/otlp-grpc-export-delegate.ts index 549c39fa433..aa9e3b183bd 100644 --- a/experimental/packages/otlp-grpc-exporter-base/src/otlp-grpc-export-delegate.ts +++ b/experimental/packages/otlp-grpc-exporter-base/src/otlp-grpc-export-delegate.ts @@ -3,21 +3,65 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { MeterProvider } from '@opentelemetry/api'; + +import type { ServiceError } from '@grpc/grpc-js'; import type { IOtlpExportDelegate } from '@opentelemetry/otlp-exporter-base'; -import { createOtlpNetworkExportDelegate } from '@opentelemetry/otlp-exporter-base'; +import { + createOtlpNetworkExportDelegate, + ExporterMetrics, + type IExporterSignal, +} from '@opentelemetry/otlp-exporter-base'; import type { ISerializer } from '@opentelemetry/otlp-transformer'; import type { OtlpGrpcConfiguration } from './configuration/otlp-grpc-configuration'; import { createOtlpGrpcExporterTransport } from './grpc-exporter-transport'; +import { ATTR_RPC_RESPONSE_STATUS_CODE } from './semconv'; + +export function createOtlpGrpcExporterMetrics( + metricsComponentType: string, + signal: IExporterSignal, + url: string | undefined, + meterProvider: MeterProvider | undefined +): ExporterMetrics { + return new ExporterMetrics({ + componentType: metricsComponentType, + signal, + url, + meterProvider, + errorAttributes: (error: unknown) => { + if (!isServiceError(error)) { + return {}; + } + // Lazy-load so that we don't need to require/import '@grpc/grpc-js' before it can be wrapped by instrumentation. + const { status } = + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports + require('@grpc/grpc-js') as typeof import('@grpc/grpc-js'); + const statusName = status[error.code] ?? 'UNKNOWN'; + return { + [ATTR_RPC_RESPONSE_STATUS_CODE]: statusName, + }; + }, + }); +} export function createOtlpGrpcExportDelegate( options: OtlpGrpcConfiguration, serializer: ISerializer, + metricsComponentType: string, + signal: IExporterSignal, + meterProvider: MeterProvider | undefined, grpcName: string, grpcPath: string ): IOtlpExportDelegate { return createOtlpNetworkExportDelegate( options, serializer, + createOtlpGrpcExporterMetrics( + metricsComponentType, + signal, + options.url, + meterProvider + ), createOtlpGrpcExporterTransport({ address: options.url, compression: options.compression, @@ -29,3 +73,12 @@ export function createOtlpGrpcExportDelegate( }) ); } + +function isServiceError(error: unknown): error is ServiceError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ); +} diff --git a/experimental/packages/otlp-grpc-exporter-base/src/semconv.ts b/experimental/packages/otlp-grpc-exporter-base/src/semconv.ts new file mode 100644 index 00000000000..3521bacad7e --- /dev/null +++ b/experimental/packages/otlp-grpc-exporter-base/src/semconv.ts @@ -0,0 +1,25 @@ +/* + * 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 + */ + +/** + * Status code of the RPC returned by the RPC server or generated by the client + * + * @example OK + * @example DEADLINE_EXCEEDED + * @example -32602 + * + * @note Usually it represents an error code, but may also represent partial success, warning, or differentiate between various types of successful outcomes. + * Semantic conventions for individual RPC frameworks **SHOULD** document what `rpc.response.status_code` means in the context of that system and which values are considered to represent errors. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_RPC_RESPONSE_STATUS_CODE = + 'rpc.response.status_code' as const; diff --git a/experimental/packages/otlp-grpc-exporter-base/test/grpc-exporter-transport.test.ts b/experimental/packages/otlp-grpc-exporter-base/test/grpc-exporter-transport.test.ts index cde877c4f2b..8a9b7f75260 100644 --- a/experimental/packages/otlp-grpc-exporter-base/test/grpc-exporter-transport.test.ts +++ b/experimental/packages/otlp-grpc-exporter-base/test/grpc-exporter-transport.test.ts @@ -15,17 +15,23 @@ import * as assert from 'assert'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as sinon from 'sinon'; -import type { Metadata } from '@grpc/grpc-js'; +import type { Metadata, ServiceError } from '@grpc/grpc-js'; import { Server, ServerCredentials, ServerInterceptingCall, + status, } from '@grpc/grpc-js'; import { types } from 'util'; import type { ExportResponseFailure, ExportResponseSuccess, } from '@opentelemetry/otlp-exporter-base'; +import type { ISerializer } from '@opentelemetry/otlp-transformer'; +import type { Histogram } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import { createOtlpGrpcExportDelegate } from '../src'; +import { ExportResultCode } from '@opentelemetry/core'; const testServiceDefinition = { export: { @@ -73,6 +79,23 @@ interface ServerTestContext { serverResponseProvider: () => { error: Error | null; buffer?: Buffer }; } +interface FakeInternalRepresentation { + foo: string; +} + +interface FakeSignalResponse { + partialSuccess?: { foo: string }; +} + +type FakeSerializer = ISerializer< + FakeInternalRepresentation, + FakeSignalResponse +>; + +const internalRepresentation: FakeInternalRepresentation = { + foo: 'internal', +}; + /** * Starts a customizable server that saves all responses to context.responses * Returns data as defined in context.ServerResponseProvider @@ -443,6 +466,129 @@ describe('GrpcExporterTransport', function () { assert.strictEqual(result.status, 'failure'); assert.strictEqual(result.error, expectedError); }); + + it('delegate records metrics for success', async () => { + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const serializerStubs = { + // simulate that the serializer returns something to send + serializeRequest: sinon.stub().returns(Buffer.from([1, 2, 3])), + // simulate that it returns a full success (empty response) + deserializeResponse: sinon.stub().returns({}), + }; + const mockSerializer = serializerStubs; + + const delegate = createOtlpGrpcExportDelegate( + { + url: simpleClientConfig.address, + metadata: simpleClientConfig.metadata, + credentials: simpleClientConfig.credentials, + compression: simpleClientConfig.compression, + concurrencyLimit: 10, + timeoutMillis, + }, + mockSerializer, + 'test_grpc_exporter', + { name: 'log', countItems: () => 10 }, + meterProvider, + simpleClientConfig.grpcName, + simpleClientConfig.grpcPath + ); + + await new Promise(resolve => + delegate.export(internalRepresentation, result => { + assert.strictEqual(result.code, ExportResultCode.SUCCESS); + assert.strictEqual(result.error, undefined); + resolve(); + }) + ); + + const { resourceMetrics } = await metricReader.collect(); + const metrics = resourceMetrics.scopeMetrics[0].metrics; + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(duration); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_grpc_exporter', + 'otel.component.name': 'test_grpc_exporter/0', + 'server.address': 'localhost', + 'server.port': 1234, + }); + }); + + it('delegate records metrics for gRPC error', async () => { + const error: ServiceError = { + name: 'ServiceError', + message: 'service failed', + code: status.DATA_LOSS, + details: 'failed', + metadata: simpleClientConfig.metadata(), + }; + serverTestContext.serverResponseProvider = () => ({ + error, + }); + const metricReader = new TestMetricReader(); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + const serializerStubs = { + // simulate that the serializer returns something to send + serializeRequest: sinon.stub().returns(Buffer.from([1, 2, 3])), + // simulate that it returns a full success (empty response) + deserializeResponse: sinon.stub().returns({}), + }; + const mockSerializer = serializerStubs; + + const delegate = createOtlpGrpcExportDelegate( + { + url: simpleClientConfig.address, + metadata: simpleClientConfig.metadata, + credentials: simpleClientConfig.credentials, + compression: simpleClientConfig.compression, + concurrencyLimit: 10, + timeoutMillis, + }, + mockSerializer, + 'test_grpc_exporter', + { name: 'log', countItems: () => 10 }, + meterProvider, + simpleClientConfig.grpcName, + simpleClientConfig.grpcPath + ); + + await new Promise(resolve => + delegate.export(internalRepresentation, result => { + assert.strictEqual(result.code, ExportResultCode.FAILED); + resolve(); + }) + ); + + const { resourceMetrics } = await metricReader.collect(); + const metrics = resourceMetrics.scopeMetrics[0].metrics; + const duration = metrics.find( + metric => + metric.descriptor.name === 'otel.sdk.exporter.operation.duration' + ); + assert.ok(duration); + const histogram = duration.dataPoints[0].value as Histogram; + assert.strictEqual(histogram.count, 1); + assert.strictEqual(histogram.count, 1); + assert.deepStrictEqual(duration.dataPoints[0].attributes, { + 'otel.component.type': 'test_grpc_exporter', + 'otel.component.name': 'test_grpc_exporter/1', + 'server.address': 'localhost', + 'server.port': 1234, + 'rpc.response.status_code': 'DATA_LOSS', + 'error.type': 'Error', + }); + }); }); describe('uds', function () { let shutdownHandle: (() => void) | undefined; @@ -499,3 +645,12 @@ describe('GrpcExporterTransport', function () { }); }); }); + +export class TestMetricReader extends MetricReader { + protected override onShutdown(): Promise { + return Promise.resolve(); + } + protected override onForceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/experimental/packages/otlp-transformer/src/i-signal.ts b/experimental/packages/otlp-transformer/src/i-signal.ts new file mode 100644 index 00000000000..4e9a782df61 --- /dev/null +++ b/experimental/packages/otlp-transformer/src/i-signal.ts @@ -0,0 +1,9 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IExporterSignal { + name: 'span' | 'metric_data_point' | 'log'; + countItems: (request: Internal) => number; +} diff --git a/experimental/packages/otlp-transformer/src/index.ts b/experimental/packages/otlp-transformer/src/index.ts index a2a7e8b703d..c36682b5821 100644 --- a/experimental/packages/otlp-transformer/src/index.ts +++ b/experimental/packages/otlp-transformer/src/index.ts @@ -3,17 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type { - IExportMetricsPartialSuccess, - IExportMetricsServiceResponse, +export { + type IExportMetricsPartialSuccess, + type IExportMetricsServiceResponse, + MetricsSignal, } from './metrics'; -export type { - IExportTracePartialSuccess, - IExportTraceServiceResponse, +export { + type IExportTracePartialSuccess, + type IExportTraceServiceResponse, + TraceSignal, } from './trace'; -export type { - IExportLogsServiceResponse, - IExportLogsPartialSuccess, +export { + type IExportLogsServiceResponse, + type IExportLogsPartialSuccess, + LogsSignal, } from './logs'; export { ProtobufLogsSerializer } from './logs/protobuf'; diff --git a/experimental/packages/otlp-transformer/src/logs/index.ts b/experimental/packages/otlp-transformer/src/logs/index.ts index 62815054a83..9d44916ed0f 100644 --- a/experimental/packages/otlp-transformer/src/logs/index.ts +++ b/experimental/packages/otlp-transformer/src/logs/index.ts @@ -3,8 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ReadableLogRecord } from '@opentelemetry/sdk-logs'; + +import type { IExporterSignal } from '../i-signal'; + // IMPORTANT: exports added here are public export type { IExportLogsServiceResponse, IExportLogsPartialSuccess, } from './export-response'; + +export const LogsSignal: IExporterSignal = { + name: 'log', + countItems: (request: ReadableLogRecord[]) => request.length, +}; diff --git a/experimental/packages/otlp-transformer/src/metrics/index.ts b/experimental/packages/otlp-transformer/src/metrics/index.ts index 623b3192089..3ace7d6c95d 100644 --- a/experimental/packages/otlp-transformer/src/metrics/index.ts +++ b/experimental/packages/otlp-transformer/src/metrics/index.ts @@ -3,8 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ResourceMetrics } from '@opentelemetry/sdk-metrics'; + +import type { IExporterSignal } from '../i-signal'; + // IMPORTANT: exports added here are public export type { IExportMetricsPartialSuccess, IExportMetricsServiceResponse, } from './export-response'; + +export const MetricsSignal: IExporterSignal = { + name: 'metric_data_point', + countItems: (request: ResourceMetrics) => { + let count = 0; + for (const scopeMetrics of request.scopeMetrics) { + for (const metric of scopeMetrics.metrics) { + count += metric.dataPoints.length; + } + } + return count; + }, +}; diff --git a/experimental/packages/otlp-transformer/src/trace/index.ts b/experimental/packages/otlp-transformer/src/trace/index.ts index 86c23e7b6f8..7c59c064468 100644 --- a/experimental/packages/otlp-transformer/src/trace/index.ts +++ b/experimental/packages/otlp-transformer/src/trace/index.ts @@ -3,8 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; + +import type { IExporterSignal } from '../i-signal'; + // IMPORTANT: exports added here are public export type { IExportTracePartialSuccess, IExportTraceServiceResponse, } from './export-response'; + +export const TraceSignal: IExporterSignal = { + name: 'span', + countItems: (request: ReadableSpan[]) => request.length, +};