diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 38e08da0d02..3e18a7bdc89 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -17,7 +17,7 @@ npm install ## Running Tests -Test can be run completely or each step can be run separately. +Tests can be run completely or each step can be run separately. ### `npm run test:e2e` diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 9ccdc206b6d..0431cee4005 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -4,12 +4,12 @@ "description": "End-to-end tests for OpenTelemetry JS", "version": "0.7.0", "scripts": { - "test:e2e": "npm run stop-collector; npm run run-collector && npm run export-telemetry && npm run verify || npm run stop-collector", + "test:e2e": "npm run run-collector; npm run stop-collector; node test.mjs && npm run verify || npm run stop-collector", "lint": "eslint . --ext .mjs", "lint:fix": "eslint . --ext .mjs --fix", "run-collector": "docker run --pull=always -d --rm --name otelcol-e2e -v $(pwd)/collector-config.yaml:/etc/otelcol/config.yaml -v $(pwd)/collector-output.json:/tmp/collector-output.json -p 4317:4317 -p 4318:4318 -w /tmp otel/opentelemetry-collector-contrib:latest --config /etc/otelcol/config.yaml && sleep 5", "export-telemetry": "node test.mjs; sleep 5", - "prerun-collector": "node -e \"require('fs').writeFileSync('collector-output.json', '')\"", + "prerun-collector": "node -e \"const fs = require('fs'); fs.writeFileSync('collector-output.json', ''); fs.chmodSync('collector-output.json', 0o666);\"", "stop-collector": "docker stop otelcol-e2e", "verify": "node verify.mjs" }, diff --git a/e2e-tests/test.mjs b/e2e-tests/test.mjs index 20476c580c6..4faf0c81928 100644 --- a/e2e-tests/test.mjs +++ b/e2e-tests/test.mjs @@ -18,8 +18,8 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'; -import { SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'; -import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { diag, DiagConsoleLogger, @@ -28,69 +28,141 @@ import { metrics, } from '@opentelemetry/api'; import { logs } from '@opentelemetry/api-logs'; +import { exec } from 'child_process'; +import { promisify } from 'util'; -// Enable diagnostic logging (optional) -diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); +const execAsync = promisify(exec); + +// Enable diagnostic logging +diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.VERBOSE); const collectorUrl = 'http://localhost:4318/v1'; -// Set up trace exporter with SimpleSpanProcessor -const traceExporter = new OTLPTraceExporter({ - url: `${collectorUrl}/traces`, -}); -const spanProcessors = [new SimpleSpanProcessor(traceExporter)]; - -// Set up metric exporter -const metricExporter = new OTLPMetricExporter({ - url: `${collectorUrl}/metrics`, -}); -const metricReader = new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: 1000, - exportTimeoutMillis: 1000, -}); - -// Set up log exporter -const logExporter = new OTLPLogExporter({ - url: `${collectorUrl}/logs`, -}); -const logRecordProcessors = [new SimpleLogRecordProcessor(logExporter)]; - -// Set up OpenTelemetry SDK -const sdk = new NodeSDK({ - spanProcessors, - metricReader, - logRecordProcessors, -}); +/** + * Scenario: Collector becomes available after initial failures + * + * We assume that collector is down when test starts + * + * This test verifies that: + * 1. When the collector is unavailable, exports fail with retryable errors + * 2. Telemetry can created while collector is down + * 3. Batch processors buffer the data and retry with exponential backoff + * 4. When collector starts, retries succeed + * 5. All telemetry is successfully exported + * + */ +async function testCollectorRecovery() { + console.log('[Step 1] Setting up OpenTelemetry SDK...'); + + const traceExporter = new OTLPTraceExporter({ + url: `${collectorUrl}/traces`, + }); + + const metricExporter = new OTLPMetricExporter({ + url: `${collectorUrl}/metrics`, + }); + + const logExporter = new OTLPLogExporter({ + url: `${collectorUrl}/logs`, + }); + + const sdk = new NodeSDK({ + spanProcessors: [ + new BatchSpanProcessor(traceExporter, { + scheduledDelayMillis: 1000, + exportTimeoutMillis: 30000, + }), + ], + metricReader: new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 30000, + exportTimeoutMillis: 30000, + }), + logRecordProcessors: [ + new BatchLogRecordProcessor(logExporter, { + scheduledDelayMillis: 1000, + exportTimeoutMillis: 30000, + }), + ], + }); -async function main() { sdk.start(); + console.log('✓ SDK started\n'); + + const tracer = trace.getTracer('retry-test-tracer'); + const meter = metrics.getMeter('retry-test-meter'); + const logger = logs.getLogger('retry-test-logger'); + + console.log('[Step 2] Creating telemetry while collector is DOWN...'); - // Create a span - const tracer = trace.getTracer('example-tracer'); - const span = tracer.startSpan('example-span'); - span.setAttribute('example-attribute', 'value'); - span.end(); + const span1 = tracer.startSpan('span-before-collector-start'); + span1.setAttribute('status', 'created-while-down'); + span1.setAttribute('attempt', 1); + span1.end(); - // Create a metric - const meter = metrics.getMeter('example-meter'); - const counter = meter.createUpDownCounter('example_counter'); - counter.add(42, { foo: 'bar' }); + const counter = meter.createCounter('test_counter'); + counter.add(10, { status: 'created-while-down' }); - // Create a log - const logger = logs.getLogger('example-logger'); logger.emit({ severityText: 'INFO', - body: 'test-log-body', - attributes: { foo: 'bar' }, - eventName: 'test-log-event', + body: 'log-before-collector-start', + attributes: { status: 'created-while-down' }, }); - // flushes exporters and shuts down the SDK + console.log('✓ Created 1 span, 1 metric, 1 log\n'); + + console.log('[Step 3] Waiting 2 seconds for first export attempt to fail...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + + console.log( + '✓ First export attempt should have failed, retries should be happening\n' + ); + + console.log( + '[Step 4] Starting OpenTelemetry Collector (during retry attempts)...' + ); + await execAsync('npm run run-collector'); + + // Wait for collector to be ready (within retry window) + console.log('Waiting for collector to become ready (2s)...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + console.log('✓ Collector should be ready, retries should now succeed\n'); + + // Step 5: Create more telemetry after collector is up + console.log('[Step 5] Creating telemetry while collector is UP...'); + + const span2 = tracer.startSpan('span-after-collector-start'); + span2.setAttribute('status', 'created-while-up'); + span2.setAttribute('attempt', 2); + span2.end(); + + counter.add(20, { status: 'created-while-up' }); + + logger.emit({ + severityText: 'INFO', + body: 'log-after-collector-start', + attributes: { status: 'created-while-up' }, + }); + + // Step 6: Wait for exports to complete + console.log('[Step 6] Waiting 5 seconds for exports to complete...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + console.log('✓ Export window completed\n'); + + // Step 7: Shutdown SDK + console.log('[Step 7] Shutting down SDK...'); await sdk.shutdown(); + console.log('✓ SDK shutdown complete\n'); +} + +async function main() { + try { + await testCollectorRecovery(); + process.exit(0); + } catch (error) { + console.error('\n✗ SCENARIO FAILED:', error); + process.exit(1); + } } -main().catch(err => { - console.error('Error running example:', err); - process.exit(1); -}); +main(); diff --git a/e2e-tests/verify.mjs b/e2e-tests/verify.mjs old mode 100755 new mode 100644 index 16bafb7d913..576d74f6536 --- a/e2e-tests/verify.mjs +++ b/e2e-tests/verify.mjs @@ -24,25 +24,55 @@ let verifiedMetric = false; let verifiedLog = false; const lines = data.split('\n').filter(Boolean); + +// Extract all individual telemetry items +const allSpans = []; +const allMetrics = []; +const allLogs = []; + for (const line of lines) { const parsed = JSON.parse(line); if (parsed.resourceSpans) { - console.log('found span'); - verifySpan(parsed.resourceSpans[0].scopeSpans[0].spans[0]); - verifiedSpan = true; + parsed.resourceSpans.forEach(rs => { + rs.scopeSpans?.forEach(ss => { + ss.spans?.forEach(span => allSpans.push(span)); + }); + }); } if (parsed.resourceMetrics) { - console.log('found metric'); - verifyMetric(parsed.resourceMetrics[0].scopeMetrics[0].metrics[0]); - verifiedMetric = true; + parsed.resourceMetrics.forEach(rm => { + rm.scopeMetrics?.forEach(sm => { + sm.metrics?.forEach(metric => allMetrics.push(metric)); + }); + }); } if (parsed.resourceLogs) { - console.log('found log'); - verifyLog(parsed.resourceLogs[0].scopeLogs[0].logRecords[0]); - verifiedLog = true; + parsed.resourceLogs.forEach(rl => { + rl.scopeLogs?.forEach(sl => { + sl.logRecords?.forEach(log => allLogs.push(log)); + }); + }); } } +if (allSpans.length > 0) { + console.log('found spans'); + verifySpans(allSpans); + verifiedSpan = true; +} + +if (allMetrics.length > 0) { + console.log('found metrics'); + verifyMetrics(allMetrics); + verifiedMetric = true; +} + +if (allLogs.length > 0) { + console.log('found logs'); + verifyLogs(allLogs); + verifiedLog = true; +} + if (!verifiedSpan) { console.error('No spans found in the output'); process.exit(1); @@ -56,49 +86,60 @@ if (!verifiedLog) { process.exit(1); } -function verifySpan(span) { - const expectedName = 'example-span'; - if (span.name !== expectedName) { - console.error(`Expected span name ${expectedName}, but got '${span.name}'`); +function verifySpans(spans) { + const expectedSpanNames = [ + 'span-before-collector-start', + 'span-after-collector-start', + ]; + + if (spans.length < 2) { + console.error(`Expected at least 2 spans, but got ${spans.length}`); process.exit(1); } -} -function verifyMetric(metric) { - const expectedName = 'example_counter'; - const expectedValue = 42; + const foundSpanNames = spans.map(s => s.name); - if (metric.name !== expectedName) { - console.error( - `Expected metric name ${expectedName}, but got '${metric.name}'` - ); + expectedSpanNames.forEach(expectedName => { + if (!foundSpanNames.includes(expectedName)) { + console.error(`Expected span '${expectedName}' not found`); + process.exit(1); + } + }); +} + +function verifyMetrics(metrics) { + const testCounter = metrics.find(m => m.name === 'test_counter'); + if (!testCounter) { + console.error("Expected metric 'test_counter' not found"); process.exit(1); } - if ( - metric.sum && - metric.sum.dataPoints && - metric.sum.dataPoints[0].asDouble !== expectedValue - ) { + + const dataPoints = testCounter.sum?.dataPoints || []; + if (dataPoints.length < 2) { console.error( - `Expected metric value ${expectedValue}, but got '${metric.sum.dataPoints[0].asDouble}'` + `Expected at least 2 data points, but got ${dataPoints.length}` ); process.exit(1); } } -function verifyLog(log) { - const expectedBody = 'test-log-body'; - if (log.body && log.body.stringValue !== expectedBody) { - console.error( - `Expected log body '${expectedBody}', but got '${log.body.stringValue}'` - ); - process.exit(1); - } +function verifyLogs(logs) { + const expectedLogBodies = [ + 'log-before-collector-start', + 'log-after-collector-start', + ]; - if (log.eventName !== 'test-log-event') { - console.error( - `Expected log event name 'test-log-event', but got '${log.eventName}'` - ); + if (logs.length < 2) { + console.error(`Expected at least 2 logs, but got ${logs.length}`); process.exit(1); } + + const foundLogBodies = logs.map(l => l.body?.stringValue); + + expectedLogBodies.forEach(expectedBody => { + if (!foundLogBodies.includes(expectedBody)) { + console.error(`Expected log '${expectedBody}' not found`); + process.exit(1); + } + }); } diff --git a/experimental/packages/otlp-exporter-base/src/export-response.ts b/experimental/packages/otlp-exporter-base/src/export-response.ts index 4e0eb9703e8..48f18850afd 100644 --- a/experimental/packages/otlp-exporter-base/src/export-response.ts +++ b/experimental/packages/otlp-exporter-base/src/export-response.ts @@ -27,6 +27,7 @@ export interface ExportResponseFailure { export interface ExportResponseRetryable { status: 'retryable'; retryInMillis?: number; + error?: Error; } export type ExportResponse = diff --git a/experimental/packages/otlp-exporter-base/src/is-export-retryable.ts b/experimental/packages/otlp-exporter-base/src/is-export-retryable.ts index 8b4569987bb..fea5d5d24dc 100644 --- a/experimental/packages/otlp-exporter-base/src/is-export-retryable.ts +++ b/experimental/packages/otlp-exporter-base/src/is-export-retryable.ts @@ -14,11 +14,59 @@ * limitations under the License. */ -export function isExportRetryable(statusCode: number): boolean { +export function isExportHTTPErrorRetryable(statusCode: number): boolean { const retryCodes = [429, 502, 503, 504]; return retryCodes.includes(statusCode); } +function getErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + + if ('code' in error && typeof error.code === 'string') { + return error.code; + } + + const err = error as Error; + if (err.cause && typeof err.cause === 'object' && 'code' in err.cause) { + const code = (err.cause as { code: unknown }).code; + if (typeof code === 'string') { + return code; + } + } + + return undefined; +} + +export function isExportNetworkErrorRetryable(error: Error): boolean { + const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + ]); + + if (error.name === 'AbortError') { + return false; + } + + const code = getErrorCode(error); + if (code && RETRYABLE_ERROR_CODES.has(code)) { + return true; + } + + if (error instanceof TypeError && !error.cause) { + return true; + } + + return false; +} + export function parseRetryAfterToMills( retryAfter?: string | undefined | null ): number | undefined { diff --git a/experimental/packages/otlp-exporter-base/src/retrying-transport.ts b/experimental/packages/otlp-exporter-base/src/retrying-transport.ts index c85ae8e07e9..8a924bd66e7 100644 --- a/experimental/packages/otlp-exporter-base/src/retrying-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/retrying-transport.ts @@ -16,6 +16,7 @@ import { IExporterTransport } from './exporter-transport'; import { ExportResponse } from './export-response'; +import { diag } from '@opentelemetry/api'; const MAX_ATTEMPTS = 5; const INITIAL_BACKOFF = 1000; @@ -46,30 +47,42 @@ class RetryingTransport implements IExporterTransport { } async send(data: Uint8Array, timeoutMillis: number): Promise { - const deadline = Date.now() + timeoutMillis; - let result = await this._transport.send(data, timeoutMillis); let attempts = MAX_ATTEMPTS; let nextBackoff = INITIAL_BACKOFF; + const deadline = Date.now() + timeoutMillis; + let result = await this._transport.send(data, timeoutMillis); + while (result.status === 'retryable' && attempts > 0) { attempts--; - + // use maximum of computed backoff and 0 to avoid negative timeouts - const backoff = Math.max( - Math.min(nextBackoff, MAX_BACKOFF) + getJitter(), - 0 - ); + const backoff = Math.max(Math.min(nextBackoff * (1 + getJitter()), MAX_BACKOFF), 0); nextBackoff = nextBackoff * BACKOFF_MULTIPLIER; const retryInMillis = result.retryInMillis ?? backoff; // return when expected retry time is after the export deadline. const remainingTimeoutMillis = deadline - Date.now(); if (retryInMillis > remainingTimeoutMillis) { + diag.info( + `Export retry time ${Math.round(retryInMillis)}ms exceeds remaining timeout ${Math.round( + remainingTimeoutMillis + )}ms, not retrying further.` + ); return result; } - + + diag.verbose(`Scheduling export retry in ${Math.round(retryInMillis)}ms`); result = await this.retry(data, remainingTimeoutMillis, retryInMillis); } + + if (result.status === 'success') { + diag.verbose(`Export succeded after ${MAX_ATTEMPTS - attempts} retry attempts.`); + } else if (result.status === 'retryable') { + diag.info(`Export failed after maximum retry attempts (${MAX_ATTEMPTS}).`); + } else { + diag.info(`Export failed with non-retryable error: ${result.error}`); + } return result; } diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts index 03cf7425ec2..c40f2a32820 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts @@ -18,7 +18,8 @@ import { IExporterTransport } from '../exporter-transport'; import { ExportResponse } from '../export-response'; import { diag } from '@opentelemetry/api'; import { - isExportRetryable, + isExportHTTPErrorRetryable, + isExportNetworkErrorRetryable, parseRetryAfterToMills, } from '../is-export-retryable'; import { HeadersFactory } from '../configuration/otlp-http-configuration'; @@ -53,7 +54,7 @@ class FetchTransport implements IExporterTransport { if (response.status >= 200 && response.status <= 299) { diag.debug('response success'); return { status: 'success' }; - } else if (isExportRetryable(response.status)) { + } else if (isExportHTTPErrorRetryable(response.status)) { const retryAfter = response.headers.get('Retry-After'); const retryInMillis = parseRetryAfterToMills(retryAfter); return { status: 'retryable', retryInMillis }; @@ -63,10 +64,10 @@ class FetchTransport implements IExporterTransport { error: new Error('Fetch request failed with non-retryable status'), }; } catch (error) { - if (error?.name === 'AbortError') { + if (isExportNetworkErrorRetryable(error)) { return { - status: 'failure', - error: new Error('Fetch request timed out', { cause: error }), + status: 'retryable', + error: new Error('Fetch request encountered a network error', { cause: error }), }; } return { diff --git a/experimental/packages/otlp-exporter-base/src/transport/http-transport-utils.ts b/experimental/packages/otlp-exporter-base/src/transport/http-transport-utils.ts index f0675ffdd6e..10121e3d3df 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/http-transport-utils.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/http-transport-utils.ts @@ -19,7 +19,8 @@ import * as zlib from 'zlib'; import { Readable } from 'stream'; import { ExportResponse } from '../export-response'; import { - isExportRetryable, + isExportHTTPErrorRetryable, + isExportNetworkErrorRetryable, parseRetryAfterToMills, } from '../is-export-retryable'; import { OTLPExporterError } from '../types'; @@ -74,7 +75,7 @@ export function sendWithHttp( status: 'success', data: Buffer.concat(responseData), }); - } else if (res.statusCode && isExportRetryable(res.statusCode)) { + } else if (res.statusCode && isExportHTTPErrorRetryable(res.statusCode)) { onDone({ status: 'retryable', retryInMillis: parseRetryAfterToMills(res.headers['retry-after']), @@ -96,16 +97,23 @@ export function sendWithHttp( req.setTimeout(timeoutMillis, () => { req.destroy(); onDone({ - status: 'failure', - error: new Error('Request Timeout'), + status: 'retryable', + error: new Error('Request timed out'), }); }); req.on('error', (error: Error) => { - onDone({ - status: 'failure', - error, - }); + if (isExportNetworkErrorRetryable(error)) { + onDone({ + status: 'retryable', + error, + }); + } else { + onDone({ + status: 'failure', + error, + }); + } }); compressAndSend(req, compression, data, (error: Error) => { diff --git a/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts index cf16c6bff64..42e8a9b8289 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts @@ -18,7 +18,7 @@ import { IExporterTransport } from '../exporter-transport'; import { ExportResponse } from '../export-response'; import { diag } from '@opentelemetry/api'; import { - isExportRetryable, + isExportHTTPErrorRetryable, parseRetryAfterToMills, } from '../is-export-retryable'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -49,7 +49,7 @@ class XhrTransport implements IExporterTransport { xhr.ontimeout = _ => { resolve({ - status: 'failure', + status: 'retryable', error: new Error('XHR request timed out'), }); }; @@ -60,7 +60,7 @@ class XhrTransport implements IExporterTransport { resolve({ status: 'success', }); - } else if (xhr.status && isExportRetryable(xhr.status)) { + } else if (xhr.status && isExportHTTPErrorRetryable(xhr.status)) { resolve({ status: 'retryable', retryInMillis: parseRetryAfterToMills( @@ -83,8 +83,8 @@ class XhrTransport implements IExporterTransport { }; xhr.onerror = () => { resolve({ - status: 'failure', - error: new Error('XHR request errored'), + status: 'retryable', + error: new Error('XHR request encountered a network error'), }); }; diff --git a/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts index 2e7316c6727..2fdea561a57 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts @@ -122,7 +122,7 @@ describe('FetchTransport', function () { }, done /* catch any rejections */); }); - it('returns failure when request times out', function (done) { + it('returns failure when request is aborted', function (done) { // arrange const abortError = new Error('aborted request'); abortError.name = 'AbortError'; @@ -137,7 +137,7 @@ describe('FetchTransport', function () { assert.strictEqual(response.status, 'failure'); assert.strictEqual( (response as ExportResponseFailure).error.message, - 'Fetch request timed out' + 'Fetch request errored' ); } catch (e) { done(e); @@ -147,7 +147,7 @@ describe('FetchTransport', function () { clock.tick(requestTimeout + 100); }); - it('returns failure when no server exists', function (done) { + it('returns failure when fetch throws non-network error', function (done) { // arrange sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed')); const clock = sinon.useFakeTimers(); @@ -169,5 +169,27 @@ describe('FetchTransport', function () { }, done /* catch any rejections */); clock.tick(requestTimeout + 100); }); + + it('returns retryable when browser fetch throws network error', function (done) { + // arrange + // Browser fetch throws TypeError for network errors + sinon.stub(globalThis, 'fetch').rejects(new TypeError('Failed to fetch')); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'retryable'); + assert.strictEqual( + response.error?.message, + 'Fetch request encountered a network error' + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); }); }); diff --git a/experimental/packages/otlp-exporter-base/test/browser/xhr-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/xhr-transport.test.ts index 1fed66d30d7..d26f7ac7e28 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/xhr-transport.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/xhr-transport.test.ts @@ -19,8 +19,8 @@ import * as assert from 'assert'; import { createXhrTransport } from '../../src/transport/xhr-transport'; import { ExportResponseRetryable, - ExportResponseFailure, ExportResponseSuccess, + ExportResponseFailure, } from '../../src'; import { ensureHeadersContain } from '../testHelper'; @@ -58,6 +58,14 @@ function hasOnTimeout(request: unknown): request is { ontimeout: () => void } { return 'ontimeout' in request && typeof request['ontimeout'] === 'function'; } +function hasOnAbort(request: unknown): request is { onabort: () => void } { + if (request == null || typeof request != 'object') { + return false; + } + + return 'onabort' in request && typeof request['onabort'] === 'function'; +} + describe('XhrTransport', function () { afterEach(function () { sinon.restore(); @@ -151,7 +159,7 @@ describe('XhrTransport', function () { }, done /* catch any rejections */); }); - it('returns failure when request times out', function (done) { + it('returns retryable when request times out', function (done) { // arrange // A fake server needed, otherwise the message will not be a timeout but a failure to connect. const server = sinon.useFakeServer(); @@ -170,10 +178,10 @@ describe('XhrTransport', function () { transport.send(testPayload, requestTimeout).then(response => { // assert try { - assert.strictEqual(response.status, 'failure'); + assert.strictEqual(response.status, 'retryable'); assert.strictEqual( - (response as ExportResponseFailure).error.message, - 'XHR request timed out' + (response as ExportResponseRetryable).retryInMillis, + 0 ); } catch (e) { done(e); @@ -182,11 +190,42 @@ describe('XhrTransport', function () { }, done /* catch any rejections */); }); - it('returns failure when no server exists', function (done) { + it('returns retryable when network error occurs', function (done) { // arrange const clock = sinon.useFakeTimers(); const transport = createXhrTransport(testTransportParameters); + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'retryable'); + assert.strictEqual( + (response as ExportResponseRetryable).retryInMillis, + 0 + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + + it('returns failure when request is aborted', function (done) { + // arrange + const server = sinon.useFakeServer(); + const transport = createXhrTransport(testTransportParameters); + + respondWhenRequestExists(server, () => { + // this executes after the act block + const request = server.requests[0]; + // Manually trigger the onabort event + if (hasOnAbort(request)) { + request.onabort(); + } + }); + //act transport.send(testPayload, requestTimeout).then(response => { // assert @@ -194,14 +233,13 @@ describe('XhrTransport', function () { assert.strictEqual(response.status, 'failure'); assert.strictEqual( (response as ExportResponseFailure).error.message, - 'XHR request errored' + 'XHR request aborted' ); } catch (e) { done(e); } done(); }, done /* catch any rejections */); - clock.tick(requestTimeout + 100); }); }); }); diff --git a/experimental/packages/otlp-exporter-base/test/common/is-export-retryable.test.ts b/experimental/packages/otlp-exporter-base/test/common/is-export-retryable.test.ts index 458bf75a761..1b797ca3411 100644 --- a/experimental/packages/otlp-exporter-base/test/common/is-export-retryable.test.ts +++ b/experimental/packages/otlp-exporter-base/test/common/is-export-retryable.test.ts @@ -15,7 +15,11 @@ */ import * as sinon from 'sinon'; import * as assert from 'assert'; -import { parseRetryAfterToMills } from '../../src/is-export-retryable'; +import { + isExportHTTPErrorRetryable, + isExportNetworkErrorRetryable, + parseRetryAfterToMills, +} from '../../src/is-export-retryable'; describe('parseRetryAfterToMills', function () { // now: 2023-01-20T00:00:00.000Z @@ -43,3 +47,75 @@ describe('parseRetryAfterToMills', function () { }); } }); + +describe('isExportHTTPErrorRetryable', function () { + it('should return true for retryable status codes', function () { + assert.strictEqual(isExportHTTPErrorRetryable(429), true); + assert.strictEqual(isExportHTTPErrorRetryable(502), true); + assert.strictEqual(isExportHTTPErrorRetryable(503), true); + assert.strictEqual(isExportHTTPErrorRetryable(504), true); + }); + + it('should return false for non-retryable status codes', function () { + assert.strictEqual(isExportHTTPErrorRetryable(200), false); + assert.strictEqual(isExportHTTPErrorRetryable(201), false); + assert.strictEqual(isExportHTTPErrorRetryable(400), false); + assert.strictEqual(isExportHTTPErrorRetryable(401), false); + assert.strictEqual(isExportHTTPErrorRetryable(404), false); + assert.strictEqual(isExportHTTPErrorRetryable(500), false); + }); +}); + +describe('isExportNetworkErrorRetryable', function () { + it('should return false for AbortError', function () { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + assert.strictEqual(isExportNetworkErrorRetryable(error), false); + }); + + it('should return true for Node.js system errors with error.code', function () { + const retryableCodes = [ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + ]; + + for (const code of retryableCodes) { + const error = new Error('Network error') as any; + error.code = code; + assert.strictEqual( + isExportNetworkErrorRetryable(error), + true, + `Expected ${code} to be retryable` + ); + } + }); + + it('should return true for browser fetch TypeError without cause', function () { + // Browser fetch throws TypeError for network errors without cause + const error = new TypeError('Failed to fetch'); + assert.strictEqual(isExportNetworkErrorRetryable(error), true); + }); + + it('should return false for TypeError with non-retryable cause', function () { + const cause = new Error('Invalid argument'); + const error = new TypeError('fetch failed', { cause }); + assert.strictEqual(isExportNetworkErrorRetryable(error), false); + }); + + it('should return false for non-network errors', function () { + const error = new Error('Some other error'); + assert.strictEqual(isExportNetworkErrorRetryable(error), false); + }); + + it('should return false for errors with non-retryable codes', function () { + const error = new Error('Invalid argument') as NodeJS.ErrnoException; + error.code = 'ERR_INVALID_ARG'; + assert.strictEqual(isExportNetworkErrorRetryable(error), false); + }); +}); diff --git a/experimental/packages/otlp-exporter-base/test/node/http-exporter-transport.test.ts b/experimental/packages/otlp-exporter-base/test/node/http-exporter-transport.test.ts index 30d464bd185..594ad2fd34f 100644 --- a/experimental/packages/otlp-exporter-base/test/node/http-exporter-transport.test.ts +++ b/experimental/packages/otlp-exporter-base/test/node/http-exporter-transport.test.ts @@ -195,7 +195,7 @@ describe('HttpExporterTransport', function () { ); }); - it('returns failure when request times out', function (done) { + it('returns retryable when request times out', function (done) { // arrange const timer = sinon.useFakeTimers(); server = http.createServer((_, res) => { @@ -218,10 +218,11 @@ describe('HttpExporterTransport', function () { .send(sampleRequestData, 100) .then(result => { // assert - assert.strictEqual(result.status, 'failure'); + assert.strictEqual(result.status, 'retryable'); + assert.ok(result.error, 'Expected error object to be present'); assert.strictEqual( - (result as ExportResponseFailure).error.message, - 'Request Timeout' + result.error.message, + 'Request timed out' ); done(); }) @@ -232,7 +233,7 @@ describe('HttpExporterTransport', function () { timer.tick(200); }); - it('returns failure when socket hangs up', async function () { + it('returns retryable when socket hangs up (ECONNRESET)', async function () { // arrange server = http.createServer((_, res) => { res.destroy(); @@ -250,14 +251,38 @@ describe('HttpExporterTransport', function () { const result = await transport.send(sampleRequestData, 100); // assert - assert.strictEqual(result.status, 'failure'); - assert.strictEqual( - (result as ExportResponseFailure).error.message, - 'socket hang up' - ); + assert.strictEqual(result.status, 'retryable'); + assert.ok(result.error, 'Expected error object to be present'); + assert.strictEqual((result.error as NodeJS.ErrnoException).code, 'ECONNRESET'); + assert.strictEqual(result.error?.message, 'socket hang up'); + }); + + it('returns retryable on connection refused (ECONNREFUSED)', async function () { + // arrange + server = http.createServer(); + await new Promise(resolve => server!.listen(0, resolve)); + const port = (server!.address() as any).port; + await new Promise(resolve => server!.close(resolve as any)); + server = undefined; + + const transport = createHttpExporterTransport({ + url: `http://localhost:${port}`, + headers: async () => ({}), + compression: 'none', + agentFactory: () => new http.Agent(), + }); + + // act + const result = await transport.send(sampleRequestData, 50); + + // assert + assert.strictEqual(result.status, 'retryable'); + assert.ok(result.error, 'Expected error object to be present'); + assert.strictEqual((result.error as NodeJS.ErrnoException).code, 'ECONNREFUSED'); + assert.strictEqual(result.error?.message.includes('connect ECONNREFUSED'), true); }); - it('returns failure when server does not exist', async function () { + it('returns retryable when server does not exist (ENOTFOUND)', async function () { // arrange const transport = createHttpExporterTransport({ // use wrong port @@ -271,10 +296,12 @@ describe('HttpExporterTransport', function () { const result = await transport.send(sampleRequestData, 100); // assert - assert.strictEqual(result.status, 'failure'); + assert.strictEqual(result.status, 'retryable'); + assert.ok(result.error, 'Expected error object to be present'); + assert.strictEqual((result.error as NodeJS.ErrnoException).code, 'ENOTFOUND'); assert.strictEqual( - (result as ExportResponseFailure).error.message, - 'getaddrinfo ENOTFOUND example.test' + result.error?.message.includes('getaddrinfo ENOTFOUND'), + true ); });