feat(otlp-exporter-base)!: use transport interface in web exporters (#4895)

This commit is contained in:
Marc Pichler 2024-08-21 12:35:49 +02:00 committed by GitHub
parent cd4e2bf8c0
commit f2a6bcc204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 627 additions and 452 deletions

View File

@ -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)

View File

@ -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);
});

View File

@ -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);

View File

@ -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<OTLPExporterConfigBase, ExportItem> {
protected _headers: Record<string, string>;
private _useXHR: boolean = false;
private _contentType: string;
private _serializer: ISerializer<ExportItem[], ServiceResponse>;
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<void>((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 = () => {

View File

@ -15,4 +15,3 @@
*/
export { OTLPExporterBrowserBase } from './OTLPExporterBrowserBase';
export { sendWithXhr } from './util';

View File

@ -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<ExportResponse> {
return new Promise<ExportResponse>(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);
}

View File

@ -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<string, string>,
exporterTimeout: number,
onSuccess: () => void,
onError: (error: OTLPExporterError) => void
): void {
let retryTimer: ReturnType<typeof setTimeout>;
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();
}

View File

@ -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<string, string>;
}
class XhrTransport implements IExporterTransport {
constructor(private _parameters: XhrRequestParameters) {}
send(data: Uint8Array, timeoutMillis: number): Promise<ExportResponse> {
return new Promise<ExportResponse>(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);
}

View File

@ -19,4 +19,4 @@ export {
OTLPExporterNodeConfigBase,
CompressionAlgorithm,
} from './node';
export { OTLPExporterBrowserBase, sendWithXhr } from './browser';
export { OTLPExporterBrowserBase } from './browser';

View File

@ -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');
});
});
});

View File

@ -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<string, string>;
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<string, string>;
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();
});
});
});
});
});

View File

@ -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);
});
});
});