diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 844b9d2f5e..cd5fbd271b 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -17,6 +17,8 @@ All notable changes to experimental packages in this project will be documented * feat(exporter-*-otlp*)!: remove environment-variable specific code from browser exporters * (user-facing) removes the ability to configure browser exporters by using `process.env` polyfills * feat(sdk-node)!: Automatically configure logs exporter [#4740](https://github.com/open-telemetry/opentelemetry-js/pull/4740) +* feat(exporter-*-otlp-*)!: use transport interface in browser exporters [#4895](https://github.com/open-telemetry/opentelemetry-js/pull/4895) @pichlermarc + * (user-facing) protected `headers` property was intended for internal use has been removed from all exporters ### :rocket: (Enhancement) 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 8d021387df..2e234fb2fa 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 @@ -150,12 +150,16 @@ describe('OTLPTraceExporter - web', () => { collectorTraceExporter.export(spans, () => {}); - setTimeout(() => { - const response: any = spyLoggerDebug.args[2][0]; - assert.strictEqual(response, 'sendBeacon - can send'); - assert.strictEqual(spyLoggerError.args.length, 0); + queueMicrotask(() => { + try { + const response: any = spyLoggerDebug.args[2][0]; + assert.strictEqual(response, 'SendBeacon success'); + assert.strictEqual(spyLoggerError.args.length, 0); - done(); + done(); + } catch (e) { + done(e); + } }); }); @@ -163,9 +167,17 @@ describe('OTLPTraceExporter - web', () => { stubBeacon.returns(false); collectorTraceExporter.export(spans, result => { - assert.deepStrictEqual(result.code, ExportResultCode.FAILED); - assert.ok(result.error?.message.includes('cannot send')); - done(); + try { + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.ok( + result.error, + 'Expected Error, but no Error was present on the result' + ); + assert.match(result.error?.message, /SendBeacon failed/); + done(); + } catch (e) { + done(e); + } }); }); }); @@ -179,8 +191,8 @@ describe('OTLPTraceExporter - web', () => { clock = sinon.useFakeTimers(); (window.navigator as any).sendBeacon = false; - collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); server = sinon.fakeServer.create(); + collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); }); afterEach(() => { server.restore(); @@ -189,15 +201,15 @@ describe('OTLPTraceExporter - web', () => { it('should successfully send the spans using XMLHttpRequest', done => { collectorTraceExporter.export(spans, () => {}); - queueMicrotask(() => { + queueMicrotask(async () => { const request = server.requests[0]; assert.strictEqual(request.method, 'POST'); assert.strictEqual(request.url, 'http://foo.bar.com'); - const body = request.requestBody; + const body = request.requestBody as Blob; const decoder = new TextDecoder(); const json = JSON.parse( - decoder.decode(body) + decoder.decode(await body.arrayBuffer()) ) as IExportTraceServiceRequest; const span1 = json.resourceSpans?.[0].scopeSpans?.[0].spans?.[0]; @@ -235,28 +247,36 @@ describe('OTLPTraceExporter - web', () => { queueMicrotask(() => { const request = server.requests[0]; request.respond(200); - const response: any = spyLoggerDebug.args[2][0]; - assert.strictEqual(response, 'xhr success'); - assert.strictEqual(spyLoggerError.args.length, 0); - assert.strictEqual(stubBeacon.callCount, 0); - - clock.restore(); - done(); + try { + const response: any = spyLoggerDebug.args[2][0]; + assert.strictEqual(response, 'XHR success'); + assert.strictEqual(spyLoggerError.args.length, 0); + assert.strictEqual(stubBeacon.callCount, 0); + clock.restore(); + done(); + } catch (e) { + done(e); + } }); }); it('should log the error message', done => { collectorTraceExporter.export(spans, result => { - assert.deepStrictEqual(result.code, ExportResultCode.FAILED); - assert.ok(result.error?.message.includes('Failed to export')); + try { + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.deepStrictEqual( + result.error?.message, + 'XHR request failed with non-retryable status' + ); + } catch (e) { + done(e); + } done(); }); queueMicrotask(() => { const request = server.requests[0]; request.respond(400); - clock.restore(); - done(); }); }); @@ -421,17 +441,20 @@ describe('OTLPTraceExporter - web', () => { it('should log the timeout request error message', done => { const responseSpy = sinon.spy(); collectorTraceExporter.export(spans, responseSpy); - clock.tick(10000); + clock.tick(20000); clock.restore(); setTimeout(() => { - const result = responseSpy.args[0][0] as core.ExportResult; - assert.strictEqual(result.code, core.ExportResultCode.FAILED); - const error = result.error as OTLPExporterError; - assert.ok(error !== undefined); - assert.strictEqual(error.message, 'Request Timeout'); - - done(); + try { + const result = responseSpy.args[0][0] as core.ExportResult; + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual(error.message, 'XHR request timed out'); + done(); + } catch (e) { + done(e); + } }); }); }); @@ -455,15 +478,19 @@ describe('OTLPTraceExporter - web', () => { setTimeout(() => { // Expect 4 failures - assert.strictEqual(failures.length, 4); - failures.forEach(([result]) => { - assert.strictEqual(result.code, ExportResultCode.FAILED); - assert.strictEqual( - result.error!.message, - 'Concurrent export limit reached' - ); - }); - done(); + try { + assert.strictEqual(failures.length, 4); + failures.forEach(([result]) => { + assert.strictEqual(result.code, ExportResultCode.FAILED); + assert.strictEqual( + result.error!.message, + 'Concurrent export limit reached' + ); + }); + done(); + } catch (e) { + done(e); + } }); }); }); @@ -514,26 +541,33 @@ describe('export with retry - real http request destroyed', () => { (window.navigator as any).sendBeacon = false; collectorTraceExporter = new OTLPTraceExporter(collectorExporterConfig); }); - it('should log the timeout request error message when retrying with exponential backoff with jitter', done => { + it('should log the retryable request error message when retrying with exponential backoff with jitter', done => { spans = []; spans.push(Object.assign({}, mockedReadableSpan)); - let retry = 0; + let calls = 0; server.respondWith( 'http://localhost:4318/v1/traces', function (xhr: any) { - retry++; + calls++; xhr.respond(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(retry, 1); - done(); + try { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual( + error.message, + 'Export failed with retryable status' + ); + assert.strictEqual(calls, 6); + done(); + } catch (e) { + done(e); + } }); }).timeout(3000); @@ -541,22 +575,29 @@ describe('export with retry - real http request destroyed', () => { spans = []; spans.push(Object.assign({}, mockedReadableSpan)); - let retry = 0; + let calls = 0; server.respondWith( 'http://localhost:4318/v1/traces', function (xhr: any) { - retry++; - xhr.respond(503, { 'Retry-After': 3 }); + calls++; + xhr.respond(503, { 'Retry-After': 0.1 }); } ); 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(); + try { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual( + error.message, + 'Export failed with retryable status' + ); + assert.strictEqual(calls, 6); + done(); + } catch (e) { + done(e); + } }); }).timeout(3000); it('should log the timeout request error message when retry-after header is a date', done => { @@ -569,18 +610,25 @@ describe('export with retry - real http request destroyed', () => { function (xhr: any) { retry++; const d = new Date(); - d.setSeconds(d.getSeconds() + 1); + d.setSeconds(d.getSeconds() + 0.1); xhr.respond(503, { 'Retry-After': d }); } ); 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, 2); - done(); + try { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual( + error.message, + 'Export failed with retryable status' + ); + assert.strictEqual(retry, 6); + done(); + } catch (e) { + done(e); + } }); }).timeout(3000); it('should log the timeout request error message when retry-after header is a date with long delay', done => { @@ -599,12 +647,19 @@ describe('export with retry - real http request destroyed', () => { ); 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(); + try { + assert.strictEqual(result.code, core.ExportResultCode.FAILED); + const error = result.error as OTLPExporterError; + assert.ok(error !== undefined); + assert.strictEqual( + error.message, + 'Export failed with retryable status' + ); + assert.strictEqual(retry, 1); + done(); + } catch (e) { + done(e); + } }); }).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 96af0d58f7..5650fc8510 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 @@ -191,9 +191,9 @@ describe('OTLPMetricExporter - web', () => { collectorExporter.export(metrics, () => {}); - setTimeout(() => { + queueMicrotask(() => { const response: any = debugStub.args[2][0]; - assert.strictEqual(response, 'sendBeacon - can send'); + assert.strictEqual(response, 'SendBeacon success'); assert.strictEqual(errorStub.args.length, 0); done(); @@ -204,9 +204,17 @@ describe('OTLPMetricExporter - web', () => { stubBeacon.returns(false); collectorExporter.export(metrics, result => { - assert.deepStrictEqual(result.code, ExportResultCode.FAILED); - assert.ok(result.error?.message.includes('cannot send')); - done(); + try { + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.ok( + result.error, + 'Expected Error, but no Error was present on the result' + ); + assert.match(result.error?.message, /SendBeacon failed/); + done(); + } catch (e) { + done(e); + } }); }); }); @@ -232,7 +240,7 @@ describe('OTLPMetricExporter - web', () => { it('should successfully send the metrics using XMLHttpRequest', done => { collectorExporter.export(metrics, () => {}); - setTimeout(() => { + queueMicrotask(async () => { const request = server.requests[0]; assert.strictEqual(request.method, 'POST'); assert.strictEqual(request.url, 'http://foo.bar.com'); @@ -240,7 +248,7 @@ describe('OTLPMetricExporter - web', () => { const body = request.requestBody; const decoder = new TextDecoder(); const json = JSON.parse( - decoder.decode(body) + decoder.decode(await body.arrayBuffer()) ) as IExportMetricsServiceRequest; // The order of the metrics is not guaranteed. const counterIndex = metrics.scopeMetrics[0].metrics.findIndex( @@ -310,12 +318,12 @@ describe('OTLPMetricExporter - web', () => { it('should log the successful message', done => { collectorExporter.export(metrics, () => {}); - setTimeout(() => { + queueMicrotask(() => { const request = server.requests[0]; request.respond(200); const response: any = debugStub.args[2][0]; - assert.strictEqual(response, 'xhr success'); + assert.strictEqual(response, 'XHR success'); assert.strictEqual(errorStub.args.length, 0); assert.strictEqual(stubBeacon.callCount, 0); @@ -325,13 +333,19 @@ describe('OTLPMetricExporter - web', () => { it('should log the error message', done => { collectorExporter.export(metrics, result => { - assert.deepStrictEqual(result.code, ExportResultCode.FAILED); - assert.ok(result.error?.message.includes('Failed to export')); - assert.strictEqual(stubBeacon.callCount, 0); + try { + assert.deepStrictEqual(result.code, ExportResultCode.FAILED); + assert.deepStrictEqual( + result.error?.message, + 'XHR request failed with non-retryable status' + ); + } catch (e) { + done(e); + } done(); }); - setTimeout(() => { + queueMicrotask(() => { const request = server.requests[0]; request.respond(400); }); @@ -339,7 +353,7 @@ describe('OTLPMetricExporter - web', () => { it('should send custom headers', done => { collectorExporter.export(metrics, () => {}); - setTimeout(() => { + queueMicrotask(() => { const request = server.requests[0]; request.respond(200); 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 2eed5d3e56..3313c283d7 100644 --- a/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/OTLPExporterBrowserBase.ts @@ -15,13 +15,15 @@ */ import { OTLPExporterBase } from '../../OTLPExporterBase'; -import { OTLPExporterConfigBase } from '../../types'; -import * as otlpTypes from '../../types'; +import { OTLPExporterConfigBase, OTLPExporterError } from '../../types'; import { parseHeaders } from '../../util'; -import { sendWithBeacon, sendWithXhr } from './util'; import { diag } from '@opentelemetry/api'; import { getEnv, baggageUtils } from '@opentelemetry/core'; import { ISerializer } from '@opentelemetry/otlp-transformer'; +import { IExporterTransport } from '../../exporter-transport'; +import { createXhrTransport } from './xhr-transport'; +import { createSendBeaconTransport } from './send-beacon-transport'; +import { createRetryingTransport } from '../../retrying-transport'; /** * Collector Metric Exporter abstract base class @@ -30,10 +32,8 @@ export abstract class OTLPExporterBrowserBase< ExportItem, ServiceResponse, > extends OTLPExporterBase { - protected _headers: Record; - private _useXHR: boolean = false; - private _contentType: string; private _serializer: ISerializer; + private _transport: IExporterTransport; /** * @param config @@ -47,19 +47,28 @@ export abstract class OTLPExporterBrowserBase< ) { super(config); this._serializer = serializer; - this._contentType = contentType; - this._useXHR = + const useXhr = !!config.headers || typeof navigator.sendBeacon !== 'function'; - if (this._useXHR) { - this._headers = Object.assign( - {}, - parseHeaders(config.headers), - baggageUtils.parseKeyPairsIntoRecord( - getEnv().OTEL_EXPORTER_OTLP_HEADERS - ) - ); + if (useXhr) { + this._transport = createRetryingTransport({ + transport: createXhrTransport({ + headers: Object.assign( + {}, + parseHeaders(config.headers), + baggageUtils.parseKeyPairsIntoRecord( + getEnv().OTEL_EXPORTER_OTLP_HEADERS + ), + { 'Content-Type': contentType } + ), + url: this.url, + }), + }); } else { - this._headers = {}; + // sendBeacon has no way to signal retry, so we do not wrap it in a RetryingTransport + this._transport = createSendBeaconTransport({ + url: this.url, + blobType: contentType, + }); } } @@ -68,39 +77,35 @@ export abstract class OTLPExporterBrowserBase< onShutdown(): void {} send( - items: ExportItem[], + objects: ExportItem[], onSuccess: () => void, - onError: (error: otlpTypes.OTLPExporterError) => void + onError: (error: OTLPExporterError) => void ): void { if (this._shutdownOnce.isCalled) { diag.debug('Shutdown already started. Cannot send objects'); return; } - const body = this._serializer.serializeRequest(items) ?? new Uint8Array(); - const promise = new Promise((resolve, reject) => { - if (this._useXHR) { - sendWithXhr( - body, - this.url, - { - ...this._headers, - 'Content-Type': this._contentType, - }, - this.timeoutMillis, - resolve, - reject - ); - } else { - sendWithBeacon( - body, - this.url, - { type: this._contentType }, - resolve, - reject - ); - } - }).then(onSuccess, onError); + const data = this._serializer.serializeRequest(objects); + + if (data == null) { + onError(new Error('Could not serialize message')); + return; + } + + const promise = this._transport + .send(data, this.timeoutMillis) + .then(response => { + if (response.status === 'success') { + onSuccess(); + } else if (response.status === 'failure' && response.error) { + onError(response.error); + } else if (response.status === 'retryable') { + onError(new OTLPExporterError('Export failed with retryable status')); + } else { + onError(new OTLPExporterError('Export failed with unknown error')); + } + }, onError); this._sendingPromises.push(promise); const popPromise = () => { diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/index.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/index.ts index 58b8777a97..6c0c4bd425 100644 --- a/experimental/packages/otlp-exporter-base/src/platform/browser/index.ts +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/index.ts @@ -15,4 +15,3 @@ */ export { OTLPExporterBrowserBase } from './OTLPExporterBrowserBase'; -export { sendWithXhr } from './util'; diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/send-beacon-transport.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/send-beacon-transport.ts new file mode 100644 index 0000000000..79f9bd9ff8 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/send-beacon-transport.ts @@ -0,0 +1,62 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IExporterTransport } from '../../exporter-transport'; +import { ExportResponse } from '../../export-response'; +import { diag } from '@opentelemetry/api'; + +export interface SendBeaconParameters { + url: string; + /** + * for instance 'application/x-protobuf' + */ + blobType: string; +} + +class SendBeaconTransport implements IExporterTransport { + constructor(private _params: SendBeaconParameters) {} + send(data: Uint8Array): Promise { + return new Promise(resolve => { + if ( + navigator.sendBeacon( + this._params.url, + new Blob([data], { type: this._params.blobType }) + ) + ) { + // no way to signal retry, treat everything as success + diag.debug('SendBeacon success'); + resolve({ + status: 'success', + }); + } else { + resolve({ + status: 'failure', + error: new Error('SendBeacon failed'), + }); + } + }); + } + + shutdown(): void { + // Intentionally left empty, nothing to do. + } +} + +export function createSendBeaconTransport( + parameters: SendBeaconParameters +): IExporterTransport { + return new SendBeaconTransport(parameters); +} diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts deleted file mode 100644 index d82688b97c..0000000000 --- a/experimental/packages/otlp-exporter-base/src/platform/browser/util.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { diag } from '@opentelemetry/api'; -import { OTLPExporterError } from '../../types'; -import { - DEFAULT_EXPORT_MAX_ATTEMPTS, - DEFAULT_EXPORT_INITIAL_BACKOFF, - DEFAULT_EXPORT_BACKOFF_MULTIPLIER, - DEFAULT_EXPORT_MAX_BACKOFF, - isExportRetryable, - parseRetryAfterToMills, -} from '../../util'; - -/** - * Send metrics/spans using browser navigator.sendBeacon - * @param body - * @param url - * @param blobPropertyBag - * @param onSuccess - * @param onError - */ -export function sendWithBeacon( - body: Uint8Array, - url: string, - blobPropertyBag: BlobPropertyBag, - onSuccess: () => void, - onError: (error: OTLPExporterError) => void -): void { - if (navigator.sendBeacon(url, new Blob([body], blobPropertyBag))) { - diag.debug('sendBeacon - can send', body); - onSuccess(); - } else { - const error = new OTLPExporterError(`sendBeacon - cannot send ${body}`); - onError(error); - } -} - -/** - * function to send metrics/spans using browser XMLHttpRequest - * used when navigator.sendBeacon is not available - * @param body - * @param url - * @param headers - * @param onSuccess - * @param onError - */ -export function sendWithXhr( - body: Uint8Array, - url: string, - headers: Record, - exporterTimeout: number, - onSuccess: () => void, - onError: (error: OTLPExporterError) => void -): void { - let retryTimer: ReturnType; - let xhr: XMLHttpRequest; - let reqIsDestroyed = false; - - const exporterTimer = setTimeout(() => { - clearTimeout(retryTimer); - reqIsDestroyed = true; - - if (xhr.readyState === XMLHttpRequest.DONE) { - const err = new OTLPExporterError('Request Timeout'); - onError(err); - } else { - xhr.abort(); - } - }, exporterTimeout); - - const sendWithRetry = ( - retries = DEFAULT_EXPORT_MAX_ATTEMPTS, - minDelay = DEFAULT_EXPORT_INITIAL_BACKOFF - ) => { - xhr = new XMLHttpRequest(); - xhr.open('POST', url); - - const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - - Object.entries({ - ...defaultHeaders, - ...headers, - }).forEach(([k, v]) => { - xhr.setRequestHeader(k, v); - }); - - xhr.send(body); - - xhr.onreadystatechange = () => { - if (xhr.readyState === XMLHttpRequest.DONE && reqIsDestroyed === false) { - if (xhr.status >= 200 && xhr.status <= 299) { - diag.debug('xhr success', body); - onSuccess(); - clearTimeout(exporterTimer); - clearTimeout(retryTimer); - } else if (xhr.status && isExportRetryable(xhr.status) && retries > 0) { - let retryTime: number; - minDelay = DEFAULT_EXPORT_BACKOFF_MULTIPLIER * minDelay; - - // retry after interval specified in Retry-After header - if (xhr.getResponseHeader('Retry-After')) { - retryTime = parseRetryAfterToMills( - xhr.getResponseHeader('Retry-After')! - ); - } else { - // exponential backoff with jitter - retryTime = Math.round( - Math.random() * (DEFAULT_EXPORT_MAX_BACKOFF - minDelay) + minDelay - ); - } - - retryTimer = setTimeout(() => { - sendWithRetry(retries - 1, minDelay); - }, retryTime); - } else { - const error = new OTLPExporterError( - `Failed to export with XHR (status: ${xhr.status})`, - xhr.status - ); - onError(error); - clearTimeout(exporterTimer); - clearTimeout(retryTimer); - } - } - }; - - xhr.onabort = () => { - if (reqIsDestroyed) { - const err = new OTLPExporterError('Request Timeout'); - onError(err); - } - clearTimeout(exporterTimer); - clearTimeout(retryTimer); - }; - - xhr.onerror = () => { - if (reqIsDestroyed) { - const err = new OTLPExporterError('Request Timeout'); - onError(err); - } - clearTimeout(exporterTimer); - clearTimeout(retryTimer); - }; - }; - - sendWithRetry(); -} diff --git a/experimental/packages/otlp-exporter-base/src/platform/browser/xhr-transport.ts b/experimental/packages/otlp-exporter-base/src/platform/browser/xhr-transport.ts new file mode 100644 index 0000000000..7e36929a6a --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/platform/browser/xhr-transport.ts @@ -0,0 +1,99 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IExporterTransport } from '../../exporter-transport'; +import { ExportResponse } from '../../export-response'; +import { isExportRetryable, parseRetryAfterToMills } from '../../util'; +import { diag } from '@opentelemetry/api'; + +export interface XhrRequestParameters { + url: string; + headers: Record; +} + +class XhrTransport implements IExporterTransport { + constructor(private _parameters: XhrRequestParameters) {} + + send(data: Uint8Array, timeoutMillis: number): Promise { + return new Promise(resolve => { + const xhr = new XMLHttpRequest(); + xhr.timeout = timeoutMillis; + xhr.open('POST', this._parameters.url); + Object.entries(this._parameters.headers).forEach(([k, v]) => { + xhr.setRequestHeader(k, v); + }); + + xhr.ontimeout = _ => { + resolve({ + status: 'failure', + error: new Error('XHR request timed out'), + }); + }; + + xhr.onreadystatechange = () => { + if (xhr.status >= 200 && xhr.status <= 299) { + diag.debug('XHR success'); + resolve({ + status: 'success', + }); + } else if (xhr.status && isExportRetryable(xhr.status)) { + resolve({ + status: 'retryable', + retryInMillis: parseRetryAfterToMills( + xhr.getResponseHeader('Retry-After') + ), + }); + } else if (xhr.status !== 0) { + resolve({ + status: 'failure', + error: new Error('XHR request failed with non-retryable status'), + }); + } + }; + + xhr.onabort = () => { + resolve({ + status: 'failure', + error: new Error('XHR request aborted'), + }); + }; + xhr.onerror = () => { + resolve({ + status: 'failure', + error: new Error('XHR request errored'), + }); + }; + + xhr.send( + new Blob([data], { type: this._parameters.headers['Content-Type'] }) + ); + }); + } + + shutdown() { + // Intentionally left empty, nothing to do. + } +} + +/** + * Creates an exporter transport that uses XHR to send the data + * @param parameters applied to each request made by transport + */ +export function createXhrTransport( + parameters: XhrRequestParameters +): IExporterTransport { + return new XhrTransport(parameters); +} diff --git a/experimental/packages/otlp-exporter-base/src/platform/index.ts b/experimental/packages/otlp-exporter-base/src/platform/index.ts index 08a2b63613..14deab5550 100644 --- a/experimental/packages/otlp-exporter-base/src/platform/index.ts +++ b/experimental/packages/otlp-exporter-base/src/platform/index.ts @@ -19,4 +19,4 @@ export { OTLPExporterNodeConfigBase, CompressionAlgorithm, } from './node'; -export { OTLPExporterBrowserBase, sendWithXhr } from './browser'; +export { OTLPExporterBrowserBase } from './browser'; diff --git a/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts new file mode 100644 index 0000000000..e108df7f00 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as sinon from 'sinon'; +import { createSendBeaconTransport } from '../../src/platform/browser/send-beacon-transport'; +import * as assert from 'assert'; + +describe('SendBeaconTransport', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('send', function () { + it('returns failure when sendBeacon fails', async function () { + // arrange + const sendStub = sinon.stub(navigator, 'sendBeacon').returns(false); + const transport = createSendBeaconTransport({ + url: 'http://example.test', + blobType: 'application/json', + }); + + // act + const result = await transport.send(Uint8Array.from([1, 2, 3]), 1000); + + // assert + sinon.assert.calledOnceWithMatch( + sendStub, + 'http://example.test', + sinon.match + .instanceOf(Blob) + .and( + sinon.match( + actual => actual.type === 'application/json', + 'Expected Blob type to match.' + ) + ) + ); + assert.strictEqual(result.status, 'failure'); + }); + + it('returns success when sendBeacon succeeds', async function () { + // arrange + const sendStub = sinon.stub(navigator, 'sendBeacon').returns(true); + const transport = createSendBeaconTransport({ + url: 'http://example.test', + blobType: 'application/json', + }); + + // act + const result = await transport.send(Uint8Array.from([1, 2, 3]), 1000); + + // assert + sinon.assert.calledOnceWithMatch( + sendStub, + 'http://example.test', + sinon.match + .instanceOf(Blob) + .and( + sinon.match( + actual => actual.type === 'application/json', + 'Expected Blob type to match.' + ) + ) + ); + assert.strictEqual(result.status, 'success'); + }); + }); +}); diff --git a/experimental/packages/otlp-exporter-base/test/browser/util.test.ts b/experimental/packages/otlp-exporter-base/test/browser/util.test.ts deleted file mode 100644 index 367c51a2f1..0000000000 --- a/experimental/packages/otlp-exporter-base/test/browser/util.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as sinon from 'sinon'; -import { sendWithXhr } from '../../src/platform/browser/util'; -import { nextTick } from 'process'; -import { ensureHeadersContain } from '../testHelper'; - -describe('util - browser', () => { - let server: any; - const body = new Uint8Array(); - 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 expectedHeaders: Record; - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - // fakeTimers is used to replace the next setTimeout which is - // located in sendWithXhr function called by the export method - clock = sinon.useFakeTimers(); - - expectedHeaders = { - // ;charset=utf-8 is applied by sinon.fakeServer - 'Content-Type': 'application/json;charset=utf-8', - Accept: 'application/json', - }; - }); - describe('and Content-Type header is set', () => { - beforeEach(() => { - const explicitContentType = { - 'Content-Type': 'application/json', - }; - const exporterTimeout = 10000; - sendWithXhr( - body, - url, - explicitContentType, - exporterTimeout, - onSuccessStub, - onErrorStub - ); - }); - it('Request Headers should contain "Content-Type" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - it('Request Headers should contain "Accept" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - }); - - describe('and empty headers are set', () => { - beforeEach(() => { - const emptyHeaders = {}; - // use default exporter timeout - const exporterTimeout = 10000; - sendWithXhr( - body, - url, - emptyHeaders, - exporterTimeout, - onSuccessStub, - onErrorStub - ); - }); - it('Request Headers should contain "Content-Type" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - it('Request Headers should contain "Accept" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - }); - describe('and custom headers are set', () => { - let customHeaders: Record; - beforeEach(() => { - customHeaders = { aHeader: 'aValue', bHeader: 'bValue' }; - const exporterTimeout = 10000; - sendWithXhr( - body, - url, - customHeaders, - exporterTimeout, - onSuccessStub, - onErrorStub - ); - }); - it('Request Headers should contain "Content-Type" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - it('Request Headers should contain "Accept" header', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, expectedHeaders); - clock.restore(); - done(); - }); - }); - it('Request Headers should contain custom headers', done => { - nextTick(() => { - const { requestHeaders } = server.requests[0]; - ensureHeadersContain(requestHeaders, customHeaders); - clock.restore(); - done(); - }); - }); - }); - }); -}); 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 new file mode 100644 index 0000000000..f1efbe130d --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/xhr-transport.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as assert from 'assert'; +import { createXhrTransport } from '../../src/platform/browser/xhr-transport'; +import { + ExportResponseRetryable, + ExportResponseFailure, + ExportResponseSuccess, +} from '../../src'; +import { ensureHeadersContain } from '../testHelper'; + +const testTransportParameters = { + url: 'http://example.test', + headers: { + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }, +}; + +const requestTimeout = 1000; +const testPayload = Uint8Array.from([1, 2, 3]); + +describe('XhrTransport', function () { + afterEach(() => { + sinon.restore(); + }); + + describe('send', function () { + it('returns success when request succeeds', function (done) { + // arrange + const server = sinon.fakeServer.create(); + const transport = createXhrTransport(testTransportParameters); + + let request: sinon.SinonFakeXMLHttpRequest; + queueMicrotask(() => { + // this executes after the act block + request = server.requests[0]; + request.respond(200, {}, 'test response'); + }); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'success'); + // currently we don't do anything with the response yet, so it's dropped by the transport. + assert.strictEqual( + (response as ExportResponseSuccess).data, + undefined + ); + assert.strictEqual(request.url, testTransportParameters.url); + assert.strictEqual( + (request.requestBody as unknown as Blob).type, + 'application/json' + ); + ensureHeadersContain(request.requestHeaders, { + foo: 'foo-value', + bar: 'bar-value', + // ;charset=utf-8 is applied by sinon.fakeServer + 'Content-Type': 'application/json;charset=utf-8', + }); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns failure when request fails', function (done) { + // arrange + const server = sinon.fakeServer.create(); + const transport = createXhrTransport(testTransportParameters); + + queueMicrotask(() => { + // this executes after the act block + const request = server.requests[0]; + request.respond(404, {}, ''); + }); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns retryable when request is retryable', function (done) { + // arrange + const server = sinon.fakeServer.create(); + const transport = createXhrTransport(testTransportParameters); + + queueMicrotask(() => { + // this executes after the act block + const request = server.requests[0]; + request.respond(503, { 'Retry-After': 5 }, ''); + }); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'retryable'); + assert.strictEqual( + (response as ExportResponseRetryable).retryInMillis, + 5000 + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns failure when request times out', function (done) { + // arrange + // A fake server needed, otherwise the message will not be a timeout but a failure to connect. + sinon.useFakeServer(); + const clock = sinon.useFakeTimers(); + const transport = createXhrTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'XHR request timed out' + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + + it('returns failure when no server exists', function (done) { + // arrange + const clock = sinon.useFakeTimers(); + const transport = createXhrTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'XHR request errored' + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + }); +});