diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index a9bd8d8d16..96e877e49f 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to experimental packages in this project will be documented * feat(instrumentation): Make `init()` method public [#4418](https://github.com/open-telemetry/opentelemetry-js/pull/4418) * feat(exporter-metrics-otlp-http): add option to set the exporter aggregation preference [#4409](https://github.com/open-telemetry/opentelemetry-js/pull/4409) @AkselAllas * feat(node-sdk): add spanProcessors option [#4454](https://github.com/open-telemetry/opentelemetry-js/pull/4454) @naseemkullah +* feat(otlp-exporter-base): Add fetch sender for WebWorker and ServiceWorker environment. [#3542](https://github.com/open-telemetry/opentelemetry-js/pull/3542) @sugi ### :bug: (Bug Fix) diff --git a/experimental/packages/exporter-trace-otlp-http/package.json b/experimental/packages/exporter-trace-otlp-http/package.json index a06869791a..5465b8284a 100644 --- a/experimental/packages/exporter-trace-otlp-http/package.json +++ b/experimental/packages/exporter-trace-otlp-http/package.json @@ -75,6 +75,7 @@ "codecov": "3.8.3", "cpx": "1.5.0", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", diff --git a/experimental/packages/exporter-trace-otlp-http/test/browser/CollectorTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-http/test/browser/CollectorTraceExporter.test.ts index 4e8bc1d6e1..88cee54e75 100644 --- a/experimental/packages/exporter-trace-otlp-http/test/browser/CollectorTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-http/test/browser/CollectorTraceExporter.test.ts @@ -33,6 +33,8 @@ import { OTLPExporterError, } from '@opentelemetry/otlp-exporter-base'; import { IExportTraceServiceRequest } from '@opentelemetry/otlp-transformer'; +import { FetchMockStatic, MockCall } from 'fetch-mock/esm/client'; +const fetchMock = require('fetch-mock/esm/client').default as FetchMockStatic; describe('OTLPTraceExporter - web', () => { let collectorTraceExporter: OTLPTraceExporter; @@ -270,6 +272,140 @@ describe('OTLPTraceExporter - web', () => { }); }); }); + + describe('when both "sendBeacon" and "XMLHTTPRequest" are NOT available (service worker env)', () => { + let clock: sinon.SinonFakeTimers; + let stubXhr: sinon.SinonStub; + const url = 'http://foo.bar.com'; + beforeEach(() => { + clock = sinon.useFakeTimers(); + + stubBeacon.value(undefined); + stubXhr = sinon.stub(globalThis, 'XMLHttpRequest').value(undefined); + fetchMock.mock(url, 200); + collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); + }); + afterEach(() => { + sinon.restore(); + clock.restore(); + fetchMock.restore(); + }); + + it('should successfully send the spans using fetch', done => { + collectorTraceExporter.export(spans, () => { + try { + assert.ok(fetchMock.called(url)); + const request = fetchMock.lastCall(url)?.[1]; + assert.ok(request); + assert.strictEqual(request.method, 'POST'); + + const body = request.body?.toString(); + assert.ok(body); + const json = JSON.parse(body) as IExportTraceServiceRequest; + const span1 = json.resourceSpans?.[0].scopeSpans?.[0].spans?.[0]; + + assert.ok(typeof span1 !== 'undefined', "span doesn't exist"); + ensureSpanIsCorrect(span1); + + const resource = json.resourceSpans?.[0].resource; + assert.ok( + typeof resource !== 'undefined', + "resource doesn't exist" + ); + ensureWebResourceIsCorrect(resource); + + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + ensureExportTraceServiceRequestIsSet(json); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should log the successful message', done => { + const spyLoggerDebug = sinon.stub(); + const spyLoggerError = sinon.stub(); + const nop = () => {}; + const diagLogger: DiagLogger = { + debug: spyLoggerDebug, + error: spyLoggerError, + info: nop, + verbose: nop, + warn: nop, + }; + + diag.setLogger(diagLogger, DiagLogLevel.ALL); + + collectorTraceExporter.export(spans, () => { + try { + assert.strictEqual(fetchMock.calls('*').length, 1); + assert.ok(fetchMock.called(url)); + + assert.strictEqual( + spyLoggerDebug.args[spyLoggerDebug.callCount - 1][0], + 'Request Success' + ); + assert.strictEqual(spyLoggerError.args.length, 0); + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should log the error message', done => { + fetchMock.restore(); + fetchMock.mock(url, 400); + collectorTraceExporter.export(spans, result => { + try { + assert.ok(fetchMock.called(url)); + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.ok(result.error?.message.includes('Failed to export')); + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should send custom headers', done => { + collectorTraceExporter = new OTLPTraceExporter( + Object.assign({}, collectorExporterConfig, { + headers: { + 'My-Custom-Header1': '123', + 'My-Custom-Header2': 'abc', + }, + }) + ); + + collectorTraceExporter.export(spans, () => { + try { + assert.ok(fetchMock.called(url)); + const request = fetchMock.lastCall(url)?.[1]; + assert.ok(request); + assert.ok(request.headers); + const sentHeadersObj = Object.fromEntries( + Object.entries(request.headers) + ); + assert.strictEqual(sentHeadersObj['My-Custom-Header1'], '123'); + assert.strictEqual(sentHeadersObj['My-Custom-Header2'], 'abc'); + + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + }); }); describe('export - common', () => { @@ -432,6 +568,66 @@ describe('OTLPTraceExporter - web', () => { }); }); }); + + describe('when both "sendBeacon" and "XMLHttpRequest" are NOT available', () => { + let clock: sinon.SinonFakeTimers; + let stubXhr: sinon.SinonStub; + beforeEach(() => { + clock = sinon.useFakeTimers(); + + stubBeacon.value(undefined); + stubXhr = sinon.stub(globalThis, 'XMLHttpRequest').value(undefined); + fetchMock.mock('*', 200); + collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); + }); + afterEach(() => { + sinon.restore(); + clock.restore(); + fetchMock.restore(); + }); + + const assertRequestHeaders = ( + call: MockCall | undefined, + expected: Record + ) => { + assert.ok(call); + const headers = call[1]?.headers; + assert.ok(headers, 'invalid header'); + ensureHeadersContain( + Object.fromEntries(Object.entries(headers)), + expected + ); + }; + + it('should successfully send spans using fetch', done => { + collectorTraceExporter.export(spans, () => { + try { + assert.ok(fetchMock.called('*')); + assertRequestHeaders(fetchMock.lastCall('*'), customHeaders); + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubOpen.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should log the timeout request error message', done => { + collectorTraceExporter.export(spans, result => { + try { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + done(); + } catch (e) { + done(e); + } + }); + clock.tick(10000); + }); + }); }); describe('export - concurrency limit', () => { @@ -701,4 +897,108 @@ describe('export with retry - real http request destroyed', () => { }); }).timeout(3000); }); + + describe('when both "sendBeacon" and "XMLHttpRequest" are NOT available', () => { + const url = 'http://localhost:4318/v1/traces'; + let clock: sinon.SinonFakeTimers | undefined; + beforeEach(() => { + (window.navigator as any).sendBeacon = false; + sinon.stub(globalThis, 'XMLHttpRequest').value(undefined); + collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); + }); + afterEach(() => { + clock?.restore(); + fetchMock.reset(); + }); + it('should log the timeout request error message when retrying with exponential backoff with jitter', done => { + spans = []; + spans.push(Object.assign({}, mockedReadableSpan)); + clock = sinon.useFakeTimers(); + + let tries = 0; + fetchMock.mock(url, () => { + tries++; + return 503; + }); + + collectorTraceExporter.export(spans, result => { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + assert.strictEqual(tries, 1); + done(); + }); + clock.tick(2000); + }).timeout(3000); + + it('should log the timeout request error message when retry-after header is set to 3 seconds', done => { + spans = []; + spans.push(Object.assign({}, mockedReadableSpan)); + clock = sinon.useFakeTimers(); + + let retry = 0; + fetchMock.mock(url, () => { + retry++; + return { status: 503, headers: { 'Retry-After': 3 } }; + }); + + collectorTraceExporter.export(spans, result => { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + assert.strictEqual(retry, 1); + done(); + }); + clock.tick(2000); + }).timeout(3000); + + it('should log the timeout request error message when retry-after header is a date', async () => { + spans = []; + spans.push(Object.assign({}, mockedReadableSpan)); + + let tries = 0; + fetchMock.mock(url, () => { + tries++; + const d = new Date(); + d.setSeconds(d.getSeconds() + 1); + return { status: 503, headers: { 'Retry-After': d.toUTCString() } }; + }); + + await new Promise(r => setTimeout(r, 1000 - (Date.now() % 1000))); // wait to start export exactly in seconds + return new Promise(resolve => { + collectorTraceExporter.export(spans, result => { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + assert.strictEqual(tries, 2); + resolve(); + }); + }); + }).timeout(3000); + + it('should log the timeout request error message when retry-after header is a date with long delay', done => { + spans = []; + spans.push(Object.assign({}, mockedReadableSpan)); + + let tries = 0; + fetchMock.mock(url, () => { + tries++; + const d = new Date(); + d.setSeconds(d.getSeconds() + 120); + return { status: 503, headers: { 'Retry-After': d.toUTCString() } }; + }); + + collectorTraceExporter.export(spans, result => { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + assert.strictEqual(tries, 1); + done(); + }); + }).timeout(3000); + }); }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/CollectorMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/CollectorMetricExporter.test.ts index cc112a9b55..eb3eb74ba9 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/CollectorMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/CollectorMetricExporter.test.ts @@ -47,6 +47,8 @@ import { } from '../../src'; import { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; import { IExportMetricsServiceRequest } from '@opentelemetry/otlp-transformer'; +import { FetchMockStatic, MockCall } from 'fetch-mock/esm/client'; +const fetchMock = require('fetch-mock/esm/client').default as FetchMockStatic; describe('OTLPMetricExporter - web', () => { let collectorExporter: OTLPMetricExporter; @@ -345,6 +347,179 @@ describe('OTLPMetricExporter - web', () => { }); }); }); + describe('when both "sendBeacon" and "XMLHttpRequest" are NOT available (service worker env)', () => { + let stubXhr: sinon.SinonStub; + const url = 'http://foo.bar.com'; + beforeEach(() => { + stubBeacon.value(undefined); + stubXhr = sinon.stub(globalThis, 'XMLHttpRequest').value(undefined); + collectorExporter = new OTLPMetricExporter({ + url, + temporalityPreference: AggregationTemporalityPreference.CUMULATIVE, + }); + // Overwrites the start time to make tests consistent + Object.defineProperty(collectorExporter, '_startTime', { + value: 1592602232694000000, + }); + fetchMock.mock(url, 200); + }); + afterEach(() => { + sinon.restore(); + fetchMock.restore(); + }); + + it('should successfully send the metrics using fetch', done => { + collectorExporter.export(metrics, () => { + try { + assert.ok(fetchMock.called(url)); + const request = fetchMock.lastCall(url)?.[1]; + assert.ok(request); + assert.strictEqual(request.method, 'POST'); + + const body = request.body?.toString(); + assert.ok(body); + + const json = JSON.parse(body) as IExportMetricsServiceRequest; + // The order of the metrics is not guaranteed. + const counterIndex = metrics.scopeMetrics[0].metrics.findIndex( + it => it.descriptor.name === 'int-counter' + ); + const observableIndex = metrics.scopeMetrics[0].metrics.findIndex( + it => it.descriptor.name === 'double-observable-gauge2' + ); + const histogramIndex = metrics.scopeMetrics[0].metrics.findIndex( + it => it.descriptor.name === 'int-histogram' + ); + + const metric1 = + json.resourceMetrics[0].scopeMetrics[0].metrics[counterIndex]; + const metric2 = + json.resourceMetrics[0].scopeMetrics[0].metrics[observableIndex]; + const metric3 = + json.resourceMetrics[0].scopeMetrics[0].metrics[histogramIndex]; + + assert.ok(typeof metric1 !== 'undefined', "metric doesn't exist"); + ensureCounterIsCorrect( + metric1, + metrics.scopeMetrics[0].metrics[counterIndex].dataPoints[0] + .endTime, + metrics.scopeMetrics[0].metrics[counterIndex].dataPoints[0] + .startTime + ); + + assert.ok( + typeof metric2 !== 'undefined', + "second metric doesn't exist" + ); + ensureObservableGaugeIsCorrect( + metric2, + metrics.scopeMetrics[0].metrics[observableIndex].dataPoints[0] + .endTime, + metrics.scopeMetrics[0].metrics[observableIndex].dataPoints[0] + .startTime, + 6, + 'double-observable-gauge2' + ); + + assert.ok( + typeof metric3 !== 'undefined', + "third metric doesn't exist" + ); + ensureHistogramIsCorrect( + metric3, + metrics.scopeMetrics[0].metrics[histogramIndex].dataPoints[0] + .endTime, + metrics.scopeMetrics[0].metrics[histogramIndex].dataPoints[0] + .startTime, + [0, 100], + [0, 2, 0] + ); + + const resource = json.resourceMetrics[0].resource; + assert.ok( + typeof resource !== 'undefined', + "resource doesn't exist" + ); + ensureWebResourceIsCorrect(resource); + + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + ensureExportMetricsServiceRequestIsSet(json); + + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should log the successful message', done => { + collectorExporter.export(metrics, () => { + try { + assert.strictEqual(fetchMock.calls('*').length, 1); + assert.ok(fetchMock.called(url)); + + assert.strictEqual( + debugStub.args[debugStub.callCount - 1][0], + 'Request Success' + ); + assert.strictEqual(errorStub.args.length, 0); + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should log the error message', done => { + fetchMock.restore(); + fetchMock.mock('*', 400); + collectorExporter.export(metrics, result => { + try { + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.ok(result.error?.message.includes('Failed to export')); + assert.strictEqual(stubBeacon.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should send custom headers', done => { + collectorExporter = new OTLPMetricExporter({ + url, + temporalityPreference: AggregationTemporalityPreference.CUMULATIVE, + headers: { + 'My-Custom-Header1': '123', + 'My-Custom-Header2': 'abc', + }, + }); + collectorExporter.export(metrics, () => { + try { + assert.ok(fetchMock.called(url)); + const request = fetchMock.lastCall(url)?.[1]; + assert.ok(request); + assert.ok(request.headers); + const sentHeadersObj = Object.fromEntries( + Object.entries(request.headers) + ); + assert.strictEqual(sentHeadersObj['My-Custom-Header1'], '123'); + assert.strictEqual(sentHeadersObj['My-Custom-Header2'], 'abc'); + + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + }); }); describe('export with custom headers', () => { @@ -408,6 +583,64 @@ describe('OTLPMetricExporter - web', () => { }); }); }); + + describe('when both "sendBeacon" and "XMLHttpRequest" are NOT available', () => { + let clock: sinon.SinonFakeTimers; + let stubXhr: sinon.SinonStub; + beforeEach(() => { + clock = sinon.useFakeTimers(); + stubBeacon.value(undefined); + stubXhr = sinon.stub(globalThis, 'XMLHttpRequest').value(undefined); + fetchMock.mock('*', 200); + collectorExporter = new OTLPMetricExporter(collectorExporterConfig); + }); + afterEach(() => { + clock.restore(); + fetchMock.restore(); + }); + + const assertRequesetHeaders = ( + call: MockCall | undefined, + expected: Record + ) => { + assert.ok(call); + const headers = call[1]?.headers; + assert.ok(headers, 'invalid header'); + ensureHeadersContain( + Object.fromEntries(Object.entries(headers)), + expected + ); + }; + + it('should successfully send metrics using fetch', done => { + collectorExporter.export(metrics, () => { + try { + assert.ok(fetchMock.called('*')); + assertRequesetHeaders(fetchMock.lastCall('*'), customHeaders); + assert.strictEqual(stubBeacon.callCount, 0); + assert.strictEqual(stubOpen.callCount, 0); + assert.strictEqual(stubXhr.callCount, 0); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should log the timeout request error message', done => { + collectorExporter.export(metrics, result => { + try { + assert.strictEqual(result.code, ExportResultCode.FAILED); + const error = result.error; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'Request Timeout'); + done(); + } catch (e) { + done(e); + } + }); + clock.tick(10000); + }); + }); }); }); diff --git a/experimental/packages/otlp-exporter-base/package.json b/experimental/packages/otlp-exporter-base/package.json index ce41ac2f0f..705319b27b 100644 --- a/experimental/packages/otlp-exporter-base/package.json +++ b/experimental/packages/otlp-exporter-base/package.json @@ -73,6 +73,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts index dbe02b222d..55b1a4ead4 100644 --- a/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts @@ -18,10 +18,16 @@ import { OTLPExporterBase } from '../../OTLPExporterBase'; import { OTLPExporterConfigBase } from '../../types'; import * as otlpTypes from '../../types'; import { parseHeaders } from '../../util'; -import { sendWithBeacon, sendWithXhr } from './util'; +import { sendWithBeacon, sendWithFetch, sendWithXhr } from './util'; import { diag } from '@opentelemetry/api'; import { getEnv, baggageUtils, _globalThis } from '@opentelemetry/core'; +enum SendMethod { + beacon = 1, + xhr = 2, + fetch = 3, +} + /** * Collector Metric Exporter abstract base class */ @@ -30,16 +36,21 @@ export abstract class OTLPExporterBrowserBase< ServiceRequest, > extends OTLPExporterBase { protected _headers: Record; - private _useXHR: boolean = false; + private sendMethod: SendMethod; /** * @param config */ constructor(config: OTLPExporterConfigBase = {}) { super(config); - this._useXHR = - !!config.headers || typeof navigator.sendBeacon !== 'function'; - if (this._useXHR) { + if (!config.headers && typeof navigator.sendBeacon === 'function') { + this.sendMethod = SendMethod.beacon; + } else if (typeof XMLHttpRequest === 'function') { + this.sendMethod = SendMethod.xhr; + } else { + this.sendMethod = SendMethod.fetch; + } + if (this.sendMethod !== SendMethod.beacon) { this._headers = Object.assign( {}, parseHeaders(config.headers), @@ -73,7 +84,7 @@ export abstract class OTLPExporterBrowserBase< const body = JSON.stringify(serviceRequest); const promise = new Promise((resolve, reject) => { - if (this._useXHR) { + if (this.sendMethod === SendMethod.xhr) { sendWithXhr( body, this.url, @@ -82,7 +93,16 @@ export abstract class OTLPExporterBrowserBase< resolve, reject ); - } else { + } else if (this.sendMethod === SendMethod.fetch) { + sendWithFetch( + body, + this.url, + this._headers, + this.timeoutMillis, + resolve, + reject + ); + } else if (this.sendMethod === SendMethod.beacon) { sendWithBeacon( body, this.url, diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts index fade4afa88..480b1219da 100644 --- a/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts @@ -24,6 +24,11 @@ import { parseRetryAfterToMills, } from '../../util'; +const defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + /** * Send metrics/spans using browser navigator.sendBeacon * @param body @@ -88,11 +93,6 @@ export function sendWithXhr( xhr = new XMLHttpRequest(); xhr.open('POST', url); - const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - Object.entries({ ...defaultHeaders, ...headers, @@ -161,3 +161,102 @@ export function sendWithXhr( sendWithRetry(); } + +/** + * function to send metrics/spans using browser fetch + * used when navigator.sendBeacon and XMLHttpRequest are not available + * @param body + * @param url + * @param headers + * @param onSuccess + * @param onError + */ +export function sendWithFetch( + body: string, + url: string, + headers: Record, + exporterTimeout: number, + onSuccess: () => void, + onError: (error: OTLPExporterError) => void +): void { + const controller = new AbortController(); + let cancelRetry: ((e: OTLPExporterError) => void) | undefined; + const exporterTimer = setTimeout(() => { + controller.abort(); + cancelRetry?.(new OTLPExporterError('Request Timeout')); + }, exporterTimeout); + + const fetchWithRetry = ( + retries = DEFAULT_EXPORT_MAX_ATTEMPTS, + minDelay = DEFAULT_EXPORT_INITIAL_BACKOFF + ) => { + return fetch(url, { + method: 'POST', + headers: { + ...defaultHeaders, + ...headers, + }, + signal: controller.signal, + body, + }).then( + response => { + if (response.status >= 200 && response.status <= 299) { + diag.debug('Request Success'); + return; + } else if ( + response.status && + isExportRetryable(response.status) && + retries > 0 + ) { + let retryTime: number; + minDelay = DEFAULT_EXPORT_BACKOFF_MULTIPLIER * minDelay; + + // retry after interval specified in Retry-After header + if (response.headers.has('Retry-After')) { + retryTime = parseRetryAfterToMills( + response.headers.get('Retry-After') + ); + } else { + // exponential backoff with jitter + retryTime = Math.round( + Math.random() * (DEFAULT_EXPORT_MAX_BACKOFF - minDelay) + minDelay + ); + } + + return new Promise((resolve, reject) => { + const retryTimer = setTimeout(() => { + cancelRetry = undefined; + fetchWithRetry(retries - 1, minDelay).then(resolve, reject); + }, retryTime); + cancelRetry = e => { + clearTimeout(retryTimer); + reject(e); + }; + }); + } else { + return Promise.reject( + new OTLPExporterError( + `Failed to export with fetch: (${response.status} ${response.statusText})`, + response.status + ) + ); + } + }, + (e: Error) => { + if (e.name === 'AbortError') { + return Promise.reject(new OTLPExporterError('Request Timeout')); + } else { + return Promise.reject( + new OTLPExporterError(`Request Fail: ${e.name} ${e.message}`) + ); + } + } + ); + }; + fetchWithRetry() + .then( + () => onSuccess(), + e => onError(e) + ) + .finally(() => clearTimeout(exporterTimer)); +} diff --git a/experimental/packages/otlp-exporter-base/test/browser/util.test.ts b/experimental/packages/otlp-exporter-base/test/browser/util.test.ts index 1dd3b77d58..cc571768a0 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/util.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/util.test.ts @@ -14,33 +14,24 @@ * limitations under the License. */ +import * as assert from 'assert'; import * as sinon from 'sinon'; -import { sendWithXhr } from '../../src/platform/browser/util'; +import { sendWithFetch, sendWithXhr } from '../../src/platform/browser/util'; import { nextTick } from 'process'; import { ensureHeadersContain } from '../testHelper'; +import { FetchMockStatic, MockCall } from 'fetch-mock/esm/client'; +const fetchMock = require('fetch-mock/esm/client').default as FetchMockStatic; describe('util - browser', () => { - let server: any; const body = ''; const url = ''; - let onSuccessStub: sinon.SinonStub; - let onErrorStub: sinon.SinonStub; - - beforeEach(() => { - onSuccessStub = sinon.stub(); - onErrorStub = sinon.stub(); - server = sinon.fakeServer.create(); - }); - - afterEach(() => { - server.restore(); - sinon.restore(); - }); - describe('when XMLHTTPRequest is used', () => { + let server: any; let expectedHeaders: Record; let clock: sinon.SinonFakeTimers; + let onSuccessStub: sinon.SinonStub; + let onErrorStub: sinon.SinonStub; beforeEach(() => { // fakeTimers is used to replace the next setTimeout which is // located in sendWithXhr function called by the export method @@ -51,6 +42,13 @@ describe('util - browser', () => { 'Content-Type': 'application/json;charset=utf-8', Accept: 'application/json', }; + onSuccessStub = sinon.stub(); + onErrorStub = sinon.stub(); + server = sinon.fakeServer.create(); + }); + afterEach(() => { + server.restore(); + sinon.restore(); }); describe('and Content-Type header is set', () => { beforeEach(() => { @@ -156,4 +154,106 @@ describe('util - browser', () => { }); }); }); + + describe('when fetch is used', () => { + let clock: sinon.SinonFakeTimers; + + const assertRequesetHeaders = ( + call: MockCall | undefined, + expected: Record + ) => { + assert.ok(call); + const headers = call[1]?.headers; + assert.ok(headers, 'invalid header'); + ensureHeadersContain( + Object.fromEntries(Object.entries(headers)), + expected + ); + }; + + beforeEach(() => { + // fakeTimers is used to replace the next setTimeout which is + // located in sendWithXhr function called by the export method + clock = sinon.useFakeTimers(); + + fetchMock.mock(url, {}); + }); + afterEach(() => { + fetchMock.restore(); + clock.restore(); + }); + describe('and Content-Type header is set', () => { + beforeEach(done => { + const explicitContentType = { + 'Content-Type': 'application/json', + }; + const exporterTimeout = 10000; + sendWithFetch( + body, + url, + explicitContentType, + exporterTimeout, + done, + done + ); + }); + it('Request Headers should contain "Content-Type" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + 'Content-Type': 'application/json', + }); + }); + it('Request Headers should contain "Accept" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + Accept: 'application/json', + }); + }); + }); + + describe('and empty headers are set', () => { + beforeEach(done => { + const emptyHeaders = {}; + // use default exporter timeout + const exporterTimeout = 10000; + sendWithFetch(body, url, emptyHeaders, exporterTimeout, done, done); + }); + it('Request Headers should contain "Content-Type" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + 'Content-Type': 'application/json', + }); + }); + it('Request Headers should contain "Accept" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + Accept: 'application/json', + }); + }); + }); + describe('and custom headers are set', () => { + let customHeaders: Record; + beforeEach(done => { + customHeaders = { aHeader: 'aValue', bHeader: 'bValue' }; + const exporterTimeout = 10000; + sendWithFetch(body, url, customHeaders, exporterTimeout, done, done); + }); + it('Request Headers should contain "Content-Type" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + 'Content-Type': 'application/json', + }); + }); + it('Request Headers should contain "Accept" header', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), { + Accept: 'application/json', + }); + }); + it('Request Headers should contain custom headers', () => { + assert.ok(fetchMock.called(url)); + assertRequesetHeaders(fetchMock.lastCall(url), customHeaders); + }); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index daff8df12e..6e2aa44ec2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1777,6 +1777,7 @@ "codecov": "3.8.3", "cpx": "1.5.0", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -4226,6 +4227,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -17949,6 +17951,91 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-mock": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", + "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.0.0", + "@babel/runtime": "^7.0.0", + "core-js": "^3.0.0", + "debug": "^4.1.1", + "glob-to-regexp": "^0.4.0", + "is-subset": "^0.1.1", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "querystring": "^0.2.0", + "whatwg-url": "^6.5.0" + }, + "engines": { + "node": ">=4.0.0" + }, + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + } + }, + "node_modules/fetch-mock/node_modules/core-js": { + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.33.2.tgz", + "integrity": "sha512-XeBzWI6QL3nJQiHmdzbAOiMYqjrb7hwU7A39Qhvd/POSa/t9E1AeZyEZx3fNvp/vtM8zXwhoL0FsiS0hD0pruQ==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/fetch-mock/node_modules/path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + }, + "node_modules/fetch-mock/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fetch-mock/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/fetch-mock/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "node_modules/fetch-mock/node_modules/whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/figgy-pudding": { "version": "3.5.2", "dev": true, @@ -20402,6 +20489,12 @@ "node": ">=8" } }, + "node_modules/is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, "node_modules/is-text-path": { "version": "1.0.1", "dev": true, @@ -22248,6 +22341,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "dev": true, @@ -22277,6 +22376,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/lodash.union": { "version": "4.6.0", "dev": true, @@ -27371,6 +27476,16 @@ "node": ">=0.6" } }, + "node_modules/querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/querystring-es3": { "version": "0.2.1", "dev": true, @@ -33849,6 +33964,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "^9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -40179,6 +40295,7 @@ "codecov": "3.8.3", "cpx": "1.5.0", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -40522,6 +40639,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "^9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -41713,6 +41831,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", @@ -48991,6 +49110,70 @@ "pend": "~1.2.0" } }, + "fetch-mock": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", + "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", + "dev": true, + "requires": { + "@babel/core": "^7.0.0", + "@babel/runtime": "^7.0.0", + "core-js": "^3.0.0", + "debug": "^4.1.1", + "glob-to-regexp": "^0.4.0", + "is-subset": "^0.1.1", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "querystring": "^0.2.0", + "whatwg-url": "^6.5.0" + }, + "dependencies": { + "core-js": { + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.33.2.tgz", + "integrity": "sha512-XeBzWI6QL3nJQiHmdzbAOiMYqjrb7hwU7A39Qhvd/POSa/t9E1AeZyEZx3fNvp/vtM8zXwhoL0FsiS0hD0pruQ==", + "dev": true + }, + "path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + } + } + }, "figgy-pudding": { "version": "3.5.2", "dev": true, @@ -50593,6 +50776,12 @@ "version": "2.0.0", "dev": true }, + "is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, "is-text-path": { "version": "1.0.1", "dev": true, @@ -51854,6 +52043,12 @@ "version": "3.0.4", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "dev": true + }, "lodash.ismatch": { "version": "4.4.0", "dev": true @@ -51878,6 +52073,12 @@ "version": "4.4.0", "dev": true }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "lodash.union": { "version": "4.6.0", "dev": true @@ -55322,6 +55523,12 @@ "qs": { "version": "6.7.0" }, + "querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "dev": true + }, "querystring-es3": { "version": "0.2.1", "dev": true, diff --git a/packages/opentelemetry-exporter-zipkin/package.json b/packages/opentelemetry-exporter-zipkin/package.json index 1c815a0329..9c5eaef2a2 100644 --- a/packages/opentelemetry-exporter-zipkin/package.json +++ b/packages/opentelemetry-exporter-zipkin/package.json @@ -71,6 +71,7 @@ "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "cross-var": "1.1.0", + "fetch-mock": "^9.11.0", "karma": "6.4.2", "karma-chrome-launcher": "3.1.0", "karma-coverage": "2.2.1", diff --git a/packages/opentelemetry-exporter-zipkin/src/platform/browser/util.ts b/packages/opentelemetry-exporter-zipkin/src/platform/browser/util.ts index 7c5ace3588..0e2bb9d16c 100644 --- a/packages/opentelemetry-exporter-zipkin/src/platform/browser/util.ts +++ b/packages/opentelemetry-exporter-zipkin/src/platform/browser/util.ts @@ -34,6 +34,7 @@ export function prepareSend( ): zipkinTypes.SendFn { let xhrHeaders: Record; const useBeacon = typeof navigator.sendBeacon === 'function' && !headers; + const xhr = typeof XMLHttpRequest === 'function'; if (headers) { xhrHeaders = { Accept: 'application/json', @@ -56,8 +57,10 @@ export function prepareSend( const payload = JSON.stringify(zipkinSpans); if (useBeacon) { sendWithBeacon(payload, done, urlStr); - } else { + } else if (xhr) { sendWithXhr(payload, done, urlStr, xhrHeaders); + } else { + sendWithFetch(payload, done, urlStr, xhrHeaders); } }; } @@ -130,3 +133,40 @@ function sendWithXhr( diag.debug(`Zipkin request payload: ${data}`); xhr.send(data); } + +/** + * Sends data using fetch + * @param data + * @param done + * @param urlStr + * @param headers + */ +function sendWithFetch( + data: string, + done: (result: ExportResult) => void, + urlStr: string, + headers: Record = {} +) { + diag.debug(`Zipkin request payload: ${data}`); + fetch(urlStr, { method: 'POST', body: data, headers }).then( + response => { + diag.debug( + `Zipkin response status code: ${response.status}, body: ${data}` + ); + if (response.status >= 200 && response.status < 400) { + return done({ code: ExportResultCode.SUCCESS }); + } else { + return done({ + code: ExportResultCode.FAILED, + error: new Error( + `Got unexpected status code from zipkin: ${response.status}` + ), + }); + } + }, + error => { + globalErrorHandler(new Error(`Zipkin request error: ${error.message}`)); + return done({ code: ExportResultCode.FAILED }); + } + ); +} diff --git a/packages/opentelemetry-exporter-zipkin/test/browser/zipkin.test.ts b/packages/opentelemetry-exporter-zipkin/test/browser/zipkin.test.ts index e9d2c083a6..22cc0b862e 100644 --- a/packages/opentelemetry-exporter-zipkin/test/browser/zipkin.test.ts +++ b/packages/opentelemetry-exporter-zipkin/test/browser/zipkin.test.ts @@ -28,8 +28,11 @@ import { ensureSpanIsCorrect, mockedReadableSpan, } from '../helper'; +import { FetchMockStatic } from 'fetch-mock/esm/client'; +const fetchMock = require('fetch-mock/esm/client').default as FetchMockStatic; const sendBeacon = navigator.sendBeacon; +const xhr = window.XMLHttpRequest; describe('Zipkin Exporter - web', () => { let zipkinExporter: ZipkinExporter; @@ -97,6 +100,30 @@ describe('Zipkin Exporter - web', () => { }); }); + describe('when both sendBeacon and XHR are NOT available', () => { + const url = 'http://localhos/test-' + Math.random(); + beforeEach(() => { + (window.navigator as any).sendBeacon = false; + (window as any).XMLHttpRequest = false; + zipkinExporter = new ZipkinExporter({ ...zipkinConfig, url }); + }); + afterEach(() => { + fetchMock.restore(); + (window as any).XMLHttpRequest = xhr; + (window.navigator as any).sendBeacon = sendBeacon; + }); + + it('should successfully send custom headers using fetch', () => { + fetchMock.mock(url, 200); + zipkinExporter.export(spans, () => {}); + assert.strictEqual(fetchMock.calls(url).length, 1); + const json = JSON.parse( + fetchMock.lastCall(url)?.[1]?.body?.toString() || '' + ) as any; + ensureSpanIsCorrect(json[0]); + }); + }); + describe('should use url defined in environment', () => { let server: any; const endpointUrl = 'http://localhost:9412'; @@ -178,6 +205,39 @@ describe('Zipkin Exporter - web', () => { server.restore(); }); + describe('when both sendBeacon and XHR are NOT available', () => { + const url = 'http://localhos/test-' + Math.random(); + beforeEach(() => { + (window.navigator as any).sendBeacon = false; + (window as any).XMLHttpRequest = false; + zipkinExporter = new ZipkinExporter({ ...zipkinConfig, url }); + }); + afterEach(() => { + fetchMock.restore(); + (window as any).XMLHttpRequest = xhr; + (window.navigator as any).sendBeacon = sendBeacon; + }); + + it('should successfully send custom headers using fetch', done => { + fetchMock.mock(url, 200); + new Promise(r => { + zipkinExporter.export(spans, r); + }).then(() => { + setTimeout(() => { + assert.strictEqual(spyBeacon.callCount, 0); + assert.strictEqual(fetchMock.calls(url).length, 1); + + const request = fetchMock.lastCall(url)?.[1]; + const sentHeadersObj = Object.fromEntries( + Object.entries(request?.headers || {}) + ); + ensureHeadersContain(sentHeadersObj, customHeaders); + done(); + }); + }); + }); + }); + describe('when "sendBeacon" is available', () => { beforeEach(() => { zipkinExporter = new ZipkinExporter(zipkinConfig);