Skip to content

Commit

Permalink
feat(otlp-exporter-base): Add fetch sender for ServiceWorker environm…
Browse files Browse the repository at this point in the history
…ent.
  • Loading branch information
sugi committed Jan 17, 2023
1 parent 5127371 commit f654e84
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 30 deletions.
2 changes: 2 additions & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ All notable changes to experimental packages in this project will be documented

### :rocket: (Enhancement)

* feat(otlp-exporter-base): Add fetch sender for ServiceWorker environment. [#3542](https://github.com/open-telemetry/opentelemetry-js/pull/3542) @sugi

### :bug: (Bug Fix)

### :books: (Refine Doc)
Expand Down
1 change: 1 addition & 0 deletions experimental/packages/otlp-exporter-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@types/node": "18.6.5",
"@types/sinon": "10.0.13",
"codecov": "3.8.3",
"fetch-mock": "^9.11.0",
"istanbul-instrumenter-loader": "3.0.1",
"mocha": "10.0.0",
"nock": "13.0.11",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ 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 } from '@opentelemetry/core';
import { _globalThis } from '@opentelemetry/core';

/**
* Collector Metric Exporter abstract base class
Expand All @@ -30,16 +31,21 @@ export abstract class OTLPExporterBrowserBase<
ServiceRequest
> extends OTLPExporterBase<OTLPExporterConfigBase, ExportItem, ServiceRequest> {
protected _headers: Record<string, string>;
private _useXHR: boolean = false;
private sendMethod: 'beacon' | 'xhr' | 'fetch' = 'beacon';

/**
* @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 = 'beacon';
} else if (typeof XMLHttpRequest === 'function') {
this.sendMethod = 'xhr';
} else {
this.sendMethod = 'fetch';
}
if (this.sendMethod !== 'beacon') {
this._headers = Object.assign(
{},
parseHeaders(config.headers),
Expand All @@ -53,11 +59,11 @@ export abstract class OTLPExporterBrowserBase<
}

onInit(): void {
window.addEventListener('unload', this.shutdown);
_globalThis.addEventListener('unload', this.shutdown);
}

onShutdown(): void {
window.removeEventListener('unload', this.shutdown);
_globalThis.removeEventListener('unload', this.shutdown);
}

send(
Expand All @@ -73,7 +79,7 @@ export abstract class OTLPExporterBrowserBase<
const body = JSON.stringify(serviceRequest);

const promise = new Promise<void>((resolve, reject) => {
if (this._useXHR) {
if (this.sendMethod === 'xhr') {
sendWithXhr(
body,
this.url,
Expand All @@ -82,7 +88,16 @@ export abstract class OTLPExporterBrowserBase<
resolve,
reject
);
} else {
} else if (this.sendMethod === 'fetch') {
sendWithFetch(
body,
this.url,
this._headers,
this.timeoutMillis,
resolve,
reject
);
} else if (this.sendMethod === 'beacon') {
sendWithBeacon(
body,
this.url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
import { diag } from '@opentelemetry/api';
import { OTLPExporterError } from '../../types';

const defaultHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json',
};

/**
* Send metrics/spans using browser navigator.sendBeacon
* @param body
Expand Down Expand Up @@ -67,11 +72,6 @@ export function sendWithXhr(
const xhr = new XMLHttpRequest();
xhr.open('POST', url);

const defaultHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json',
};

Object.entries({
...defaultHeaders,
...headers,
Expand Down Expand Up @@ -101,3 +101,57 @@ export function sendWithXhr(
}
};
}

/**
* 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<string, string>,
exporterTimeout: number,
onSuccess: () => void,
onError: (error: OTLPExporterError) => void
): void {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), exporterTimeout);
fetch(url, {
method: 'POST',
headers: {
...defaultHeaders,
...headers,
},
signal: controller.signal,
body,
})
.then(response => {
if (response.ok) {
response.text().then(
t => diag.debug('Request Success', t),
() => {}
);
onSuccess();
} else {
onError(
new OTLPExporterError(
`Request Error (${response.status} ${response.statusText})`,
response.status
)
);
}
})
.catch((e: Error) => {
if (e.name === 'AbortError') {
onError(new OTLPExporterError('Request Timeout'));
} else {
onError(new OTLPExporterError(`Request Fail: ${e.name} ${e.message}`));
}
})
.finally(() => clearTimeout(timerId));
}
132 changes: 116 additions & 16 deletions experimental/packages/otlp-exporter-base/test/browser/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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
Expand All @@ -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(() => {
Expand Down Expand Up @@ -156,4 +154,106 @@ describe('util - browser', () => {
});
});
});

describe('when fetch is used', () => {
let clock: sinon.SinonFakeTimers;

const assertRequesetHeaders = (
call: MockCall | undefined,
expected: Record<string, string>
) => {
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<string, string>;
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);
});
});
});
});

0 comments on commit f654e84

Please sign in to comment.