diff --git a/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts b/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts index ab9f4f4fa9..fb7274cdce 100644 --- a/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts +++ b/packages/opentelemetry-instrumentation-xml-http-request/src/xhr.ts @@ -19,6 +19,7 @@ import { isWrapped, InstrumentationBase, InstrumentationConfig, + safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import { hrTime, isUrlIgnored, otperformance } from '@opentelemetry/core'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; @@ -45,6 +46,11 @@ import { AttributeNames } from './enums/AttributeNames'; // safe enough const OBSERVER_WAIT_TIME_MS = 300; +export type XHRCustomAttributeFunction = ( + span: api.Span, + xhr: XMLHttpRequest +) => void; + /** * XMLHttpRequest config */ @@ -64,6 +70,8 @@ export interface XMLHttpRequestInstrumentationConfig * also not be traced. */ ignoreUrls?: Array; + /** Function for adding custom attributes on the span */ + applyCustomAttributesOnSpan?: XHRCustomAttributeFunction; } /** @@ -171,6 +179,24 @@ export class XMLHttpRequestInstrumentation extends InstrumentationBase applyCustomAttributesOnSpan(span, xhr), + error => { + if (!error) { + return; + } + + api.diag.error('applyCustomAttributesOnSpan', error); + }, + true + ); + } + } + /** * will collect information about all resources created * between "send" and "end" with additional waiting for main resource @@ -398,6 +424,10 @@ export class XMLHttpRequestInstrumentation extends InstrumentationBase { sinon.restore(); }; - prepareData = (done: any, fileUrl: string, config?: any) => { + prepareData = ( + done: any, + fileUrl: string, + config?: XMLHttpRequestInstrumentationConfig + ) => { const fakeXhr = sinon.useFakeXMLHttpRequest(); fakeXhr.onCreate = function (xhr: any) { requests.push(xhr); @@ -698,6 +705,29 @@ describe('xhr', () => { ); }); }); + + 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 request is NOT successful', () => { @@ -711,7 +741,9 @@ describe('xhr', () => { 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; let fakeNow = 0; - beforeEach(() => { + const prepareData = function ( + config: XMLHttpRequestInstrumentationConfig = {} + ) { const fakeXhr = sinon.useFakeXMLHttpRequest(); fakeXhr.onCreate = function (xhr: any) { requests.push(xhr); @@ -738,7 +770,7 @@ describe('xhr', () => { webTracerWithZoneProvider = new WebTracerProvider(); registerInstrumentations({ - instrumentations: [new XMLHttpRequestInstrumentation()], + instrumentations: [new XMLHttpRequestInstrumentation(config)], tracerProvider: webTracerWithZoneProvider, }); @@ -750,37 +782,89 @@ describe('xhr', () => { webTracerWithZone = webTracerWithZoneProvider.getTracer('xhr-test'); rootSpan = webTracerWithZone.startSpan('root'); + }; + + beforeEach(() => { + prepareData(); }); afterEach(() => { clearData(); }); - describe('when request loads and receives an error code', () => { - beforeEach(done => { - api.context.with( - api.setSpan(api.context.active(), rootSpan), + function timedOutRequest(done: any) { + api.context.with(api.setSpan(api.context.active(), rootSpan), () => { + void getData( + new XMLHttpRequest(), + url, () => { - getData( - new XMLHttpRequest(), - url, - () => { - 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' - ); + sinon.clock.tick(XHR_TIMEOUT); + }, + testAsync + ).then(() => { + fakeNow = 0; + sinon.clock.tick(1000); + done(); + }); + }); + } + + function abortedRequest(done: any) { + api.context.with(api.setSpan(api.context.active(), rootSpan), () => { + void getData(new XMLHttpRequest(), url, () => {}, 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.setSpan(api.context.active(), rootSpan), () => { + void getData( + new XMLHttpRequest(), + url, + () => { + 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.setSpan(api.context.active(), rootSpan), () => { + void getData(new XMLHttpRequest(), url, () => {}, 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]; @@ -900,21 +984,7 @@ describe('xhr', () => { describe('when request encounters a network error', () => { beforeEach(done => { - api.context.with( - api.setSpan(api.context.active(), rootSpan), - () => { - getData(new XMLHttpRequest(), url, () => {}, testAsync).then( - () => { - fakeNow = 0; - sinon.clock.tick(1000); - done(); - } - ); - - assert.strictEqual(requests.length, 1, 'request not called'); - requests[0].error(); - } - ); + networkErrorRequest(done); }); it('span should have correct attributes', () => { @@ -992,21 +1062,7 @@ describe('xhr', () => { }); beforeEach(done => { - api.context.with( - api.setSpan(api.context.active(), rootSpan), - () => { - getData(new XMLHttpRequest(), url, () => {}, testAsync).then( - () => { - fakeNow = 0; - sinon.clock.tick(1000); - done(); - } - ); - - assert.strictEqual(requests.length, 1, 'request not called'); - requests[0].abort(); - } - ); + abortedRequest(done); }); it('span should have correct attributes', () => { @@ -1084,23 +1140,7 @@ describe('xhr', () => { }); beforeEach(done => { - api.context.with( - api.setSpan(api.context.active(), rootSpan), - () => { - getData( - new XMLHttpRequest(), - url, - () => { - sinon.clock.tick(XHR_TIMEOUT); - }, - testAsync - ).then(() => { - fakeNow = 0; - sinon.clock.tick(1000); - done(); - }); - } - ); + timedOutRequest(done); }); it('span should have correct attributes', () => { @@ -1168,6 +1208,94 @@ describe('xhr', () => { 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); + }); + }); + }); }); }); });