diff --git a/packages/opentelemetry-exporter-collector/README.md b/packages/opentelemetry-exporter-collector/README.md index d31f7a73704..63fa55d13b3 100644 --- a/packages/opentelemetry-exporter-collector/README.md +++ b/packages/opentelemetry-exporter-collector/README.md @@ -15,15 +15,16 @@ npm install --save @opentelemetry/exporter-collector ``` ## Usage in Web -The CollectorExporter in Web expects the endpoint to end in `/v1/trace`. +The CollectorExporter in Web expects the endpoint to end in `/v1/trace`. ```js import { SimpleSpanProcessor } from '@opentelemetry/tracing'; import { WebTracerProvider } from '@opentelemetry/web'; -import { CollectorExporter } from '@opentelemetry/exporter-collector' +import { CollectorExporter } from '@opentelemetry/exporter-collector'; const collectorOptions = { - url: '' // url is optional and can be omitted - default is http://localhost:55678/v1/trace + url: '', // url is optional and can be omitted - default is http://localhost:55678/v1/trace + headers: {}, //an optional object containing custom headers to be sent with each request }; const provider = new WebTracerProvider(); @@ -81,6 +82,29 @@ provider.register(); To see how to generate credentials, you can refer to the script used to generate certificates for tests [here](./test/certs/regenerate.sh) +The exporter can be configured to send custom metadata with each request as in the example below: + +```js +const grpc = require('grpc'); +const { BasicTracerProvider, SimpleSpanProcessor } = require('@opentelemetry/tracing'); +const { CollectorExporter } = require('@opentelemetry/exporter-collector'); + +const metadata = new grpc.Metadata(); +metadata.set('k', 'v'); + +const collectorOptions = { + serviceName: 'basic-service', + url: '', // url is optional and can be omitted - default is localhost:55678 + metadata, // // an optional grpc.Metadata object to be sent with each request +}; + +const provider = new BasicTracerProvider(); +const exporter = new CollectorExporter(collectorOptions); +provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); + +provider.register(); +``` + Note, that this will only work if TLS is also configured on the server. ## Running opentelemetry-collector locally to see the traces diff --git a/packages/opentelemetry-exporter-collector/src/platform/browser/CollectorExporter.ts b/packages/opentelemetry-exporter-collector/src/platform/browser/CollectorExporter.ts index b6607258e1e..5e7f121ac62 100644 --- a/packages/opentelemetry-exporter-collector/src/platform/browser/CollectorExporter.ts +++ b/packages/opentelemetry-exporter-collector/src/platform/browser/CollectorExporter.ts @@ -22,7 +22,12 @@ import { ReadableSpan } from '@opentelemetry/tracing'; import { toCollectorExportTraceServiceRequest } from '../../transform'; import * as collectorTypes from '../../types'; -export type CollectorExporterConfig = CollectorExporterConfigBase; +/** + * Collector Exporter Config for Web + */ +export interface CollectorExporterConfig extends CollectorExporterConfigBase { + headers?: { [key: string]: string }; +} const DEFAULT_COLLECTOR_URL = 'http://localhost:55678/v1/trace'; @@ -32,6 +37,22 @@ const DEFAULT_COLLECTOR_URL = 'http://localhost:55678/v1/trace'; export class CollectorExporter extends CollectorExporterBase< CollectorExporterConfig > { + DEFAULT_HEADERS: { [key: string]: string } = { + [collectorTypes.OT_REQUEST_HEADER]: '1', + }; + private _headers: { [key: string]: string }; + private _useXHR: boolean = false; + + /** + * @param config + */ + constructor(config: CollectorExporterConfig = {}) { + super(config); + this._headers = config.headers || this.DEFAULT_HEADERS; + this._useXHR = + !!config.headers || typeof navigator.sendBeacon !== 'function'; + } + onInit(): void { window.addEventListener('unload', this.shutdown); } @@ -53,13 +74,12 @@ export class CollectorExporter extends CollectorExporterBase< spans, this ); - const body = JSON.stringify(exportTraceServiceRequest); - if (typeof navigator.sendBeacon === 'function') { - this._sendSpansWithBeacon(body, onSuccess, onError); - } else { + if (this._useXHR) { this._sendSpansWithXhr(body, onSuccess, onError); + } else { + this._sendSpansWithBeacon(body, onSuccess, onError); } } @@ -97,9 +117,12 @@ export class CollectorExporter extends CollectorExporterBase< ) { const xhr = new XMLHttpRequest(); xhr.open('POST', this.url); - xhr.setRequestHeader(collectorTypes.OT_REQUEST_HEADER, '1'); xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json'); + Object.entries(this._headers).forEach(([k, v]) => { + xhr.setRequestHeader(k, v); + }); + xhr.send(body); xhr.onreadystatechange = () => { diff --git a/packages/opentelemetry-exporter-collector/src/platform/node/CollectorExporter.ts b/packages/opentelemetry-exporter-collector/src/platform/node/CollectorExporter.ts index 65968be05b1..e5c767ecf3a 100644 --- a/packages/opentelemetry-exporter-collector/src/platform/node/CollectorExporter.ts +++ b/packages/opentelemetry-exporter-collector/src/platform/node/CollectorExporter.ts @@ -36,6 +36,7 @@ const DEFAULT_COLLECTOR_URL = 'localhost:55678'; */ export interface CollectorExporterConfig extends CollectorExporterConfigBase { credentials?: grpc.ChannelCredentials; + metadata?: grpc.Metadata; } /** @@ -47,12 +48,14 @@ export class CollectorExporter extends CollectorExporterBase< isShutDown: boolean = false; traceServiceClient?: TraceServiceClient = undefined; grpcSpansQueue: GRPCQueueItem[] = []; + metadata?: grpc.Metadata; /** * @param config */ constructor(config: CollectorExporterConfig = {}) { super(config); + this.metadata = config.metadata; } onShutdown(): void { @@ -115,6 +118,7 @@ export class CollectorExporter extends CollectorExporterBase< this.traceServiceClient.export( exportTraceServiceRequest, + this.metadata, ( err: collectorTypes.opentelemetryProto.collector.trace.v1.ExportTraceServiceError ) => { diff --git a/packages/opentelemetry-exporter-collector/src/platform/node/types.ts b/packages/opentelemetry-exporter-collector/src/platform/node/types.ts index cdb4da9f074..4674fadf908 100644 --- a/packages/opentelemetry-exporter-collector/src/platform/node/types.ts +++ b/packages/opentelemetry-exporter-collector/src/platform/node/types.ts @@ -32,5 +32,9 @@ export interface GRPCQueueItem { * Trace Service Client for sending spans */ export interface TraceServiceClient extends grpc.Client { - export: (request: any, callback: Function) => {}; + export: ( + request: any, + metadata: grpc.Metadata | undefined, + callback: Function + ) => {}; } diff --git a/packages/opentelemetry-exporter-collector/test/browser/CollectorExporter.test.ts b/packages/opentelemetry-exporter-collector/test/browser/CollectorExporter.test.ts index d282cc1828c..265bf441366 100644 --- a/packages/opentelemetry-exporter-collector/test/browser/CollectorExporter.test.ts +++ b/packages/opentelemetry-exporter-collector/test/browser/CollectorExporter.test.ts @@ -28,6 +28,7 @@ import { ensureSpanIsCorrect, ensureExportTraceServiceRequestIsSet, ensureWebResourceIsCorrect, + ensureHeadersContain, mockedReadableSpan, } from '../helper'; const sendBeacon = navigator.sendBeacon; @@ -44,14 +45,6 @@ describe('CollectorExporter - web', () => { spyOpen = sinon.stub(XMLHttpRequest.prototype, 'open'); spySend = sinon.stub(XMLHttpRequest.prototype, 'send'); spyBeacon = sinon.stub(navigator, 'sendBeacon'); - collectorExporterConfig = { - hostName: 'foo', - logger: new NoopLogger(), - serviceName: 'bar', - attributes: {}, - url: 'http://foo.bar.com', - }; - collectorExporter = new CollectorExporter(collectorExporterConfig); spans = []; spans.push(Object.assign({}, mockedReadableSpan)); }); @@ -64,7 +57,21 @@ describe('CollectorExporter - web', () => { }); describe('export', () => { + beforeEach(() => { + collectorExporterConfig = { + hostName: 'foo', + logger: new NoopLogger(), + serviceName: 'bar', + attributes: {}, + url: 'http://foo.bar.com', + }; + }); + describe('when "sendBeacon" is available', () => { + beforeEach(() => { + collectorExporter = new CollectorExporter(collectorExporterConfig); + }); + it('should successfully send the spans using sendBeacon', done => { collectorExporter.export(spans, () => {}); @@ -139,6 +146,7 @@ describe('CollectorExporter - web', () => { let server: any; beforeEach(() => { (window.navigator as any).sendBeacon = false; + collectorExporter = new CollectorExporter(collectorExporterConfig); server = sinon.fakeServer.create(); }); afterEach(() => { @@ -216,6 +224,78 @@ describe('CollectorExporter - web', () => { done(); }); }); + + it('should send custom headers', done => { + collectorExporter.export(spans, () => {}); + + setTimeout(() => { + const request = server.requests[0]; + request.respond(200); + + assert.strictEqual(spyBeacon.callCount, 0); + done(); + }); + }); + }); + }); + + describe('export with custom headers', () => { + let server: any; + const customHeaders = { + foo: 'bar', + bar: 'baz', + }; + + beforeEach(() => { + collectorExporterConfig = { + logger: new NoopLogger(), + headers: customHeaders, + }; + server = sinon.fakeServer.create(); + }); + + afterEach(() => { + server.restore(); + }); + + describe('when "sendBeacon" is available', () => { + beforeEach(() => { + collectorExporter = new CollectorExporter(collectorExporterConfig); + }); + it('should successfully send custom headers using XMLHTTPRequest', done => { + collectorExporter.export(spans, () => {}); + + setTimeout(() => { + const [{ requestHeaders }] = server.requests; + + ensureHeadersContain(requestHeaders, customHeaders); + assert.strictEqual(spyBeacon.callCount, 0); + assert.strictEqual(spyOpen.callCount, 0); + + done(); + }); + }); + }); + + describe('when "sendBeacon" is NOT available', () => { + beforeEach(() => { + (window.navigator as any).sendBeacon = false; + collectorExporter = new CollectorExporter(collectorExporterConfig); + }); + + it('should successfully send spans using XMLHttpRequest', done => { + collectorExporter.export(spans, () => {}); + + setTimeout(() => { + const [{ requestHeaders }] = server.requests; + + ensureHeadersContain(requestHeaders, customHeaders); + assert.strictEqual(spyBeacon.callCount, 0); + assert.strictEqual(spyOpen.callCount, 0); + + done(); + }); + }); }); }); }); diff --git a/packages/opentelemetry-exporter-collector/test/helper.ts b/packages/opentelemetry-exporter-collector/test/helper.ts index 86ac2fc6881..4737c66b96e 100644 --- a/packages/opentelemetry-exporter-collector/test/helper.ts +++ b/packages/opentelemetry-exporter-collector/test/helper.ts @@ -20,6 +20,7 @@ import { Resource } from '@opentelemetry/resources'; import * as assert from 'assert'; import { opentelemetryProto } from '../src/types'; import * as collectorTypes from '../src/types'; +import * as grpc from 'grpc'; if (typeof Buffer === 'undefined') { (window as any).Buffer = { @@ -509,3 +510,26 @@ export function ensureExportTraceServiceRequestIsSet( const spans = instrumentationLibrarySpans[0].spans; assert.strictEqual(spans && spans.length, 1, 'spans are missing'); } + +export function ensureMetadataIsCorrect( + actual: grpc.Metadata, + expected: grpc.Metadata +) { + //ignore user agent + expected.remove('user-agent'); + actual.remove('user-agent'); + assert.deepStrictEqual(actual.getMap(), expected.getMap()); +} + +export function ensureHeadersContain( + actual: { [key: string]: string }, + expected: { [key: string]: string } +) { + Object.entries(expected).forEach(([k, v]) => { + assert.strictEqual( + v, + actual[k], + `Expected ${actual} to contain ${k}: ${v}` + ); + }); +} diff --git a/packages/opentelemetry-exporter-collector/test/node/CollectorExporter.test.ts b/packages/opentelemetry-exporter-collector/test/node/CollectorExporter.test.ts index 2aa2b881fa1..13da3284a2d 100644 --- a/packages/opentelemetry-exporter-collector/test/node/CollectorExporter.test.ts +++ b/packages/opentelemetry-exporter-collector/test/node/CollectorExporter.test.ts @@ -31,6 +31,7 @@ import * as collectorTypes from '../../src/types'; import { ensureResourceIsCorrect, ensureExportedSpanIsCorrect, + ensureMetadataIsCorrect, mockedReadableSpan, } from '../helper'; @@ -41,18 +42,23 @@ const includeDirs = [path.resolve(__dirname, '../../src/platform/node/protos')]; const address = 'localhost:1501'; type TestParams = { - useTLS: boolean; + useTLS?: boolean; + metadata?: grpc.Metadata; }; +const metadata = new grpc.Metadata(); +metadata.set('k', 'v'); + const testCollectorExporter = (params: TestParams) => describe(`CollectorExporter - node ${ - params.useTLS ? 'with TLS' : '' - }`, () => { + params.useTLS ? 'with' : 'without' + } TLS, ${params.metadata ? 'with' : 'without'} metadata`, () => { let collectorExporter: CollectorExporter; let server: grpc.Server; let exportedData: | collectorTypes.opentelemetryProto.trace.v1.ResourceSpans | undefined; + let reqMetadata: grpc.Metadata | undefined; before(done => { server = new grpc.Server(); @@ -75,9 +81,11 @@ const testCollectorExporter = (params: TestParams) => { Export: (data: { request: collectorTypes.opentelemetryProto.collector.trace.v1.ExportTraceServiceRequest; + metadata: grpc.Metadata; }) => { try { exportedData = data.request.resourceSpans[0]; + reqMetadata = data.metadata; } catch (e) { exportedData = undefined; } @@ -117,6 +125,7 @@ const testCollectorExporter = (params: TestParams) => serviceName: 'basic-service', url: address, credentials, + metadata: params.metadata, }); const provider = new BasicTracerProvider(); @@ -126,6 +135,7 @@ const testCollectorExporter = (params: TestParams) => afterEach(() => { exportedData = undefined; + reqMetadata = undefined; }); describe('export', () => { @@ -153,6 +163,9 @@ const testCollectorExporter = (params: TestParams) => ensureResourceIsCorrect(resource); } } + if (params.metadata && reqMetadata) { + ensureMetadataIsCorrect(reqMetadata, params.metadata); + } done(); }, 200); }); @@ -179,3 +192,4 @@ describe('CollectorExporter - node (getDefaultUrl)', () => { testCollectorExporter({ useTLS: true }); testCollectorExporter({ useTLS: false }); +testCollectorExporter({ metadata });