From b8f39a0c9fd22a042801a21ffaef202d33812561 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Fri, 13 Feb 2026 11:19:50 -0500 Subject: [PATCH] Resolve OTLP export failures instead of rejecting The OTLPMetricExporter's internal HTTP retry mechanism can produce unhandled promise rejections that bypass all try/catch layers and crash the CLI process. Fix this at the source by resolving on export failure in InstantaneousMetricReader instead of rejecting, and logging via diag.error(). This makes forceFlush() and shutdown() paths both safe without needing scattered .catch() handlers. Co-Authored-By: Claude --- .../export/InstantaneousMetricReader.test.ts | 48 +++++++++++++++++++ .../export/InstantaneousMetricReader.ts | 9 ++-- .../BaseOtelService/BaseOtelService.ts | 3 +- 3 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.test.ts diff --git a/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.test.ts b/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.test.ts new file mode 100644 index 00000000000..1262e969054 --- /dev/null +++ b/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.test.ts @@ -0,0 +1,48 @@ +import {InstantaneousMetricReader} from './InstantaneousMetricReader.js' +import {ExportResultCode} from '@opentelemetry/core' +import type {PushMetricExporter, ResourceMetrics} from '@opentelemetry/sdk-metrics' +import {MeterProvider} from '@opentelemetry/sdk-metrics' +import {describe, expect, test, vi} from 'vitest' + +function createMockExporter(resultCode: ExportResultCode, error?: Error): PushMetricExporter { + return { + export: vi.fn((_metrics: ResourceMetrics, callback: (result: {code: ExportResultCode; error?: Error}) => void) => { + callback({code: resultCode, error}) + }), + forceFlush: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + } as unknown as PushMetricExporter +} + +function createReaderWithProvider(exporter: PushMetricExporter): {reader: InstantaneousMetricReader; provider: MeterProvider} { + const reader = new InstantaneousMetricReader({exporter, throttleLimit: 0}) + const provider = new MeterProvider() + provider.addMetricReader(reader) + return {reader, provider} +} + +describe('InstantaneousMetricReader', () => { + test('resolves on successful export', async () => { + const exporter = createMockExporter(ExportResultCode.SUCCESS) + const {reader, provider} = createReaderWithProvider(exporter) + + await expect(reader.forceFlush()).resolves.toBeUndefined() + await provider.shutdown() + }) + + test('resolves without rejecting on export failure', async () => { + const exporter = createMockExporter(ExportResultCode.FAILED, new Error('Export failed with retryable status')) + const {reader, provider} = createReaderWithProvider(exporter) + + await expect(reader.forceFlush()).resolves.toBeUndefined() + await provider.shutdown() + }) + + test('resolves without rejecting when export error is undefined', async () => { + const exporter = createMockExporter(ExportResultCode.FAILED) + const {reader, provider} = createReaderWithProvider(exporter) + + await expect(reader.forceFlush()).resolves.toBeUndefined() + await provider.shutdown() + }) +}) diff --git a/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.ts b/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.ts index 9ac92bbf893..bdd7d14bb20 100644 --- a/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.ts +++ b/packages/cli-kit/src/public/node/vendor/otel-js/export/InstantaneousMetricReader.ts @@ -41,13 +41,12 @@ export class InstantaneousMetricReader extends MetricReader { diag.error('PeriodicExportingMetricReader: metrics collection errors', ...errors) } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { this._exporter.export(resourceMetrics, (result) => { - if (result.code === ExportResultCode.SUCCESS) { - resolve() - } else { - reject(result.error ?? new Error(`InstantaneousMetricReader: metrics export failed (error ${result.error})`)) + if (result.code !== ExportResultCode.SUCCESS) { + diag.error('InstantaneousMetricReader: metrics export failed', result.error) } + resolve() }) }) } diff --git a/packages/cli-kit/src/public/node/vendor/otel-js/service/BaseOtelService/BaseOtelService.ts b/packages/cli-kit/src/public/node/vendor/otel-js/service/BaseOtelService/BaseOtelService.ts index 069eb035a24..8a09ed4af02 100644 --- a/packages/cli-kit/src/public/node/vendor/otel-js/service/BaseOtelService/BaseOtelService.ts +++ b/packages/cli-kit/src/public/node/vendor/otel-js/service/BaseOtelService/BaseOtelService.ts @@ -136,8 +136,7 @@ export class BaseOtelService implements OtelService { instrument.add(finalValue, finalLabels) } // We flush metrics after every record - we do not await as we fire & forget. - // Catch any export errors to prevent unhandled rejections from crashing the CLI - void this.meterProvider.forceFlush({}).catch(() => {}) + void this.meterProvider.forceFlush({}) } record(firstValue, firstLabels) this.metrics.set(metricName, record)