diff --git a/index.d.ts b/index.d.ts index 9779eacaf..480580bf7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -33,6 +33,7 @@ declare class Rollbar implements Rollbar.Components { ): Rollbar.LambdaHandler; public errorHandler(): Rollbar.ExpressErrorHandler; + public expressMiddleware(): Rollbar.ExpressMiddleware | undefined; // Components @@ -219,6 +220,11 @@ declare namespace Rollbar { response: any, next: ExpressNextFunction, ) => any; + export type ExpressMiddleware = ( + request: any, + response: any, + next: ExpressNextFunction, + ) => any; export type ExpressNextFunction = (err?: any) => void; class Locals {} export type LocalsType = typeof Locals; @@ -333,9 +339,18 @@ declare namespace Rollbar { export interface TransformSpanParams { span: any; } + export type TracingPropagationHeader = + | 'baggage' + | 'traceparent' + | 'tracestate'; + export interface TracingPropagationOptions { + enabledHeaders?: TracingPropagationHeader[]; + enabledCorsUrls?: (string | RegExp)[]; + } export interface TracingOptions { enabled?: boolean; endpoint?: string; + propagation?: TracingPropagationOptions; transformSpan?: (params: TransformSpanParams) => void; } diff --git a/package-lock.json b/package-lock.json index 3aa37f515..b2d4eb20b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "rollbar", - "version": "3.0.0-rc.1", + "version": "3.0.0", "license": "MIT", "dependencies": { "@rrweb/record": "^2.0.0-alpha.18", @@ -52,6 +52,7 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", + "undici": "^6.23.0", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", "webpack-node-externals": "^3.0.0" @@ -13010,6 +13011,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 1d52deee3..686bc7974 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", + "undici": "^6.23.0", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", "webpack-node-externals": "^3.0.0" diff --git a/src/browser/telemetry.js b/src/browser/telemetry.js index 1f0752bce..9fbcf6e7a 100644 --- a/src/browser/telemetry.js +++ b/src/browser/telemetry.js @@ -284,6 +284,23 @@ class Instrumenter { function (orig) { return function (data) { const xhr = this; + const tracing = self.rollbar?.tracing; + if ( + _.shouldAddBaggageHeader( + self.options, + tracing, + xhr.__rollbar_xhr?.url, + ) + ) { + try { + xhr.setRequestHeader( + 'baggage', + `rollbar.session.id=${tracing.sessionId}`, + ); + } catch (_e) { + /* ignore errors from adding baggage header */ + } + } function onreadystatechangeHandler() { if (xhr.__rollbar_xhr) { @@ -435,6 +452,14 @@ class Instrumenter { if (args[1] && args[1].method) { method = args[1].method; } + const tracing = self.rollbar?.tracing; + if (_.shouldAddBaggageHeader(self.options, tracing, url)) { + const headers = { + baggage: `rollbar.session.id=${tracing.sessionId}`, + }; + + _.addHeadersToFetch(args, headers); + } const metadata = { method: method, url: url, diff --git a/src/rollbar.js b/src/rollbar.js index 11af0caac..237b5027a 100644 --- a/src/rollbar.js +++ b/src/rollbar.js @@ -184,9 +184,11 @@ Rollbar.prototype._log = function (defaultLevel, item) { Rollbar.prototype._addItemAttributes = function (item) { const span = this.tracing?.getSpan(); + const asyncLocalSessionId = _.getSessionIdFromAsyncLocalStorage(this); + const sessionId = asyncLocalSessionId || this.tracing?.sessionId; const attributes = [ - { key: 'session_id', value: this.tracing?.sessionId }, + { key: 'session_id', value: sessionId }, { key: 'span_id', value: span?.spanId }, { key: 'trace_id', value: span?.traceId }, ]; diff --git a/src/server/middleware/rollbarExpressMiddleware.js b/src/server/middleware/rollbarExpressMiddleware.js new file mode 100644 index 000000000..39dd64121 --- /dev/null +++ b/src/server/middleware/rollbarExpressMiddleware.js @@ -0,0 +1,63 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +export function extractSessionId(headerValue) { + if (!headerValue) { + return null; + } + const rawValue = Array.isArray(headerValue) + ? headerValue.join(',') + : headerValue; + if (typeof rawValue !== 'string') { + return null; + } + const entries = rawValue.split(','); + for (const entry of entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const equalsIndex = trimmed.indexOf('='); + if (equalsIndex === -1) { + continue; + } + const key = trimmed.slice(0, equalsIndex).trim(); + if (key !== 'rollbar.session.id') { + continue; + } + const value = trimmed.slice(equalsIndex + 1).trim(); + if (!value) { + return null; + } + try { + return decodeURIComponent(value); + } catch (_e) { + return value; + } + } + return null; +} + +function getBaggageHeader(req) { + if (!req) { + return null; + } + if (typeof req.get === 'function') { + return req.get('baggage'); + } + return req.headers?.baggage || null; +} + +export default function rollbarExpressMiddleware(rollbar) { + const storage = rollbar?.client.asyncLocalStorage || new AsyncLocalStorage(); + if (rollbar) { + rollbar.client.asyncLocalStorage = storage; + } + + return function rollbarExpressMiddlewareHandler(req, _res, next) { + const sessionId = extractSessionId(getBaggageHeader(req)); + if (!sessionId) { + return next(); + } + return storage.run({ sessionId }, () => next()); + }; +} diff --git a/src/server/rollbar.js b/src/server/rollbar.js index 4208d0198..4ef8e851b 100644 --- a/src/server/rollbar.js +++ b/src/server/rollbar.js @@ -15,6 +15,7 @@ import truncation from '../truncation.js'; import * as _ from '../utility.js'; import * as serverDefaults from './defaults.js'; +import rollbarExpressMiddleware from './middleware/rollbarExpressMiddleware.js'; import Instrumenter from './telemetry.js'; import * as transforms from './transforms.js'; import Transport from './transport.js'; @@ -460,6 +461,18 @@ Rollbar.wrapCallback = function (f) { } }; +Rollbar.prototype.expressMiddleware = function () { + return rollbarExpressMiddleware(this); +}; + +Rollbar.expressMiddleware = function () { + if (_instance) { + return rollbarExpressMiddleware(_instance); + } + handleUninitialized(); + return undefined; +}; + Rollbar.prototype.captureEvent = function () { var event = _.createTelemetryEvent(arguments); return this.client.captureEvent(event.type, event.metadata, event.level); diff --git a/src/server/telemetry.js b/src/server/telemetry.js index abfac0cf5..6a132750f 100644 --- a/src/server/telemetry.js +++ b/src/server/telemetry.js @@ -85,6 +85,15 @@ Instrumenter.prototype.instrumentNetwork = function () { this.replacements, 'network', ); + if (typeof globalThis.fetch === 'function') { + replace( + globalThis, + 'fetch', + fetchRequestWrapper.bind(this), + this.replacements, + 'network', + ); + } }; function networkRequestWrapper(orig) { @@ -93,10 +102,22 @@ function networkRequestWrapper(orig) { return (...args) => { const [url, options, cb] = args; var mergedOptions = urlHelpers.mergeOptions(url, options, cb); + const requestUrl = urlHelpers.constructUrl(mergedOptions.options); + const sessionId = _.getSessionIdFromAsyncLocalStorage(this.rollbar.client); + + if ( + sessionId && + _.shouldAddBaggageHeader(this.options, { sessionId }, requestUrl) + ) { + if (!mergedOptions.options.headers) { + mergedOptions.options.headers = {}; + } + mergedOptions.options.headers.baggage = `rollbar.session.id=${sessionId}`; + } var metadata = { method: mergedOptions.options.method || 'GET', - url: urlHelpers.constructUrl(mergedOptions.options), + url: requestUrl, status_code: null, start_time_ms: _.now(), end_time_ms: null, @@ -142,6 +163,104 @@ function responseCallbackWrapper(options, metadata, callback) { }; } +function fetchRequestWrapper(orig) { + var telemeter = this.telemeter; + + return (...args) => { + const input = args[0]; + const init = args[1]; + let method = 'GET'; + let url; + const sessionId = _.getSessionIdFromAsyncLocalStorage(this.rollbar.client); + + if (_.isType(input, 'string') || input instanceof URL) { + url = input.toString(); + } else if (input) { + url = input.url; + if (input.method) { + method = input.method; + } + } + + if (init && init.method) { + method = init.method; + } + + if ( + sessionId && + _.shouldAddBaggageHeader(this.options, { sessionId }, url) + ) { + const headers = { baggage: `rollbar.session.id=${sessionId}` }; + + _.addHeadersToFetch(args, headers); + } + + const metadata = { + method: method, + url: url, + status_code: null, + start_time_ms: _.now(), + end_time_ms: null, + }; + + if (this.autoInstrument.networkRequestHeaders) { + const requestHeaders = normalizeFetchHeaders( + init && init.headers ? init.headers : input && input.headers, + ); + if (requestHeaders) { + metadata.request_headers = requestHeaders; + } + } + + telemeter.captureNetwork(metadata, 'fetch'); + + return orig.apply(globalThis, args).then( + (res) => { + metadata.end_time_ms = _.now(); + metadata.status_code = res.status; + if (this.autoInstrument.networkResponseHeaders) { + const responseHeaders = normalizeFetchHeaders(res.headers); + if (responseHeaders) { + metadata.response = metadata.response || {}; + metadata.response.headers = responseHeaders; + } + } + return res; + }, + (err) => { + metadata.end_time_ms = _.now(); + metadata.status_code = 0; + metadata.error = [err.name, err.message].join(': '); + throw err; + }, + ); + }; +} + +function normalizeFetchHeaders(headers) { + if (!headers) return null; + if (typeof headers.forEach === 'function') { + const normalized = {}; + headers.forEach((value, key) => { + normalized[key] = value; + }); + return normalized; + } + if (Array.isArray(headers)) { + const normalized = {}; + headers.forEach((pair) => { + if (pair && pair.length > 1) { + normalized[pair[0]] = pair[1]; + } + }); + return normalized; + } + if (_.isType(headers, 'object')) { + return headers; + } + return null; +} + Instrumenter.prototype.captureNetwork = function ( metadata, subtype, diff --git a/src/utility.js b/src/utility.js index d479dfa75..bc1a7719f 100644 --- a/src/utility.js +++ b/src/utility.js @@ -146,6 +146,10 @@ function isBrowser() { return typeof window !== 'undefined'; } +function isRequestObject(input) { + return typeof Request !== 'undefined' && input instanceof Request; +} + function redact() { return '********'; } @@ -867,6 +871,69 @@ function merge() { return result; } +function shouldAddBaggageHeader(options, tracing, url) { + if (!tracing?.sessionId || !url) { + return false; + } + const propagation = options?.tracing?.propagation; + const enabledHeaders = propagation?.enabledHeaders; + if (!Array.isArray(enabledHeaders) || !enabledHeaders.includes('baggage')) { + return false; + } + const enabledCorsUrls = propagation?.enabledCorsUrls; + if (!Array.isArray(enabledCorsUrls) || enabledCorsUrls.length === 0) { + return false; + } + return enabledCorsUrls.some((pattern) => { + if (isType(pattern, 'string')) { + return url === pattern; + } + if (isType(pattern, 'regexp')) { + return pattern.test(url); + } + return false; + }); +} + +function addHeadersToFetch(args, newHeaders) { + // Headers may be in the request object or the init object. + // If present in both places, the init object must be used. + // + let init = args[1]; + const initHeaders = init?.headers; + const reqHeaders = isRequestObject(args[0]) && args[0].headers; + let headers = initHeaders || reqHeaders; + + // If headers are not present in either place, they are added to the init object. + // If there is no init object, one must be created and added to args. + if (!headers) { + if (!init) { + args[1] = init = {}; + } + headers = init.headers = {}; + } + + // `headers` may be a Headers object or a plain object. + if (headers instanceof Headers) { + for (const key of Object.keys(newHeaders)) { + headers.append(key, newHeaders[key]); + } + } else if (isObject(headers)) { + for (const key of Object.keys(newHeaders)) { + headers[key] = newHeaders[key]; + } + } +} + +function getSessionIdFromAsyncLocalStorage(client) { + const storage = client.asyncLocalStorage; + if (!storage || typeof storage.getStore !== 'function') { + return null; + } + const store = storage.getStore(); + return store?.sessionId || null; +} + export { addParamsAndAccessTokenToPath, createItem, @@ -902,4 +969,7 @@ export { maxByteSize, typeName, uuid4, + shouldAddBaggageHeader, + addHeadersToFetch, + getSessionIdFromAsyncLocalStorage, }; diff --git a/test/browser.rollbar.autoInstrument.fetch.test.ts b/test/browser.rollbar.autoInstrument.fetch.test.ts index ca7a866c1..0df3cea96 100644 --- a/test/browser.rollbar.autoInstrument.fetch.test.ts +++ b/test/browser.rollbar.autoInstrument.fetch.test.ts @@ -115,6 +115,69 @@ describe('options.autoInstrument', function () { }); }); + it('should add baggage header when propagation enabled', async function () { + const server = window.server; + expect(server).to.exist; + + stubResponse(server); + server.requests.length = 0; + + window.fetchStub = sinon.stub(window, 'fetch'); + window.fetch.returns( + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }), + ), + ); + + const rollbar = (window.rollbar = new Rollbar({ + accessToken: 'POST_CLIENT_ITEM_TOKEN', + tracing: { + enabled: true, + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/fetch-test'], + }, + }, + autoInstrument: { + log: false, + network: true, + networkRequestHeaders: true, + }, + })); + + const fetchHeaders = new Headers(); + fetchHeaders.append('Content-Type', 'application/json'); + + const fetchRequest = new Request('https://example.com/fetch-test'); + const fetchInit = { + method: 'POST', + headers: fetchHeaders, + body: JSON.stringify({ name: 'bar' }), + }; + + await window.fetch(fetchRequest, fetchInit).then(async () => { + rollbar.log('test'); // generate a payload to inspect + + await setTimeoutAsync(1); + + server.respond(); + + expect(server.requests).to.have.lengthOf(1); + const body = JSON.parse(server.requests[0].requestBody); + + expect(body.data.body.telemetry[0].body.request_headers).to.include({ + baggage: `rollbar.session.id=${rollbar.tracing.sessionId}`, + }); + + rollbar.configure({ autoInstrument: false }); + window.fetch.restore(); + }); + }); + it('should report error for http 4xx when enabled', async function () { const server = window.server; expect(server).to.exist; diff --git a/test/browser.rollbar.autoInstrument.xhr.test.ts b/test/browser.rollbar.autoInstrument.xhr.test.ts index b2138932a..5c88e236d 100644 --- a/test/browser.rollbar.autoInstrument.xhr.test.ts +++ b/test/browser.rollbar.autoInstrument.xhr.test.ts @@ -91,6 +91,55 @@ describe('options.autoInstrument', function () { server.respond(); }); + it('should add baggage header when propagation enabled', async function () { + const server = window.server; + stubResponse(server); + server.requests.length = 0; + + server.respondWith('POST', 'https://example.com/xhr-test', [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ ok: true }), + ]); + + const rollbar = (window.rollbar = new Rollbar({ + accessToken: 'POST_CLIENT_ITEM_TOKEN', + tracing: { + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/xhr-test'], + }, + }, + autoInstrument: { + log: false, + network: true, + networkRequestHeaders: true, + }, + })); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://example.com/xhr-test', true); + xhr.setRequestHeader('Content-type', 'application/json'); + xhr.onreadystatechange = async function () { + if (xhr.readyState === 4) { + rollbar.log('test'); // generate a payload to inspect + + await setTimeoutAsync(1); + + server.respond(); + + expect(server.requests.length).to.eql(2); + const body = JSON.parse(server.requests[1].requestBody); + + expect(body.data.body.telemetry[0].body.request_headers).to.include({ + baggage: `rollbar.session.id=${rollbar.tracing.sessionId}`, + }); + } + }; + xhr.send(JSON.stringify({ name: 'bar' })); + server.respond(); + }); + it('should add telemetry events for GET xhr calls', async function () { const server = window.server; stubResponse(server); diff --git a/test/server.middleware.rollbarExpressMiddleware.test.js b/test/server.middleware.rollbarExpressMiddleware.test.js new file mode 100644 index 000000000..53d056d06 --- /dev/null +++ b/test/server.middleware.rollbarExpressMiddleware.test.js @@ -0,0 +1,105 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { expect } from 'chai'; + +import rollbarExpressMiddleware, { + extractSessionId, +} from '../src/server/middleware/rollbarExpressMiddleware.js'; + +function makeReq(headers) { + return { headers }; +} + +function makeNext(storeReader) { + const calls = []; + const next = () => { + if (storeReader) { + calls.push(storeReader()); + } else { + calls.push('called'); + } + }; + next.calls = calls; + return next; +} + +describe('rollbarExpressMiddleware', function () { + let rollbar; + + beforeEach(function () { + rollbar = { + client: { + asyncLocalStorage: new AsyncLocalStorage(), + }, + }; + }); + + afterEach(function () { + rollbar.client.asyncLocalStorage = undefined; + }); + + describe('extractSessionId', function () { + it('parses a single rollbar session id', function () { + expect(extractSessionId('rollbar.session.id=abc123')).to.equal('abc123'); + }); + + it('parses a rollbar session id among other baggage values', function () { + const header = 'foo=bar, rollbar.session.id=abc123, baz=qux'; + expect(extractSessionId(header)).to.equal('abc123'); + }); + + it('parses and decodes a URL-encoded rollbar session id', function () { + expect(extractSessionId('rollbar.session.id=abc%20123')).to.equal( + 'abc 123', + ); + }); + + it('parses when header is an array', function () { + const header = ['foo=bar', 'rollbar.session.id=xyz']; + expect(extractSessionId(header)).to.equal('xyz'); + }); + }); + + it('stores session id from baggage header in async local storage', function () { + const middleware = rollbarExpressMiddleware(rollbar); + const req = makeReq({ + baggage: 'foo=bar, rollbar.session.id=abc123, baz=qux', + }); + const next = makeNext(() => rollbar.client.asyncLocalStorage.getStore()); + + middleware(req, {}, next); + + expect(next.calls).to.have.lengthOf(1); + expect(next.calls[0]).to.deep.equal({ + sessionId: 'abc123', + }); + }); + + it('does not set async local storage when header is missing', function () { + const middleware = rollbarExpressMiddleware(rollbar); + const req = makeReq(); + const next = makeNext(() => rollbar.client.asyncLocalStorage.getStore()); + + middleware(req, {}, next); + + expect(next.calls).to.have.lengthOf(1); + expect(next.calls[0]).to.equal(undefined); + }); + + it('creates async local storage when missing on rollbar', function () { + rollbar = { client: {} }; + const middleware = rollbarExpressMiddleware(rollbar); + const req = makeReq({ baggage: 'rollbar.session.id=xyz' }); + const next = makeNext(() => rollbar.client.asyncLocalStorage.getStore()); + + middleware(req, {}, next); + + expect(next.calls).to.have.lengthOf(1); + expect(rollbar.client.asyncLocalStorage).to.be.instanceOf( + AsyncLocalStorage, + ); + expect(next.calls[0]).to.deep.equal({ + sessionId: 'xyz', + }); + }); +}); diff --git a/test/server.rollbar.logging.test.js b/test/server.rollbar.logging.test.js index e3af3d489..17f072b07 100644 --- a/test/server.rollbar.logging.test.js +++ b/test/server.rollbar.logging.test.js @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + import { expect } from 'chai'; import sinon from 'sinon'; @@ -321,4 +323,25 @@ describe('rollbar logging and tracing', function () { }); }); }); + + describe('_addItemAttributes', function () { + it('should use async local session id', function () { + const rollbar = new Rollbar({ + captureUncaught: true, + environment: 'fake-env', + }); + rollbar.client.asyncLocalStorage = new AsyncLocalStorage(); + const sessionId = 'als-session'; + + rollbar.client.asyncLocalStorage.run({ sessionId }, () => { + const item = { uuid: 'item-1', data: {} }; + rollbar.client._addItemAttributes(item); + + const sessionAttr = item.data.attributes.find( + (attr) => attr.key === 'session_id', + ); + expect(sessionAttr.value).to.equal(sessionId); + }); + }); + }); }); diff --git a/test/server.telemetry.test.js b/test/server.telemetry.test.js index 831aa1494..46e953d4f 100644 --- a/test/server.telemetry.test.js +++ b/test/server.telemetry.test.js @@ -1,10 +1,12 @@ import http from 'http'; import https from 'https'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { URL } from 'url'; import { expect } from 'chai'; import nock from 'nock'; import sinon from 'sinon'; +import { MockAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'; import Rollbar from '../src/server/rollbar.js'; import { mergeOptions } from '../src/server/telemetry/urlHelpers.js'; @@ -70,11 +72,15 @@ function stubGetWithError(url) { return nock(url).get('/api/users').replyWithError('dns error'); } -const testHeaders1 = { - 'Content-Type': 'application/json', - 'X-access-token': '123', +const testHeaders1 = () => { + return { + 'Content-Type': 'application/json', + 'X-access-token': '123', + }; +}; +const testHeaders2 = () => { + return { authorization: 'abc', foo: '456' }; }; -const testHeaders2 = { authorization: 'abc', foo: '456' }; const testHeaders3 = { 'content-type': 'application/json', foo: '123' }; const testHeaders4 = { authorization: 'abc', bar: '456' }; const testBody1 = 'test body 1'; @@ -83,6 +89,13 @@ const testMessage1 = 'test console message'; const testMessage2 = 'test console error message'; const testMessagePart = ', extra part'; +function lowerCaseKeys(headers) { + return Object.keys(headers || {}).reduce((acc, key) => { + acc[key.toLowerCase()] = headers[key]; + return acc; + }, {}); +} + describe('telemetry', function () { describe('with log and network capture enabled', function () { let rollbar; @@ -114,12 +127,12 @@ describe('telemetry', function () { console.info(testMessage1, testMessagePart); // eslint-disable-line no-console response1 = await request(http, 'http://example.com/api/users', { method: 'GET', - headers: testHeaders1, + headers: testHeaders1(), }); console.error(testMessage2); // eslint-disable-line no-console response2 = await request(https, 'https://example.com/api/users', { method: 'POST', - headers: testHeaders2, + headers: testHeaders2(), }); await uncaught(rollbar); @@ -128,6 +141,8 @@ describe('telemetry', function () { afterEach(function () { addItemStub.restore(); nock.cleanAll(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; // Restore Mocha's uncaught exception handlers mochaHandlers.forEach((handler) => { @@ -204,12 +219,12 @@ describe('telemetry', function () { console.info(testMessage1, testMessagePart); // eslint-disable-line no-console await request(https, 'https://example.com/api/users', { method: 'GET', - headers: testHeaders1, + headers: testHeaders1(), }); console.error(testMessage2); // eslint-disable-line no-console await request(http, 'http://example.com/api/users', { method: 'POST', - headers: testHeaders2, + headers: testHeaders2(), }); await message(rollbar); @@ -218,6 +233,8 @@ describe('telemetry', function () { afterEach(function () { addItemStub.restore(); nock.cleanAll(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; }); it('message payload should have telemetry', function () { @@ -244,6 +261,170 @@ describe('telemetry', function () { }); }); + describe('with fetch network capture enabled', function () { + let rollbar; + let addItemStub; + let response; + let mockAgent; + let originalDispatcher; + let sessionId; + let asyncLocalStorage; + + beforeEach(async function () { + if (typeof fetch !== 'function') { + this.skip(); + } + + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + mockAgent + .get('https://example.com') + .intercept({ path: '/api/users', method: 'GET' }) + .reply(200, testBody1, { headers: testHeaders3 }); + + rollbar = new Rollbar({ + accessToken: 'abc123', + captureUncaught: true, + tracing: { + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/api/users'], + }, + }, + autoInstrument: { + network: true, + networkResponseHeaders: true, + networkRequestHeaders: true, + }, + }); + sessionId = 'test-session-id'; + addItemStub = sinon.stub(rollbar.client.notifier.queue, 'addItem'); + asyncLocalStorage = new AsyncLocalStorage(); + rollbar.client.asyncLocalStorage = asyncLocalStorage; + + await asyncLocalStorage.run({ sessionId }, async () => { + response = await fetch('https://example.com/api/users', { + method: 'GET', + headers: testHeaders1(), + }); + + await message(rollbar); + }); + }); + + afterEach(function () { + addItemStub.restore(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; + if (mockAgent) { + mockAgent.close(); + } + if (originalDispatcher) { + setGlobalDispatcher(originalDispatcher); + } + }); + + it('message payload should have fetch telemetry', function () { + expect(addItemStub.called).to.be.true; + const telemetry = addItemStub.getCall(0).args[3].data.body.telemetry; + const fetchEvent = telemetry.find( + (event) => event.type === 'network' && event.body.subtype === 'fetch', + ); + + expect(response.status).to.equal(200); + expect(fetchEvent).to.exist; + expect(fetchEvent.body.method).to.equal('GET'); + expect(fetchEvent.body.url).to.equal('https://example.com/api/users'); + expect(fetchEvent.body.status_code).to.equal(200); + expect(lowerCaseKeys(fetchEvent.body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', + baggage: `rollbar.session.id=${sessionId}`, + }); + expect(lowerCaseKeys(fetchEvent.body.response.headers)).to.deep.equal({ + 'content-type': 'application/json', + foo: '123', + }); + }); + }); + + describe('with fetch network capture enabled and without async local storage', function () { + let rollbar; + let addItemStub; + let response; + let mockAgent; + let originalDispatcher; + + beforeEach(async function () { + if (typeof fetch !== 'function') { + this.skip(); + } + + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + mockAgent + .get('https://example.com') + .intercept({ path: '/api/users', method: 'GET' }) + .reply(200, testBody1, { headers: testHeaders3 }); + + rollbar = new Rollbar({ + accessToken: 'abc123', + captureUncaught: true, + tracing: { + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/api/users'], + }, + }, + autoInstrument: { + network: true, + networkResponseHeaders: true, + networkRequestHeaders: true, + }, + }); + rollbar.client.asyncLocalStorage = undefined; + addItemStub = sinon.stub(rollbar.client.notifier.queue, 'addItem'); + + response = await fetch('https://example.com/api/users', { + method: 'GET', + headers: testHeaders1(), + }); + + await message(rollbar); + }); + + afterEach(function () { + addItemStub.restore(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; + if (mockAgent) { + mockAgent.close(); + } + if (originalDispatcher) { + setGlobalDispatcher(originalDispatcher); + } + }); + + it('message payload should omit baggage header', function () { + expect(addItemStub.called).to.be.true; + const telemetry = addItemStub.getCall(0).args[3].data.body.telemetry; + const fetchEvent = telemetry.find( + (event) => event.type === 'network' && event.body.subtype === 'fetch', + ); + + expect(response.status).to.equal(200); + expect(fetchEvent).to.exist; + expect(lowerCaseKeys(fetchEvent.body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', + }); + }); + }); + describe('with telemetry disabled', function () { let rollbar; let addItemStub; @@ -263,12 +444,12 @@ describe('telemetry', function () { console.info(testMessage1, testMessagePart); // eslint-disable-line no-console await request(https, 'https://example.com/api/users', { method: 'GET', - headers: testHeaders1, + headers: testHeaders1(), }); console.error(testMessage2); // eslint-disable-line no-console await request(https, 'https://example.com/api/users', { method: 'POST', - headers: testHeaders2, + headers: testHeaders2(), }); await message(rollbar); @@ -291,28 +472,126 @@ describe('telemetry', function () { let addItemStub; let response1; let response2; + let sessionId; + let asyncLocalStorage; beforeEach(async function () { rollbar = new Rollbar({ accessToken: 'abc123', captureUncaught: true, + tracing: { + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/api/users'], + }, + }, autoInstrument: { network: true, networkResponseHeaders: true, networkRequestHeaders: true, }, }); + sessionId = 'test-session-id'; addItemStub = sinon.stub(rollbar.client.notifier.queue, 'addItem'); + asyncLocalStorage = new AsyncLocalStorage(); + rollbar.client.asyncLocalStorage = asyncLocalStorage; const options = { method: 'GET', protocol: 'https:', host: 'example.com', path: '/api/users', - headers: testHeaders1, + headers: testHeaders1(), + }; + + await asyncLocalStorage.run({ sessionId }, async () => { + // Invoke telemetry events + console.info(testMessage1, testMessagePart); // eslint-disable-line no-console + stubGetWithResponse( + 'https://example.com', + 200, + testHeaders3, + testBody1, + ); + response1 = await requestWithCallback(https, options).catch((e) => e); + console.error(testMessage2); // eslint-disable-line no-console + stubGetWithError('https://example.com'); + response2 = await requestWithCallback(https, options).catch((e) => e); + + await message(rollbar); + }); + }); + + afterEach(function () { + addItemStub.restore(); + nock.cleanAll(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; + }); + + it('message payload should have telemetry or error info', function () { + expect(addItemStub.called).to.be.true; + const telemetry = addItemStub.getCall(0).args[3].data.body.telemetry; + + // Verify that the responses were received intact. + expect(response1.headers).to.deep.equal(testHeaders3); + expect(response1.statusCode).to.equal(200); + expect(response2).to.be.instanceof(Error); + + // Verify telemetry + expect(lowerCaseKeys(telemetry[1].body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', + baggage: `rollbar.session.id=${sessionId}`, + }); + expect(lowerCaseKeys(telemetry[1].body.response.headers)).to.deep.equal({ + 'content-type': 'application/json', + foo: '123', + }); + expect(lowerCaseKeys(telemetry[3].body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', + baggage: `rollbar.session.id=${sessionId}`, + }); + expect(telemetry[3].body.response).to.be.undefined; + expect(telemetry[3].body.status_code).to.equal(0); + expect(telemetry[3].body.error).to.equal('Error: dns error'); + }); + }); + + describe('with callback request and error response without async local storage', function () { + let rollbar; + let addItemStub; + let response1; + let response2; + + beforeEach(async function () { + rollbar = new Rollbar({ + accessToken: 'abc123', + captureUncaught: true, + tracing: { + propagation: { + enabledHeaders: ['baggage'], + enabledCorsUrls: ['https://example.com/api/users'], + }, + }, + autoInstrument: { + network: true, + networkResponseHeaders: true, + networkRequestHeaders: true, + }, + }); + rollbar.client.asyncLocalStorage = undefined; + addItemStub = sinon.stub(rollbar.client.notifier.queue, 'addItem'); + + const options = { + method: 'GET', + protocol: 'https:', + host: 'example.com', + path: '/api/users', + headers: testHeaders1(), }; - // Invoke telemetry events console.info(testMessage1, testMessagePart); // eslint-disable-line no-console stubGetWithResponse('https://example.com', 200, testHeaders3, testBody1); response1 = await requestWithCallback(https, options).catch((e) => e); @@ -326,29 +605,29 @@ describe('telemetry', function () { afterEach(function () { addItemStub.restore(); nock.cleanAll(); + rollbar.instrumenter.deinstrumentNetwork(); + rollbar.client.asyncLocalStorage = undefined; }); - it('message payload should have telemetry or error info', function () { + it('message payload should omit baggage header', function () { expect(addItemStub.called).to.be.true; const telemetry = addItemStub.getCall(0).args[3].data.body.telemetry; - // Verify that the responses were received intact. expect(response1.headers).to.deep.equal(testHeaders3); expect(response1.statusCode).to.equal(200); expect(response2).to.be.instanceof(Error); - // Verify telemetry - expect(telemetry[1].body.request_headers).to.deep.equal({ - 'Content-Type': 'application/json', - 'X-access-token': '********', + expect(lowerCaseKeys(telemetry[1].body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', }); - expect(telemetry[1].body.response.headers).to.deep.equal({ + expect(lowerCaseKeys(telemetry[1].body.response.headers)).to.deep.equal({ 'content-type': 'application/json', foo: '123', }); - expect(telemetry[3].body.request_headers).to.deep.equal({ - 'Content-Type': 'application/json', - 'X-access-token': '********', + expect(lowerCaseKeys(telemetry[3].body.request_headers)).to.deep.equal({ + 'content-type': 'application/json', + 'x-access-token': '********', }); expect(telemetry[3].body.response).to.be.undefined; expect(telemetry[3].body.status_code).to.equal(0); @@ -360,11 +639,11 @@ describe('telemetry', function () { it('mergeOptions should correctly handle URL and options', function () { const optionsUsingStringUrl = mergeOptions( 'http://example.com/api/users', - { method: 'GET', headers: testHeaders1 }, + { method: 'GET', headers: testHeaders1() }, ); const optionsUsingClassUrl = mergeOptions( new URL('http://example.com/api/users'), - { method: 'GET', headers: testHeaders1 }, + { method: 'GET', headers: testHeaders1() }, ); expect(optionsUsingStringUrl).to.deep.equal(optionsUsingClassUrl); diff --git a/test/server.utility.test.js b/test/server.utility.test.js new file mode 100644 index 000000000..aececec96 --- /dev/null +++ b/test/server.utility.test.js @@ -0,0 +1,29 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { expect } from 'chai'; + +import * as _ from '../src/utility.js'; + +describe('getSessionIdFromAsyncLocalStorage', function () { + it('should return session id from async local storage', function () { + const storage = new AsyncLocalStorage(); + const rollbar = { asyncLocalStorage: storage }; + + storage.run({ sessionId: 'abc123' }, () => { + expect(_.getSessionIdFromAsyncLocalStorage(rollbar)).to.equal('abc123'); + }); + }); + + it('should return null when async local storage is missing', function () { + expect(_.getSessionIdFromAsyncLocalStorage({})).to.equal(null); + }); + + it('should return null when session id is missing', function () { + const storage = new AsyncLocalStorage(); + const rollbar = { asyncLocalStorage: storage }; + + storage.run({}, () => { + expect(_.getSessionIdFromAsyncLocalStorage(rollbar)).to.equal(null); + }); + }); +});