diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 62ec3f61c72..0e3f5c968d6 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -47,6 +47,7 @@ All notable changes to experimental packages in this project will be documented * feat(sdk-node, sdk-logs): add `mergeResourceWithDefaults` flag, which allows opting-out of resources getting merged with the default resource [#4617](https://github.com/open-telemetry/opentelemetry-js/pull/4617) * default: `true` * note: `false` will become the default behavior in a future iteration in order to comply with [specification requirements](https://github.com/open-telemetry/opentelemetry-specification/blob/f3511a5ccda376dfd1de76dfa086fc9b35b54757/specification/resource/sdk.md?plain=1#L31-L36) +* feat(instrumentation): Track request body size in XHR and Fetch instrumentations [#4706](https://github.com/open-telemetry/opentelemetry-js/pull/4706) @mustafahaddara ### :bug: (Bug Fix) diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/README.md b/experimental/packages/opentelemetry-instrumentation-fetch/README.md index 2d3c2d4aeed..0fa89bcb67d 100644 --- a/experimental/packages/opentelemetry-instrumentation-fetch/README.md +++ b/experimental/packages/opentelemetry-instrumentation-fetch/README.md @@ -69,8 +69,9 @@ Fetch instrumentation plugin has few options available to choose from. You can s | Options | Type | Description | |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------|-----------------------------------------------------------------------------------------| -| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L64) | `HttpCustomAttributeFunction` | Function for adding custom attributes | -| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L67) | `boolean` | Disable network events being added as span events (network events are added by default) | +| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L75) | `HttpCustomAttributeFunction` | Function for adding custom attributes | +| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L77) | `boolean` | Disable network events being added as span events (network events are added by default) | +| [`measureRequestSize`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L79) | `boolean` | Measure outgoing request length (outgoing request length is not measured by default) | ## Semantic Conventions @@ -80,12 +81,13 @@ Attributes collected: | Attribute | Short Description | | ------------------------------------------- | ------------------------------------------------------------------------------ | -| `http.status_code` | HTTP response status code | +| `http.status_code` | HTTP response status code | | `http.host` | The value of the HTTP host header | | `http.user_agent` | Value of the HTTP User-Agent header sent by the client | | `http.scheme` | The URI scheme identifying the used protocol | | `http.url` | Full HTTP request URL | | `http.method` | HTTP request method | +| `http.request_content_length_uncompressed` | Uncompressed size of the request body, if any body exists | ## Useful links diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts b/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts index fedb495d685..e5d9a84bdeb 100644 --- a/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts +++ b/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts @@ -31,8 +31,10 @@ import { SEMATTRS_HTTP_SCHEME, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, } from '@opentelemetry/semantic-conventions'; import { FetchError, FetchResponse, SpanData } from './types'; +import { getFetchBodyLength } from './utils'; import { VERSION } from './version'; import { _globalThis } from '@opentelemetry/core'; @@ -74,6 +76,8 @@ export interface FetchInstrumentationConfig extends InstrumentationConfig { applyCustomAttributesOnSpan?: FetchCustomAttributeFunction; // Ignore adding network events as span events ignoreNetworkEvents?: boolean; + /** Measure outgoing request size */ + measureRequestSize?: boolean; } /** @@ -320,6 +324,21 @@ export class FetchInstrumentation extends InstrumentationBase { + if (!length) return; + + createdSpan.setAttribute( + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, + length + ); + }) + .catch(error => { + plugin._diag.warn('getFetchBodyLength', error); + }); + } + function endSpanOnError(span: api.Span, error: FetchError) { plugin._applyAttributesAfterFetch(span, options, error); plugin._endSpan(span, spanData, { diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/src/utils.ts b/experimental/packages/opentelemetry-instrumentation-fetch/src/utils.ts new file mode 100644 index 00000000000..da3d329dc21 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-fetch/src/utils.ts @@ -0,0 +1,173 @@ +/* + * 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. + */ + +// Much of the logic here overlaps with the same utils file in opentelemetry-instrumentation-xml-http-request +// These may be unified in the future. + +import * as api from '@opentelemetry/api'; + +const DIAG_LOGGER = api.diag.createComponentLogger({ + namespace: '@opentelemetry/opentelemetry-instrumentation-fetch/utils', +}); + +/** + * Helper function to determine payload content length for fetch requests + * + * The fetch API is kinda messy: there are a couple of ways the body can be passed in. + * + * In all cases, the body param can be some variation of ReadableStream, + * and ReadableStreams can only be read once! We want to avoid consuming the body here, + * because that would mean that the body never gets sent with the actual fetch request. + * + * Either the first arg is a Request object, which can be cloned + * so we can clone that object and read the body of the clone + * without disturbing the original argument + * However, reading the body here can only be done async; the body() method returns a promise + * this means this entire function has to return a promise + * + * OR the first arg is a url/string + * in which case the second arg has type RequestInit + * RequestInit is NOT cloneable, but RequestInit.body is writable + * so we can chain it into ReadableStream.pipeThrough() + * + * ReadableStream.pipeThrough() lets us process a stream and returns a new stream + * So we can measure the body length as it passes through the pie, but need to attach + * the new stream to the original request + * so that the browser still has access to the body. + * + * @param body + * @returns promise that resolves to the content length of the body + */ +export function getFetchBodyLength(...args: Parameters) { + if (args[0] instanceof URL || typeof args[0] === 'string') { + const requestInit = args[1]; + if (!requestInit?.body) { + return Promise.resolve(); + } + if (requestInit.body instanceof ReadableStream) { + const { body, length } = _getBodyNonDestructively(requestInit.body); + requestInit.body = body; + + return length; + } else { + return Promise.resolve(getXHRBodyLength(requestInit.body)); + } + } else { + const info = args[0]; + if (!info?.body) { + return Promise.resolve(); + } + + return info + .clone() + .text() + .then(t => getByteLength(t)); + } +} + +function _getBodyNonDestructively(body: ReadableStream) { + // can't read a ReadableStream without destroying it + // but we CAN pipe it through and return a new ReadableStream + + // some (older) platforms don't expose the pipeThrough method and in that scenario, we're out of luck; + // there's no way to read the stream without consuming it. + if (!body.pipeThrough) { + DIAG_LOGGER.warn('Platform has ReadableStream but not pipeThrough!'); + return { + body, + length: Promise.resolve(undefined), + }; + } + + let length = 0; + let resolveLength: (l: number) => void; + const lengthPromise = new Promise(resolve => { + resolveLength = resolve; + }); + + const transform = new TransformStream({ + start() {}, + async transform(chunk, controller) { + const bytearray = (await chunk) as Uint8Array; + length += bytearray.byteLength; + + controller.enqueue(chunk); + }, + flush() { + resolveLength(length); + }, + }); + + return { + body: body.pipeThrough(transform), + length: lengthPromise, + }; +} + +/** + * Helper function to determine payload content length for XHR requests + * @param body + * @returns content length + */ +export function getXHRBodyLength( + body: Document | XMLHttpRequestBodyInit +): number | undefined { + if (typeof Document !== 'undefined' && body instanceof Document) { + return new XMLSerializer().serializeToString(document).length; + } + // XMLHttpRequestBodyInit expands to the following: + if (body instanceof Blob) { + return body.size; + } + + // ArrayBuffer | ArrayBufferView + if ((body as any).byteLength !== undefined) { + return (body as any).byteLength as number; + } + + if (body instanceof FormData) { + return getFormDataSize(body); + } + + if (body instanceof URLSearchParams) { + return getByteLength(body.toString()); + } + + if (typeof body === 'string') { + return getByteLength(body); + } + + DIAG_LOGGER.warn('unknown body type'); + return undefined; +} + +const TEXT_ENCODER = new TextEncoder(); +function getByteLength(s: string): number { + return TEXT_ENCODER.encode(s).byteLength; +} + +function getFormDataSize(formData: FormData): number { + let size = 0; + for (const [key, value] of formData.entries()) { + size += key.length; + if (value instanceof Blob) { + size += value.size; + } else { + size += value.length; + } + } + return size; +} diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/test/fetch.test.ts b/experimental/packages/opentelemetry-instrumentation-fetch/test/fetch.test.ts index 7e14cc35883..e76d9843248 100644 --- a/experimental/packages/opentelemetry-instrumentation-fetch/test/fetch.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-fetch/test/fetch.test.ts @@ -49,6 +49,7 @@ import { SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_USER_AGENT, + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, } from '@opentelemetry/semantic-conventions'; class DummySpanExporter implements tracing.SpanExporter { @@ -74,6 +75,19 @@ const getData = (url: string, method?: string) => { }); }; +const ENCODER = new TextEncoder(); +const textToReadableStream = (msg: string): ReadableStream => { + return new ReadableStream({ + start: controller => { + controller.enqueue(ENCODER.encode(msg)); + controller.close(); + }, + cancel: controller => { + controller.close(); + }, + }); +}; + const CUSTOM_ATTRIBUTE_KEY = 'span kind'; const defaultResource = { connectEnd: 15, @@ -163,6 +177,7 @@ function testForCorrectEvents( describe('fetch', () => { let contextManager: ZoneContextManager; let lastResponse: any | undefined; + let requestBody: any | undefined; let webTracerWithZone: api.Tracer; let webTracerProviderWithZone: WebTracerProvider; let dummySpanExporter: DummySpanExporter; @@ -179,12 +194,13 @@ describe('fetch', () => { const clearData = () => { sinon.restore(); lastResponse = undefined; + requestBody = undefined; }; const prepareData = async ( fileUrl: string, + apiCall: () => Promise, config: FetchInstrumentationConfig, - method?: string, disablePerfObserver?: boolean, disableGetEntries?: boolean ) => { @@ -201,6 +217,25 @@ describe('fetch', () => { }; response.headers = Object.assign({}, init.headers); + // get the request body + if (typeof input === 'string') { + const body = init.body; + if (body instanceof ReadableStream) { + const decoder = new TextDecoder(); + requestBody = ''; + const read = async () => { + for await (const c of body) { + requestBody += decoder.decode(c); + } + }; + read(); + } else { + requestBody = init.body; + } + } else { + input.text().then(r => (requestBody = r)); + } + if (init instanceof Request) { // Passing request as 2nd argument causes missing body bug (#2411) response.status = 400; @@ -288,7 +323,7 @@ describe('fetch', () => { async () => { fakeNow = 0; try { - const responsePromise = getData(fileUrl, method); + const responsePromise = apiCall(); fakeNow = 300; const response = await responsePromise; @@ -332,7 +367,9 @@ describe('fetch', () => { describe('when request is successful', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = [url]; - await prepareData(url, { propagateTraceHeaderCorsUrls }); + await prepareData(url, () => getData(url), { + propagateTraceHeaderCorsUrls, + }); }); afterEach(() => { @@ -374,44 +411,61 @@ describe('fetch', () => { const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; const attributes = span.attributes; const keys = Object.keys(attributes); - - assert.ok( - attributes[keys[0]] !== '', + assert.notStrictEqual( + attributes[AttributeNames.COMPONENT], + '', `attributes ${AttributeNames.COMPONENT} is not defined` ); + assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[2]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); assert.strictEqual( - attributes[keys[3]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 200, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); + const statusText = attributes[AttributeNames.HTTP_STATUS_TEXT]; assert.ok( - attributes[keys[4]] === 'OK' || attributes[keys[4]] === '', + statusText === 'OK' || statusText === '', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.ok( - (attributes[keys[5]] as string).indexOf('localhost') === 0, + (attributes[SEMATTRS_HTTP_HOST] as string).indexOf('localhost') === 0, `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[6]] === 'http' || attributes[keys[6]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[7]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); - assert.ok( - (attributes[keys[8]] as number) > 0, + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + 30, `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is <= 0` ); @@ -568,7 +622,7 @@ describe('fetch', () => { diagLogger.debug = spyDebug; api.diag.setLogger(diagLogger, api.DiagLogLevel.ALL); clearData(); - await prepareData(url, {}); + await prepareData(url, () => getData(url), {}); }); afterEach(() => { sinon.restore(); @@ -600,10 +654,203 @@ describe('fetch', () => { }); }); + describe('post data', () => { + describe('url and config object when request body measurement is disabled', () => { + beforeEach(async () => { + await prepareData( + url, + () => + fetch(url, { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hello: 'world' }), + }), + {} + ); + }); + + afterEach(() => { + clearData(); + }); + + it('should post data', async () => { + assert.strictEqual(requestBody, '{"hello":"world"}'); + + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED], + undefined + ); + }); + }); + + describe('url and config object', () => { + beforeEach(async () => { + await prepareData( + url, + () => + fetch(url, { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hello: 'world' }), + }), + { + measureRequestSize: true, + } + ); + }); + + afterEach(() => { + clearData(); + }); + + it('should post data', async () => { + assert.strictEqual(requestBody, '{"hello":"world"}'); + + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED], + 17 + ); + }); + }); + + describe('url and config object with stream', () => { + beforeEach(async () => { + await prepareData( + url, + () => + fetch(url, { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: textToReadableStream('{"hello":"world"}'), + }), + { + measureRequestSize: true, + } + ); + }); + + afterEach(() => { + clearData(); + }); + + it('should post data', async () => { + assert.strictEqual(requestBody, '{"hello":"world"}'); + + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED], + 17 + ); + }); + }); + + describe('single request object', () => { + beforeEach(async () => { + await prepareData( + url, + () => { + const req = new Request(url, { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: '{"hello":"world"}', + }); + return fetch(req); + }, + { + measureRequestSize: true, + } + ); + }); + + afterEach(() => { + clearData(); + }); + + it('should post data', async () => { + assert.strictEqual(requestBody, '{"hello":"world"}'); + + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED], + 17 + ); + }); + }); + + describe('single request object with urlparams', () => { + beforeEach(async () => { + await prepareData( + url, + () => { + const body = new URLSearchParams(); + body.append('hello', 'world'); + const req = new Request(url, { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); + return fetch(req); + }, + { + measureRequestSize: true, + } + ); + }); + + afterEach(() => { + clearData(); + }); + + it('should post data', async () => { + assert.strictEqual(requestBody, 'hello=world'); + + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED], + 11 + ); + }); + }); + }); + describe('when request is secure and successful', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = [secureUrl]; - await prepareData(secureUrl, { propagateTraceHeaderCorsUrls }); + await prepareData(secureUrl, () => getData(secureUrl), { + propagateTraceHeaderCorsUrls, + }); }); afterEach(() => { @@ -652,7 +899,7 @@ describe('fetch', () => { ) => { const propagateTraceHeaderCorsUrls = [url]; - await prepareData(url, { + await prepareData(url, () => getData(url), { propagateTraceHeaderCorsUrls, applyCustomAttributesOnSpan, }); @@ -720,7 +967,7 @@ describe('fetch', () => { describe('when url is ignored', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = url; - await prepareData(url, { + await prepareData(url, () => getData(url), { propagateTraceHeaderCorsUrls, ignoreUrls: [propagateTraceHeaderCorsUrls], }); @@ -747,7 +994,7 @@ describe('fetch', () => { describe('when clearTimingResources is TRUE', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = url; - await prepareData(url, { + await prepareData(url, () => getData(url), { propagateTraceHeaderCorsUrls, clearTimingResources: true, }); @@ -767,7 +1014,9 @@ describe('fetch', () => { describe('when request is NOT successful (wrong url)', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = badUrl; - await prepareData(badUrl, { propagateTraceHeaderCorsUrls }); + await prepareData(badUrl, () => getData(badUrl), { + propagateTraceHeaderCorsUrls, + }); }); afterEach(() => { clearData(); @@ -785,7 +1034,9 @@ describe('fetch', () => { describe('when request is NOT successful (405)', () => { beforeEach(async () => { const propagateTraceHeaderCorsUrls = url; - await prepareData(url, { propagateTraceHeaderCorsUrls }, 'DELETE'); + await prepareData(url, () => getData(url, 'DELETE'), { + propagateTraceHeaderCorsUrls, + }); }); afterEach(() => { clearData(); @@ -806,7 +1057,7 @@ describe('fetch', () => { // All above tests test it already but just in case // lets explicitly turn getEntriesByType off so we can be sure // that the perf entries come from the observer. - await prepareData(url, {}, undefined, false, true); + await prepareData(url, () => getData(url), {}, false, true); }); afterEach(() => { clearData(); @@ -838,7 +1089,7 @@ describe('fetch', () => { describe('when fetching with relative url', () => { beforeEach(async () => { - await prepareData('/get', {}, undefined, false, true); + await prepareData('/get', () => getData('/get'), {}, false, true); }); afterEach(() => { clearData(); @@ -882,7 +1133,7 @@ describe('fetch', () => { describe('when PerformanceObserver is undefined', () => { beforeEach(async () => { - await prepareData(url, {}, undefined, true, false); + await prepareData(url, () => getData(url), {}, true, false); }); afterEach(() => { @@ -914,7 +1165,7 @@ describe('fetch', () => { describe('when PerformanceObserver and performance.getEntriesByType are undefined', () => { beforeEach(async () => { - await prepareData(url, {}, undefined, true, true); + await prepareData(url, () => getData(url), {}, true, true); }); afterEach(() => { clearData(); @@ -949,7 +1200,7 @@ describe('fetch', () => { describe('when network events are ignored', () => { beforeEach(async () => { - await prepareData(url, { + await prepareData(url, () => getData(url), { ignoreNetworkEvents: true, }); }); diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/test/utils.test.ts b/experimental/packages/opentelemetry-instrumentation-fetch/test/utils.test.ts new file mode 100644 index 00000000000..f0fcf89ecba --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-fetch/test/utils.test.ts @@ -0,0 +1,272 @@ +/* + * 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 assert from 'assert'; + +import { getXHRBodyLength, getFetchBodyLength } from '../src/utils'; + +const ENCODER = new TextEncoder(); +function textToReadableStream(msg: string) { + return new ReadableStream({ + start: controller => { + controller.enqueue(ENCODER.encode(msg)); + controller.close(); + }, + cancel: controller => { + controller.close(); + }, + }); +} + +describe('getXHRBodyLength', () => { + it('should compute body length for Document payload', () => { + // webworkers don't have DOMParser + if (typeof DOMParser === 'undefined') { + assert.ok(true); + return; + } + const doc = new DOMParser().parseFromString( + '

hello world

', + 'text/html' + ); + + const length = getXHRBodyLength(doc); + assert.ok(length !== undefined, 'body length is undefined'); + assert.ok(length, 'body length is 0'); + }); + it('should compute body length for Blob payload', () => { + const blob = new Blob(['hello world'], { + type: 'text/plain', + }); + + assert.strictEqual(getXHRBodyLength(blob), 11); + }); + it('should compute body length for ArrayBuffer/ArrayBufferView payload', () => { + const arrayBuffer = new Uint8Array([1, 2, 3]).buffer; + + assert.strictEqual(getXHRBodyLength(arrayBuffer), 3); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(8)), 8); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(8).slice(0, 2)), 2); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(0)), 0); + }); + it('should compute body length for FormData payload', () => { + const formData = new FormData(); + formData.append('key1', 'true'); + formData.append('key2', 'hello world'); + + assert.strictEqual(getXHRBodyLength(formData), 23); + assert.strictEqual(getXHRBodyLength(new FormData()), 0); + }); + it('should compute body length for FormData payload with a file', () => { + const formData = new FormData(); + const f = new File( + ['hello world hello world hello world'], + 'test_file.txt' + ); + formData.append('file', f); + + // length should be: + // 4 for the key of the file in the form data + // 35 for the file contents + assert.strictEqual(getXHRBodyLength(formData), 39); + }); + it('should compute body length for URLSearchParams payload', () => { + const search = new URLSearchParams({ + key1: 'true', + key2: 'hello world', + }); + + assert.strictEqual(getXHRBodyLength(search), 26); + assert.strictEqual(getXHRBodyLength(new URLSearchParams()), 0); + }); + it('should compute body length for string payload', () => { + const jsonString = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + assert.strictEqual(getXHRBodyLength(jsonString), 36); + assert.strictEqual(getXHRBodyLength('hello world'), 11); + assert.strictEqual(getXHRBodyLength('π'), 2); // one character, 2 bytes + assert.strictEqual(getXHRBodyLength('🔥🔪😭'), 12); // each emoji is 4 bytes + assert.strictEqual(getXHRBodyLength('مرحبا بالعالم'), 25); // hello world in Arabic is 25 bytes + assert.strictEqual(getXHRBodyLength(''), 0); + }); +}); + +describe('getFetchBodyLength', () => { + it('should read the body of the second param when the first param is string', async () => { + const jsonString = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + const length = await getFetchBodyLength('https://example.com', { + body: jsonString, + }); + assert.strictEqual(length, 36); + }); + + it('should handle undefined body', async () => { + const length = await getFetchBodyLength('https://example.com', {}); + assert.strictEqual(length, undefined); + }); + + it('should handle unicode body', async () => { + const length = await getFetchBodyLength('https://example.com', { + body: 'π🔥🔪😭', + }); + assert.strictEqual(length, 14); // pi is 2 bytes, each emoji is 4 + }); + + it('should (non-destructively) read the body stream of the second param when the first param is string', async () => { + const jsonString = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + const requestParams = { body: textToReadableStream(jsonString) }; + const lengthPromise = getFetchBodyLength( + 'https://example.com', + requestParams + ); + + // if we try to await lengthPromise here, we get a timeout + + let lengthResolved = false; + lengthPromise.finally(() => (lengthResolved = true)); + + // length doesn't get read yet + assert.strictEqual(lengthResolved, false); + + // the body is still readable + assert.strictEqual(requestParams.body.locked, false); + + // AND the body is still correct + const { value } = await requestParams.body.getReader().read(); + const decoder = new TextDecoder(); + assert.strictEqual(decoder.decode(value), jsonString); + + // AND now length got read, and we got the correct length + const length = await lengthPromise; + assert.strictEqual(lengthResolved, true); + assert.strictEqual(length, 36); + }); + + it('should (non-destructively) read the unicode body stream of the second param when the first param is string', async () => { + const bodyString = 'π🔥🔪😭'; + const requestParams = { body: textToReadableStream(bodyString) }; + const lengthPromise = getFetchBodyLength( + 'https://example.com', + requestParams + ); + + // if we try to await lengthPromise here, we get a timeout + + let lengthResolved = false; + lengthPromise.finally(() => (lengthResolved = true)); + + // length doesn't get read yet + assert.strictEqual(lengthResolved, false); + + // the body is still readable + assert.strictEqual(requestParams.body.locked, false); + + // AND the body is still correct + const { value } = await requestParams.body.getReader().read(); + const decoder = new TextDecoder(); + assert.strictEqual(decoder.decode(value), bodyString); + + // AND now length got read, and we got the correct length + const length = await lengthPromise; + assert.strictEqual(lengthResolved, true); + assert.strictEqual(length, 14); + }); + + it('should handle readablestream objects without a pipeThrough method', async () => { + const jsonString = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + const stream = textToReadableStream(jsonString); + + // @ts-expect-error intentionally remove the .tee() method to mimic older environments where this method isn't available + stream.pipeThrough = undefined; + + const requestParams = { body: stream }; + const length = await getFetchBodyLength( + 'https://example.com', + requestParams + ); + + // we got the correct length + assert.strictEqual(length, undefined); + + // AND the body is still readable + assert.strictEqual(requestParams.body.locked, false); + + // AND the body is still correct + const { value } = await requestParams.body.getReader().read(); + const decoder = new TextDecoder(); + assert.strictEqual(decoder.decode(value), jsonString); + }); + + it('should read the body of the first param when recieving a request', async () => { + const bodyContent = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + const req = new Request('https://example.com', { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: bodyContent, + }); + + const length = await getFetchBodyLength(req); + + // we got the correct length + assert.strictEqual(length, 36); + + // AND the body is still readable and correct + const body = await req.text(); + assert.strictEqual(body, bodyContent); + }); + + it('should read the body of the first param when recieving a request with urlparams body', async () => { + const body = new URLSearchParams(); + body.append('hello', 'world'); + + const req = new Request('https://example.com', { + method: 'POST', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + }); + + const length = await getFetchBodyLength(req); + + // we got the correct length + assert.strictEqual(length, 11); + + // AND the body is still readable and correct + const requestBody = await req.text(); + assert.strictEqual(requestBody, 'hello=world'); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-xml-http-request/README.md b/experimental/packages/opentelemetry-instrumentation-xml-http-request/README.md index 8c706ffa61f..83f349175ba 100644 --- a/experimental/packages/opentelemetry-instrumentation-xml-http-request/README.md +++ b/experimental/packages/opentelemetry-instrumentation-xml-http-request/README.md @@ -70,8 +70,25 @@ XHR instrumentation plugin has few options available to choose from. You can set | Options | Type | Description | |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------|-----------------------------------------------------------------------------------------| -| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts#L76) | `XHRCustomAttributeFunction` | Function for adding custom attributes | -| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts#L78) | `boolean` | Disable network events being added as span events (network events are added by default) | +| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts#L85) | `XHRCustomAttributeFunction` | Function for adding custom attributes | +| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts#L87) | `boolean` | Disable network events being added as span events (network events are added by default) | +| [`measureRequestSize`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts#L89) | `boolean` | Measure outgoing request length (outgoing request length is not measured by default) | + +## Semantic Conventions + +This package uses `@opentelemetry/semantic-conventions` version `1.22+`, which implements Semantic Convention [Version 1.7.0](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.7.0/semantic_conventions/README.md) + +Attributes collected: + +| Attribute | Short Description | +| ------------------------------------------- | ------------------------------------------------------------------------------ | +| `http.status_code` | HTTP response status code | +| `http.host` | The value of the HTTP host header | +| `http.user_agent` | Value of the HTTP User-Agent header sent by the client | +| `http.scheme` | The URI scheme identifying the used protocol | +| `http.url` | Full HTTP request URL | +| `http.method` | HTTP request method | +| `http.request_content_length_uncompressed` | Uncompressed size of the request body, if any body exists | ## Example Screenshots diff --git a/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/utils.ts b/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/utils.ts new file mode 100644 index 00000000000..c15e84efdd1 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/utils.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. + */ + +// Much of the logic here overlaps with the same utils file in opentelemetry-instrumentation-fetch +// These may be unified in the future. + +import * as api from '@opentelemetry/api'; + +const DIAG_LOGGER = api.diag.createComponentLogger({ + namespace: + '@opentelemetry/opentelemetry-instrumentation-xml-http-request/utils', +}); + +/** + * Helper function to determine payload content length for XHR requests + * @param body + * @returns content length + */ +export function getXHRBodyLength( + body: Document | XMLHttpRequestBodyInit +): number | undefined { + if (typeof Document !== 'undefined' && body instanceof Document) { + return new XMLSerializer().serializeToString(document).length; + } + // XMLHttpRequestBodyInit expands to the following: + if (body instanceof Blob) { + return body.size; + } + + // ArrayBuffer | ArrayBufferView + if ((body as any).byteLength !== undefined) { + return (body as any).byteLength as number; + } + + if (body instanceof FormData) { + return getFormDataSize(body); + } + + if (body instanceof URLSearchParams) { + return getByteLength(body.toString()); + } + + if (typeof body === 'string') { + return getByteLength(body); + } + + DIAG_LOGGER.warn('unknown body type'); + return undefined; +} + +const TEXT_ENCODER = new TextEncoder(); +function getByteLength(s: string): number { + return TEXT_ENCODER.encode(s).byteLength; +} + +function getFormDataSize(formData: FormData): number { + let size = 0; + for (const [key, value] of formData.entries()) { + size += key.length; + if (value instanceof Blob) { + size += value.size; + } else { + size += value.length; + } + } + return size; +} diff --git a/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts b/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts index 5a8ba3012cb..6d2eafa054c 100644 --- a/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts +++ b/experimental/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts @@ -29,6 +29,7 @@ import { SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_USER_AGENT, + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, } from '@opentelemetry/semantic-conventions'; import { addSpanNetworkEvents, @@ -44,6 +45,7 @@ import { SendFunction, XhrMem, } from './types'; +import { getXHRBodyLength } from './utils'; import { VERSION } from './version'; import { AttributeNames } from './enums/AttributeNames'; @@ -83,6 +85,8 @@ export interface XMLHttpRequestInstrumentationConfig applyCustomAttributesOnSpan?: XHRCustomAttributeFunction; /** Ignore adding network events as span events */ ignoreNetworkEvents?: boolean; + /** Measure outgoing request size */ + measureRequestSize?: boolean; } /** @@ -486,6 +490,17 @@ export class XMLHttpRequestInstrumentation extends InstrumentationBase { diff --git a/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/utils.test.ts b/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/utils.test.ts new file mode 100644 index 00000000000..d14f14e1822 --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/utils.test.ts @@ -0,0 +1,94 @@ +/* + * 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 assert from 'assert'; + +import { getXHRBodyLength } from '../src/utils'; + +describe('getXHRBodyLength', () => { + it('should compute body length for Document payload', () => { + // webworkers don't have DOMParser + if (typeof DOMParser === 'undefined') { + assert.ok(true); + return; + } + const doc = new DOMParser().parseFromString( + '

hello world

', + 'text/html' + ); + + const length = getXHRBodyLength(doc); + assert.ok(length !== undefined, 'body length is undefined'); + assert.ok(length, 'body length is 0'); + }); + it('should compute body length for Blob payload', () => { + const blob = new Blob(['hello world'], { + type: 'text/plain', + }); + + assert.strictEqual(getXHRBodyLength(blob), 11); + }); + it('should compute body length for ArrayBuffer/ArrayBufferView payload', () => { + const arrayBuffer = new Uint8Array([1, 2, 3]).buffer; + + assert.strictEqual(getXHRBodyLength(arrayBuffer), 3); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(8)), 8); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(8).slice(0, 2)), 2); + assert.strictEqual(getXHRBodyLength(new ArrayBuffer(0)), 0); + }); + it('should compute body length for FormData payload', () => { + const formData = new FormData(); + formData.append('key1', 'true'); + formData.append('key2', 'hello world'); + + assert.strictEqual(getXHRBodyLength(formData), 23); + assert.strictEqual(getXHRBodyLength(new FormData()), 0); + }); + it('should compute body length for FormData payload with a file', () => { + const formData = new FormData(); + const f = new File( + ['hello world hello world hello world'], + 'test_file.txt' + ); + formData.append('file', f); + + // length should be: + // 4 for the key of the file in the form data + // 35 for the file contents + assert.strictEqual(getXHRBodyLength(formData), 39); + }); + it('should compute body length for URLSearchParams payload', () => { + const search = new URLSearchParams({ + key1: 'true', + key2: 'hello world', + }); + + assert.strictEqual(getXHRBodyLength(search), 26); + assert.strictEqual(getXHRBodyLength(new URLSearchParams()), 0); + }); + it('should compute body length for string payload', () => { + const jsonString = JSON.stringify({ + key1: 'true', + key2: 'hello world', + }); + assert.strictEqual(getXHRBodyLength(jsonString), 36); + assert.strictEqual(getXHRBodyLength('hello world'), 11); + assert.strictEqual(getXHRBodyLength('π'), 2); // one character, 2 bytes + assert.strictEqual(getXHRBodyLength('🔥🔪😭'), 12); // each emoji is 4 bytes + assert.strictEqual(getXHRBodyLength('مرحبا بالعالم'), 25); // hello world in Arabic is 25 bytes + assert.strictEqual(getXHRBodyLength(''), 0); + }); +}); diff --git a/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/xhr.test.ts b/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/xhr.test.ts index f3685e06a13..481e3f1538f 100644 --- a/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/xhr.test.ts +++ b/experimental/packages/opentelemetry-instrumentation-xml-http-request/test/xhr.test.ts @@ -29,11 +29,11 @@ import { SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH, - SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH, SEMATTRS_HTTP_SCHEME, SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_USER_AGENT, + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, } from '@opentelemetry/semantic-conventions'; import { PerformanceTimingNames as PTN, @@ -97,6 +97,41 @@ const getData = ( }); }; +const postData = ( + req: XMLHttpRequest, + url: string, + data: Document | XMLHttpRequestBodyInit, + callbackAfterSend: Function, + async?: boolean +) => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + if (async === undefined) { + async = true; + } + req.timeout = XHR_TIMEOUT; + + req.open('POST', url, async); + req.onload = function () { + resolve(); + }; + + req.onerror = function () { + resolve(); + }; + + req.onabort = function () { + resolve(); + }; + + req.ontimeout = function () { + resolve(); + }; + req.send(data); + callbackAfterSend(); + }); +}; + function createResource(resource = {}): PerformanceResourceTiming { const defaultResource = { connectEnd: 15, @@ -190,7 +225,6 @@ describe('xhr', () => { const testAsync = test.async; describe(`when async='${testAsync}'`, () => { let requests: any[] = []; - let prepareData: any; let clearData: any; let contextManager: ZoneContextManager; @@ -213,7 +247,7 @@ describe('xhr', () => { api.propagation.disable(); }); - describe('when request is successful', () => { + describe('when GET request is successful', () => { let webTracerWithZone: api.Tracer; let webTracerProviderWithZone: WebTracerProvider; let dummySpanExporter: DummySpanExporter; @@ -231,7 +265,7 @@ describe('xhr', () => { sinon.restore(); }; - prepareData = ( + const prepareData = ( done: any, fileUrl: string, config?: XMLHttpRequestInstrumentationConfig @@ -313,7 +347,10 @@ describe('xhr', () => { beforeEach(done => { const propagateTraceHeaderCorsUrls = [window.location.origin]; - prepareData(done, url, { propagateTraceHeaderCorsUrls }); + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + measureRequestSize: true, + }); }); afterEach(() => { @@ -363,40 +400,55 @@ describe('xhr', () => { const keys = Object.keys(attributes); assert.strictEqual( - attributes[keys[0]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); - assert.ok( - (attributes[keys[2]] as number) > 0, + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + 30, `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} <= 0` ); assert.strictEqual( - attributes[keys[3]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 200, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); assert.strictEqual( - attributes[keys[4]], + attributes[AttributeNames.HTTP_STATUS_TEXT], 'OK', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.strictEqual( - attributes[keys[5]], + attributes[SEMATTRS_HTTP_HOST], parseUrl(url).host, `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[6]] === 'http' || attributes[keys[6]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[7]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); @@ -808,7 +860,7 @@ describe('xhr', () => { }); }); - describe('when request is NOT successful', () => { + describe('when GET request is NOT successful', () => { let webTracerWithZoneProvider: WebTracerProvider; let webTracerWithZone: api.Tracer; let dummySpanExporter: DummySpanExporter; @@ -962,41 +1014,55 @@ describe('xhr', () => { const keys = Object.keys(attributes); assert.strictEqual( - attributes[keys[0]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; assert.strictEqual( - attributes[keys[2]], + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, 0, - `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH} is wrong` + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} <= 0` ); assert.strictEqual( - attributes[keys[3]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 400, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); assert.strictEqual( - attributes[keys[4]], + attributes[AttributeNames.HTTP_STATUS_TEXT], 'Bad Request', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.strictEqual( - attributes[keys[5]], + attributes[SEMATTRS_HTTP_HOST], 'raw.githubusercontent.com', `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[6]] === 'http' || attributes[keys[6]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[7]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); @@ -1036,38 +1102,57 @@ describe('xhr', () => { const keys = Object.keys(attributes); assert.strictEqual( - attributes[keys[0]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); assert.strictEqual( - attributes[keys[2]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 0, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); assert.strictEqual( - attributes[keys[3]], + attributes[AttributeNames.HTTP_STATUS_TEXT], '', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.strictEqual( - attributes[keys[4]], + attributes[SEMATTRS_HTTP_HOST], 'raw.githubusercontent.com', `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[5]] === 'http' || attributes[keys[5]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[6]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); }); @@ -1102,38 +1187,57 @@ describe('xhr', () => { const keys = Object.keys(attributes); assert.strictEqual( - attributes[keys[0]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); assert.strictEqual( - attributes[keys[2]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 0, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); assert.strictEqual( - attributes[keys[3]], + attributes[AttributeNames.HTTP_STATUS_TEXT], '', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.strictEqual( - attributes[keys[4]], + attributes[SEMATTRS_HTTP_HOST], 'raw.githubusercontent.com', `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[5]] === 'http' || attributes[keys[5]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[6]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); }); @@ -1168,38 +1272,1238 @@ describe('xhr', () => { const keys = Object.keys(attributes); assert.strictEqual( - attributes[keys[0]], + attributes[SEMATTRS_HTTP_METHOD], 'GET', `attributes ${SEMATTRS_HTTP_METHOD} is wrong` ); assert.strictEqual( - attributes[keys[1]], + attributes[SEMATTRS_HTTP_URL], url, `attributes ${SEMATTRS_HTTP_URL} is wrong` ); assert.strictEqual( - attributes[keys[2]], + attributes[SEMATTRS_HTTP_STATUS_CODE], 0, `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` ); assert.strictEqual( - attributes[keys[3]], + attributes[AttributeNames.HTTP_STATUS_TEXT], '', `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` ); assert.strictEqual( - attributes[keys[4]], + attributes[SEMATTRS_HTTP_HOST], 'raw.githubusercontent.com', `attributes ${SEMATTRS_HTTP_HOST} is wrong` ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; assert.ok( - attributes[keys[5]] === 'http' || attributes[keys[5]] === 'https', + httpScheme === 'http' || httpScheme === 'https', `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` ); - assert.ok( - attributes[keys[6]] !== '', + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); + + assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + EventNames.EVENT_TIMEOUT, + ]); + assert.strictEqual(events.length, 3, 'number of events is wrong'); + }); + }); + + describe('when applyCustomAttributesOnSpan hook is present', () => { + describe('AND request loads and receives an error code', () => { + beforeEach(done => { + clearData(); + prepareData({ + applyCustomAttributesOnSpan: function (span, xhr) { + span.setAttribute('xhr-custom-error-code', xhr.status); + }, + }); + erroredRequest(done); + }); + + it('span should have custom attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + assert.ok(attributes['xhr-custom-error-code'] === 400); + }); + }); + + describe('AND request encounters a network error', () => { + beforeEach(done => { + clearData(); + prepareData({ + applyCustomAttributesOnSpan: function (span, xhr) { + span.setAttribute('xhr-custom-error-code', xhr.status); + }, + }); + networkErrorRequest(done); + }); + + it('span should have custom attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + assert.ok(attributes['xhr-custom-error-code'] === 0); + }); + }); + + describe('AND request is aborted', () => { + before(function () { + // Can only abort Async requests + if (!testAsync) { + this.skip(); + } + }); + + beforeEach(done => { + clearData(); + prepareData({ + applyCustomAttributesOnSpan: function (span, xhr) { + span.setAttribute('xhr-custom-error-code', xhr.status); + }, + }); + abortedRequest(done); + }); + + it('span should have custom attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + assert.ok(attributes['xhr-custom-error-code'] === 0); + }); + }); + + describe('AND request times out', () => { + before(function () { + // Can only set timeout for Async requests + if (!testAsync) { + this.skip(); + } + }); + + beforeEach(done => { + clearData(); + prepareData({ + applyCustomAttributesOnSpan: function (span, xhr) { + span.setAttribute('xhr-custom-error-code', xhr.status); + }, + }); + timedOutRequest(done); + }); + + it('span should have custom attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + assert.ok(attributes['xhr-custom-error-code'] === 0); + }); + }); + }); + }); + + describe('when POST request is successful', () => { + let webTracerWithZone: api.Tracer; + let webTracerProviderWithZone: WebTracerProvider; + let dummySpanExporter: DummySpanExporter; + let exportSpy: any; + let clearResourceTimingsSpy: any; + let rootSpan: api.Span; + let spyEntries: any; + const url = 'http://localhost:8090/xml-http-request.js'; + const secureUrl = 'https://localhost:8090/xml-http-request.js'; + let fakeNow = 0; + let xmlHttpRequestInstrumentation: XMLHttpRequestInstrumentation; + + clearData = () => { + requests = []; + sinon.restore(); + }; + + const prepareData = ( + done: any, + fileUrl: string, + config?: XMLHttpRequestInstrumentationConfig + ) => { + const fakeXhr = sinon.useFakeXMLHttpRequest(); + fakeXhr.onCreate = function (xhr: any) { + requests.push(xhr); + }; + sinon.useFakeTimers(); + + sinon.stub(performance, 'timeOrigin').value(0); + sinon.stub(performance, 'now').callsFake(() => fakeNow); + + const resources: PerformanceResourceTiming[] = []; + resources.push( + createResource({ + name: fileUrl, + }), + createMainResource({ + name: fileUrl, + }) + ); + + spyEntries = sinon.stub( + performance as unknown as Performance, + 'getEntriesByType' + ); + spyEntries.withArgs('resource').returns(resources); + + sinon + .stub(window, 'PerformanceObserver') + .value(createFakePerformanceObs(fileUrl)); + + xmlHttpRequestInstrumentation = new XMLHttpRequestInstrumentation( + config + ); + webTracerProviderWithZone = new WebTracerProvider(); + registerInstrumentations({ + instrumentations: [xmlHttpRequestInstrumentation], + tracerProvider: webTracerProviderWithZone, + }); + webTracerWithZone = webTracerProviderWithZone.getTracer('xhr-test'); + dummySpanExporter = new DummySpanExporter(); + exportSpy = sinon.stub(dummySpanExporter, 'export'); + clearResourceTimingsSpy = sinon.stub( + performance as unknown as Performance, + 'clearResourceTimings' + ); + webTracerProviderWithZone.addSpanProcessor( + new tracing.SimpleSpanProcessor(dummySpanExporter) + ); + + rootSpan = webTracerWithZone.startSpan('root'); + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + new XMLHttpRequest(), + fileUrl, + '{"embedded":"data"}', + () => { + fakeNow = 100; + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + assert.strictEqual(requests.length, 1, 'request not called'); + + requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + '{"foo":"bar"}' + ); + } + ); + }; + + beforeEach(done => { + const propagateTraceHeaderCorsUrls = [window.location.origin]; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + measureRequestSize: true, + }); + }); + + afterEach(() => { + clearData(); + }); + + it('should patch to wrap XML HTTP Requests when enabled', () => { + const xhttp = new XMLHttpRequest(); + assert.ok(isWrapped(xhttp.send)); + xmlHttpRequestInstrumentation.enable(); + assert.ok(isWrapped(xhttp.send)); + }); + + it('should unpatch to unwrap XML HTTP Requests when disabled', () => { + const xhttp = new XMLHttpRequest(); + assert.ok(isWrapped(xhttp.send)); + xmlHttpRequestInstrumentation.disable(); + assert.ok(!isWrapped(xhttp.send)); + }); + + it('should create a span with correct root span', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + rootSpan.spanContext().spanId, + 'parent span is not root span' + ); + }); + + it('span should have correct name', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual(span.name, 'POST', 'span has wrong name'); + }); + + it('span should have correct kind', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.kind, + api.SpanKind.CLIENT, + 'span has wrong kind' + ); + }); + + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[SEMATTRS_HTTP_METHOD], + 'POST', + `attributes ${SEMATTRS_HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + url, + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + 19, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} !== 19` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + 30, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} <= 0` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_STATUS_CODE], + 200, + `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` + ); + assert.strictEqual( + attributes[AttributeNames.HTTP_STATUS_TEXT], + 'OK', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_HOST], + parseUrl(url).host, + `attributes ${SEMATTRS_HTTP_HOST} is wrong` + ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; + assert.ok( + httpScheme === 'http' || httpScheme === 'https', + `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` + ); + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', + `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` + ); + + assert.strictEqual(keys.length, 9, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const events = span.events; + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + PTN.FETCH_START, + PTN.DOMAIN_LOOKUP_START, + PTN.DOMAIN_LOOKUP_END, + PTN.CONNECT_START, + PTN.CONNECT_END, + PTN.REQUEST_START, + PTN.RESPONSE_START, + PTN.RESPONSE_END, + EventNames.EVENT_LOAD, + ]); + assert.strictEqual(events.length, 11, 'number of events is wrong'); + }); + + it('should create a span for preflight request', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const parentSpan: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + parentSpan.spanContext().spanId, + 'parent span is not root span' + ); + }); + + it('preflight request span should have correct name', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + assert.strictEqual( + span.name, + 'CORS Preflight', + 'preflight request span has wrong name' + ); + }); + + it('preflight request span should have correct kind', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + assert.strictEqual( + span.kind, + api.SpanKind.INTERNAL, + 'span has wrong kind' + ); + }); + + it('preflight request span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + assert.strictEqual(events.length, 8, 'number of events is wrong'); + testForCorrectEvents(events, [ + PTN.FETCH_START, + PTN.DOMAIN_LOOKUP_START, + PTN.DOMAIN_LOOKUP_END, + PTN.CONNECT_START, + PTN.CONNECT_END, + PTN.REQUEST_START, + PTN.RESPONSE_START, + PTN.RESPONSE_END, + ]); + }); + + it('should NOT clear the resources', () => { + assert.ok( + clearResourceTimingsSpy.notCalled, + 'resources have been cleared' + ); + }); + describe('When making https requests', () => { + beforeEach(done => { + clearData(); + // this won't generate a preflight span + const propagateTraceHeaderCorsUrls = [secureUrl]; + prepareData(done, secureUrl, { + propagateTraceHeaderCorsUrls, + }); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const events = span.events; + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + PTN.FETCH_START, + PTN.DOMAIN_LOOKUP_START, + PTN.DOMAIN_LOOKUP_END, + PTN.CONNECT_START, + PTN.SECURE_CONNECTION_START, + PTN.CONNECT_END, + PTN.REQUEST_START, + PTN.RESPONSE_START, + PTN.RESPONSE_END, + EventNames.EVENT_LOAD, + ]); + assert.strictEqual(events.length, 12, 'number of events is wrong'); + }); + + it('preflight request span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + assert.strictEqual(events.length, 9, 'number of events is wrong'); + testForCorrectEvents(events, [ + PTN.FETCH_START, + PTN.DOMAIN_LOOKUP_START, + PTN.DOMAIN_LOOKUP_END, + PTN.CONNECT_START, + PTN.SECURE_CONNECTION_START, + PTN.CONNECT_END, + PTN.REQUEST_START, + PTN.RESPONSE_START, + PTN.RESPONSE_END, + ]); + }); + }); + + describe('AND origin match with window.location', () => { + beforeEach(done => { + clearData(); + // this won't generate a preflight span + const propagateTraceHeaderCorsUrls = [url]; + prepareData(done, window.location.origin + '/xml-http-request.js', { + propagateTraceHeaderCorsUrls, + }); + }); + + it('should set trace headers', () => { + const span: api.Span = exportSpy.args[0][0][0]; + assert.strictEqual( + requests[0].requestHeaders[X_B3_TRACE_ID], + span.spanContext().traceId, + `trace header '${X_B3_TRACE_ID}' not set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SPAN_ID], + span.spanContext().spanId, + `trace header '${X_B3_SPAN_ID}' not set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SAMPLED], + String(span.spanContext().traceFlags), + `trace header '${X_B3_SAMPLED}' not set` + ); + }); + }); + + describe( + 'AND origin does NOT match window.location but match with' + + ' propagateTraceHeaderCorsUrls', + () => { + beforeEach(done => { + clearData(); + prepareData( + done, + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json', + { propagateTraceHeaderCorsUrls: /raw\.githubusercontent\.com/ } + ); + }); + it('should set trace headers', () => { + // span at exportSpy.args[0][0][0] is the preflight span + const span: api.Span = exportSpy.args[1][0][0]; + assert.strictEqual( + requests[0].requestHeaders[X_B3_TRACE_ID], + span.spanContext().traceId, + `trace header '${X_B3_TRACE_ID}' not set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SPAN_ID], + span.spanContext().spanId, + `trace header '${X_B3_SPAN_ID}' not set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SAMPLED], + String(span.spanContext().traceFlags), + `trace header '${X_B3_SAMPLED}' not set` + ); + }); + } + ); + describe( + 'AND origin does NOT match window.location And does NOT match' + + ' with propagateTraceHeaderCorsUrls', + () => { + let spyDebug: sinon.SinonSpy; + beforeEach(done => { + const diagLogger = new api.DiagConsoleLogger(); + spyDebug = sinon.spy(); + diagLogger.debug = spyDebug; + api.diag.setLogger(diagLogger, api.DiagLogLevel.ALL); + clearData(); + prepareData( + done, + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json' + ); + }); + it('should NOT set trace headers', () => { + assert.strictEqual( + requests[0].requestHeaders[X_B3_TRACE_ID], + undefined, + `trace header '${X_B3_TRACE_ID}' should not be set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SPAN_ID], + undefined, + `trace header '${X_B3_SPAN_ID}' should not be set` + ); + assert.strictEqual( + requests[0].requestHeaders[X_B3_SAMPLED], + undefined, + `trace header '${X_B3_SAMPLED}' should not be set` + ); + }); + + it('should debug info that injecting headers was skipped', () => { + assert.strictEqual( + spyDebug.lastCall.args[1], + 'headers inject skipped due to CORS policy' + ); + }); + } + ); + + describe('when url is ignored', () => { + beforeEach(done => { + clearData(); + const propagateTraceHeaderCorsUrls = url; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + ignoreUrls: [propagateTraceHeaderCorsUrls], + }); + }); + + it('should NOT create any span', () => { + assert.ok(exportSpy.notCalled, "span shouldn't be exported"); + }); + }); + + describe('when clearTimingResources is set', () => { + beforeEach(done => { + clearData(); + const propagateTraceHeaderCorsUrls = url; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + clearTimingResources: true, + }); + }); + + it('should clear the resources', () => { + assert.ok( + clearResourceTimingsSpy.calledOnce, + "resources haven't been cleared" + ); + }); + }); + + describe('when reusing the same XML Http request', () => { + const firstUrl = 'http://localhost:8090/get'; + const secondUrl = 'http://localhost:8099/get'; + + beforeEach(done => { + requests = []; + const reusableReq = new XMLHttpRequest(); + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + reusableReq, + firstUrl, + '{"embedded":"data"}', + () => { + fakeNow = 100; + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + }); + } + ); + + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + reusableReq, + secondUrl, + '{"embedded":"data"}', + () => { + fakeNow = 100; + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + + assert.strictEqual( + requests.length, + 1, + 'first request not called' + ); + + requests[0].respond( + 200, + { 'Content-Type': 'application/json' }, + '{"foo":"bar"}' + ); + } + ); + }); + + it('should clear previous span information', () => { + const span: tracing.ReadableSpan = exportSpy.args[2][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[keys[1]], + secondUrl, + `attribute ${SEMATTRS_HTTP_URL} is wrong` + ); + }); + }); + + describe('and applyCustomAttributesOnSpan hook is configured', () => { + beforeEach(done => { + clearData(); + const propagateTraceHeaderCorsUrls = [url]; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + applyCustomAttributesOnSpan: function ( + span: api.Span, + xhr: XMLHttpRequest + ) { + const res = JSON.parse(xhr.response); + span.setAttribute('xhr-custom-attribute', res.foo); + }, + }); + }); + + it('span should have custom attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + assert.ok(attributes['xhr-custom-attribute'] === 'bar'); + }); + }); + + describe('when using relative url', () => { + beforeEach(done => { + clearData(); + const propagateTraceHeaderCorsUrls = [window.location.origin]; + prepareData(done, '/get', { propagateTraceHeaderCorsUrls }); + }); + + it('should create correct span with events', () => { + // no prefetch span because mock observer uses location.origin as url when relative + // and prefetch span finding compares url origins + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + + assert.strictEqual( + exportSpy.args.length, + 1, + `Wrong number of spans: ${exportSpy.args.length}` + ); + + assert.strictEqual( + events.length, + 11, + `number of events is wrong: ${events.length}` + ); + assert.strictEqual( + events[7].name, + PTN.REQUEST_START, + `event ${PTN.REQUEST_START} is not defined` + ); + }); + + it('should have an absolute http.url attribute', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + location.origin + '/get', + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + }); + }); + }); + + describe('when POST request is NOT successful', () => { + let webTracerWithZoneProvider: WebTracerProvider; + let webTracerWithZone: api.Tracer; + let dummySpanExporter: DummySpanExporter; + let exportSpy: any; + let rootSpan: api.Span; + let spyEntries: any; + const url = + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; + let fakeNow = 0; + + const prepareData = function ( + config: XMLHttpRequestInstrumentationConfig = {} + ) { + const fakeXhr = sinon.useFakeXMLHttpRequest(); + fakeXhr.onCreate = function (xhr: any) { + requests.push(xhr); + }; + + sinon.useFakeTimers(); + + sinon.stub(performance, 'timeOrigin').value(0); + sinon.stub(performance, 'now').callsFake(() => fakeNow); + + const resources: PerformanceResourceTiming[] = []; + resources.push( + createResource({ + name: url, + }) + ); + + spyEntries = sinon.stub( + performance as unknown as Performance, + 'getEntriesByType' + ); + spyEntries.withArgs('resource').returns(resources); + + webTracerWithZoneProvider = new WebTracerProvider(); + + registerInstrumentations({ + instrumentations: [new XMLHttpRequestInstrumentation(config)], + tracerProvider: webTracerWithZoneProvider, + }); + + dummySpanExporter = new DummySpanExporter(); + exportSpy = sinon.stub(dummySpanExporter, 'export'); + webTracerWithZoneProvider.addSpanProcessor( + new tracing.SimpleSpanProcessor(dummySpanExporter) + ); + webTracerWithZone = webTracerWithZoneProvider.getTracer('xhr-test'); + + rootSpan = webTracerWithZone.startSpan('root'); + }; + + beforeEach(() => { + prepareData(); + }); + + afterEach(() => { + clearData(); + }); + + function timedOutRequest(done: any) { + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + new XMLHttpRequest(), + url, + '{"embedded":"data"}', + () => { + sinon.clock.tick(XHR_TIMEOUT); + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + } + ); + } + + function abortedRequest(done: any) { + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + new XMLHttpRequest(), + url, + '{"embedded":"data"}', + () => {}, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + + assert.strictEqual(requests.length, 1, 'request not called'); + requests[0].abort(); + } + ); + } + + function erroredRequest(done: any) { + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + new XMLHttpRequest(), + url, + '{"embedded":"data"}', + () => { + fakeNow = 100; + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + assert.strictEqual(requests.length, 1, 'request not called'); + requests[0].respond( + 400, + { 'Content-Type': 'text/plain' }, + 'Bad Request' + ); + } + ); + } + + function networkErrorRequest(done: any) { + api.context.with( + api.trace.setSpan(api.context.active(), rootSpan), + () => { + void postData( + new XMLHttpRequest(), + url, + '{"embedded":"data"}', + () => {}, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + + assert.strictEqual(requests.length, 1, 'request not called'); + requests[0].error(); + } + ); + } + + describe('when request loads and receives an error code', () => { + beforeEach(done => { + erroredRequest(done); + }); + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[SEMATTRS_HTTP_METHOD], + 'POST', + `attributes ${SEMATTRS_HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + url, + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + 0, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} <= 0` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_STATUS_CODE], + 400, + `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` + ); + assert.strictEqual( + attributes[AttributeNames.HTTP_STATUS_TEXT], + 'Bad Request', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_HOST], + 'raw.githubusercontent.com', + `attributes ${SEMATTRS_HTTP_HOST} is wrong` + ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; + assert.ok( + httpScheme === 'http' || httpScheme === 'https', + `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` + ); + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', + `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` + ); + + assert.strictEqual(keys.length, 8, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + PTN.FETCH_START, + PTN.DOMAIN_LOOKUP_START, + PTN.DOMAIN_LOOKUP_END, + PTN.CONNECT_START, + PTN.SECURE_CONNECTION_START, + PTN.CONNECT_END, + PTN.REQUEST_START, + PTN.RESPONSE_START, + PTN.RESPONSE_END, + EventNames.EVENT_ERROR, + ]); + assert.strictEqual(events.length, 12, 'number of events is wrong'); + }); + }); + + describe('when request encounters a network error', () => { + beforeEach(done => { + networkErrorRequest(done); + }); + + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[SEMATTRS_HTTP_METHOD], + 'POST', + `attributes ${SEMATTRS_HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + url, + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_STATUS_CODE], + 0, + `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` + ); + assert.strictEqual( + attributes[AttributeNames.HTTP_STATUS_TEXT], + '', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_HOST], + 'raw.githubusercontent.com', + `attributes ${SEMATTRS_HTTP_HOST} is wrong` + ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; + assert.ok( + httpScheme === 'http' || httpScheme === 'https', + `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` + ); + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', + `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` + ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); + + assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + EventNames.EVENT_ERROR, + ]); + assert.strictEqual(events.length, 3, 'number of events is wrong'); + }); + }); + + describe('when request is aborted', () => { + before(function () { + // Can only abort Async requests + if (!testAsync) { + this.skip(); + } + }); + + beforeEach(done => { + abortedRequest(done); + }); + + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[SEMATTRS_HTTP_METHOD], + 'POST', + `attributes ${SEMATTRS_HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + url, + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_STATUS_CODE], + 0, + `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` + ); + assert.strictEqual( + attributes[AttributeNames.HTTP_STATUS_TEXT], + '', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_HOST], + 'raw.githubusercontent.com', + `attributes ${SEMATTRS_HTTP_HOST} is wrong` + ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; + assert.ok( + httpScheme === 'http' || httpScheme === 'https', + `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` + ); + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', + `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` + ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); + + assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + testForCorrectEvents(events, [ + EventNames.METHOD_OPEN, + EventNames.METHOD_SEND, + EventNames.EVENT_ABORT, + ]); + assert.strictEqual(events.length, 3, 'number of events is wrong'); + }); + }); + + describe('when request times out', () => { + before(function () { + // Can only set timeout for Async requests + if (!testAsync) { + this.skip(); + } + }); + + beforeEach(done => { + timedOutRequest(done); + }); + + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.strictEqual( + attributes[SEMATTRS_HTTP_METHOD], + 'POST', + `attributes ${SEMATTRS_HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_URL], + url, + `attributes ${SEMATTRS_HTTP_URL} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_STATUS_CODE], + 0, + `attributes ${SEMATTRS_HTTP_STATUS_CODE} is wrong` + ); + assert.strictEqual( + attributes[AttributeNames.HTTP_STATUS_TEXT], + '', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.strictEqual( + attributes[SEMATTRS_HTTP_HOST], + 'raw.githubusercontent.com', + `attributes ${SEMATTRS_HTTP_HOST} is wrong` + ); + + const httpScheme = attributes[SEMATTRS_HTTP_SCHEME]; + assert.ok( + httpScheme === 'http' || httpScheme === 'https', + `attributes ${SEMATTRS_HTTP_SCHEME} is wrong` + ); + assert.notStrictEqual( + attributes[SEMATTRS_HTTP_USER_AGENT], + '', + `attributes ${SEMATTRS_HTTP_USER_AGENT} is not defined` + ); + const requestContentLength = attributes[ + SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED + ] as number; + assert.strictEqual( + requestContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED} is defined` + ); + const responseContentLength = attributes[ + SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH + ] as number; + assert.strictEqual( + responseContentLength, + undefined, + `attributes ${SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH} is defined` + ); assert.strictEqual(keys.length, 7, 'number of attributes is wrong'); }); diff --git a/tsconfig.base.json b/tsconfig.base.json index cbafb67678a..e48dfc46a1f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,7 +21,12 @@ "strict": true, "strictNullChecks": true, "target": "es2017", - "useUnknownInCatchVariables": false + "useUnknownInCatchVariables": false, + "lib": [ + "es2017", + "dom", + "dom.iterable" + ] }, "exclude": [ "node_modules"