From ac5a078ff5788c05201e50da1e23616322a4bbee Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Sat, 2 May 2026 19:51:05 +0300 Subject: [PATCH] fix(sdk-node): pass OTLP HTTP metric config Signed-off-by: cyphercodes --- experimental/CHANGELOG.md | 1 + .../opentelemetry-sdk-node/src/utils.ts | 101 ++++++++++---- .../opentelemetry-sdk-node/test/utils.test.ts | 123 +++++++++++++++++- 3 files changed, 202 insertions(+), 23 deletions(-) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 80b5ceb2cd0..e0a2fa7327b 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -15,6 +15,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :bug: Bug Fixes +* fix(sdk-node): pass OTLP HTTP metric exporter configuration from declarative config [#6665](https://github.com/open-telemetry/opentelemetry-js/issues/6665) @cyphercodes * fix(configuration): do not validate `OTEL_CONFIG_FILE` value before using it for file config [#6643](https://github.com/open-telemetry/opentelemetry-js/pull/6643) @trentm * fix(configuration): improve how 'additionalProperties' in JSON schema is translated to TS types [#6650](https://github.com/open-telemetry/opentelemetry-js/pull/6650) @trentm * fix(configuration): remove stripMinItems and preprocessNullArrays from validation/parsing [#6657](https://github.com/open-telemetry/opentelemetry-js/pull/6657) @trentm diff --git a/experimental/packages/opentelemetry-sdk-node/src/utils.ts b/experimental/packages/opentelemetry-sdk-node/src/utils.ts index ec3f090f15f..87d3ea898b5 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/utils.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/utils.ts @@ -10,6 +10,7 @@ import { getNumberFromEnv, getStringFromEnv, getStringListFromEnv, + parseKeyPairsIntoRecord, W3CBaggagePropagator, W3CTraceContextPropagator, } from '@opentelemetry/core'; @@ -65,6 +66,7 @@ import type { } from '@opentelemetry/configuration'; import type { AggregationOption, + AggregationSelector, IAttributesProcessor, IMetricReader, PushMetricExporter, @@ -79,7 +81,10 @@ import { PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; import { OTLPMetricExporter as OTLPGrpcMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; -import { OTLPMetricExporter as OTLPHttpMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { + AggregationTemporalityPreference, + OTLPMetricExporter as OTLPHttpMetricExporter, +} from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPMetricExporter as OTLPProtoMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; import type { BufferConfig, @@ -509,37 +514,88 @@ export function getOtlpMetricExporterFromEnv(): PushMetricExporter { return new OTLPProtoMetricExporter(); } +type OtlpHttpMetricExporterConfigModel = NonNullable< + PeriodicMetricReaderConfigModel['exporter']['otlp_http'] +>; + +function getMetricExporterCompression( + compression: string | undefined +): CompressionAlgorithm { + return compression === 'gzip' + ? CompressionAlgorithm.GZIP + : CompressionAlgorithm.NONE; +} + +function getMetricExporterTemporalityPreference( + temporalityPreference: OtlpHttpMetricExporterConfigModel['temporality_preference'] +): AggregationTemporalityPreference | undefined { + switch (temporalityPreference) { + case 'delta': + return AggregationTemporalityPreference.DELTA; + case 'low_memory': + return AggregationTemporalityPreference.LOWMEMORY; + case 'cumulative': + return AggregationTemporalityPreference.CUMULATIVE; + default: + return undefined; + } +} + +function getMetricExporterDefaultHistogramAggregation( + defaultHistogramAggregation: OtlpHttpMetricExporterConfigModel['default_histogram_aggregation'] +): AggregationSelector | undefined { + if (defaultHistogramAggregation !== 'base2_exponential_bucket_histogram') { + return undefined; + } + + return instrumentType => { + if (instrumentType === InstrumentType.HISTOGRAM) { + return { type: AggregationType.EXPONENTIAL_HISTOGRAM }; + } + return { type: AggregationType.DEFAULT }; + }; +} + +function getOtlpHttpMetricExporterConfig( + config: OtlpHttpMetricExporterConfigModel +) { + return { + compression: getMetricExporterCompression(config.compression), + url: config.endpoint, + headers: getHeadersFromConfiguration(config.headers, config.headers_list), + timeoutMillis: config.timeout, + httpAgentOptions: getHttpAgentOptionsFromTls(config.tls), + temporalityPreference: getMetricExporterTemporalityPreference( + config.temporality_preference + ), + aggregationPreference: getMetricExporterDefaultHistogramAggregation( + config.default_histogram_aggregation + ), + }; +} + export function getPeriodicMetricReaderFromConfiguration( periodic: PeriodicMetricReaderConfigModel ): IMetricReader | undefined { if (periodic.exporter) { let exporter; if (periodic.exporter.otlp_http) { - const encoding = periodic.exporter.otlp_http.encoding; + const config = periodic.exporter.otlp_http; + const encoding = config.encoding ?? 'protobuf'; + const exporterConfig = getOtlpHttpMetricExporterConfig(config); if (encoding === 'json') { - exporter = new OTLPHttpMetricExporter({ - compression: - periodic.exporter.otlp_http.compression === 'gzip' - ? CompressionAlgorithm.GZIP - : CompressionAlgorithm.NONE, - }); + exporter = new OTLPHttpMetricExporter(exporterConfig); } else if (encoding === 'protobuf') { - exporter = new OTLPProtoMetricExporter({ - compression: - periodic.exporter.otlp_http.compression === 'gzip' - ? CompressionAlgorithm.GZIP - : CompressionAlgorithm.NONE, - }); + exporter = new OTLPProtoMetricExporter(exporterConfig); } else { diag.warn(`Unsupported OTLP metrics encoding: ${encoding}.`); } } if (periodic.exporter.otlp_grpc) { exporter = new OTLPGrpcMetricExporter({ - compression: - periodic.exporter.otlp_grpc.compression === 'gzip' - ? CompressionAlgorithm.GZIP - : CompressionAlgorithm.NONE, + compression: getMetricExporterCompression( + periodic.exporter.otlp_grpc.compression + ), }); } @@ -681,13 +737,14 @@ export function getLogRecordProcessorsFromConfiguration( } export function getHeadersFromConfiguration( - headers: NameStringValuePairConfigModel[] | undefined + headers: NameStringValuePairConfigModel[] | undefined, + headersList?: string ): Record | undefined { - if (!headers) { + if (!headers && headersList === undefined) { return undefined; } - const result: Record = {}; - headers.forEach(header => { + const result: Record = parseKeyPairsIntoRecord(headersList); + headers?.forEach(header => { result[header.name] = header.value; }); return result; diff --git a/experimental/packages/opentelemetry-sdk-node/test/utils.test.ts b/experimental/packages/opentelemetry-sdk-node/test/utils.test.ts index decf876a94c..48ec71943e0 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/utils.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/utils.test.ts @@ -34,9 +34,58 @@ import { serviceInstanceIdDetector, } from '@opentelemetry/resources'; import type { LoggerProviderConfig } from '@opentelemetry/sdk-logs'; -import { AggregationType, InstrumentType } from '@opentelemetry/sdk-metrics'; +import type { AggregationOption } from '@opentelemetry/sdk-metrics'; +import { + AggregationTemporality, + AggregationType, + InstrumentType, +} from '@opentelemetry/sdk-metrics'; import type { SpanLimits } from '@opentelemetry/sdk-trace-node'; +interface OtlpHttpTransportParameters { + url: string; + compression: string; + headers: () => Promise>; + agentFactory: (protocol: string) => Promise<{ + options: { + ca?: Buffer; + cert?: Buffer; + key?: Buffer; + }; + }>; +} + +interface OtlpMetricExporterInternals { + _delegate: { + _timeout: number; + _transport: { + _transport: { + _parameters: OtlpHttpTransportParameters; + }; + }; + }; + selectAggregation(instrumentType: InstrumentType): AggregationOption; + selectAggregationTemporality( + instrumentType: InstrumentType + ): AggregationTemporality; +} + +interface PeriodicMetricReaderInternals { + _exporter: OtlpMetricExporterInternals; +} + +function getMetricExporterInternals( + reader: unknown +): OtlpMetricExporterInternals { + return (reader as PeriodicMetricReaderInternals)._exporter; +} + +function getHttpTransportParameters( + exporter: OtlpMetricExporterInternals +): OtlpHttpTransportParameters { + return exporter._delegate._transport._transport._parameters; +} + describe('getPropagatorFromEnv', function () { afterEach(() => { delete process.env.OTEL_PROPAGATORS; @@ -433,6 +482,78 @@ describe('getBatchLogRecordProcessorConfigFromEnv', function () { ); }); + it('passes OTLP HTTP metric exporter connection options from configuration', async function () { + const reader = getPeriodicMetricReaderFromConfiguration({ + interval: 7000, + timeout: 5000, + exporter: { + otlp_http: { + endpoint: 'https://collector.example/v1/metrics', + tls: { + ca_file: 'test/fixtures/ca.pem', + key_file: 'test/fixtures/ca-key.pem', + cert_file: 'test/fixtures/cert.pem', + }, + headers_list: 'x-list=list-value,x-overridden=list-value', + headers: [ + { name: 'x-test-header', value: 'test-value' }, + { name: 'x-overridden', value: 'header-value' }, + ], + compression: 'gzip', + timeout: 1234, + }, + }, + }); + + assert.ok(reader); + const exporter = getMetricExporterInternals(reader); + const parameters = getHttpTransportParameters(exporter); + + assert.strictEqual(parameters.url, 'https://collector.example/v1/metrics'); + assert.strictEqual(parameters.compression, 'gzip'); + assert.strictEqual(exporter._delegate._timeout, 1234); + assert.deepStrictEqual(await parameters.headers(), { + 'x-list': 'list-value', + 'x-overridden': 'header-value', + 'x-test-header': 'test-value', + 'Content-Type': 'application/x-protobuf', + }); + + const agent = await parameters.agentFactory('https:'); + assert.ok(agent.options.ca); + assert.ok(agent.options.key); + assert.ok(agent.options.cert); + }); + + it('passes OTLP HTTP metric exporter aggregation options from configuration', function () { + const reader = getPeriodicMetricReaderFromConfiguration({ + exporter: { + otlp_http: { + encoding: 'json', + temporality_preference: 'delta', + default_histogram_aggregation: 'base2_exponential_bucket_histogram', + }, + }, + }); + + assert.ok(reader); + const exporter = getMetricExporterInternals(reader); + + assert.strictEqual( + exporter.selectAggregationTemporality(InstrumentType.COUNTER), + AggregationTemporality.DELTA + ); + assert.deepStrictEqual( + exporter.selectAggregation(InstrumentType.HISTOGRAM), + { + type: AggregationType.EXPONENTIAL_HISTOGRAM, + } + ); + assert.deepStrictEqual(exporter.selectAggregation(InstrumentType.COUNTER), { + type: AggregationType.DEFAULT, + }); + }); + it('should return values for getInstrumentType', function () { assert.deepStrictEqual( getInstrumentType('counter' as InstrumentTypeConfigModel),