Skip to content

Commit 9d447fd

Browse files
authored
Merge pull request #2640 from bugsnag/PLAT-15268/request-tracker-package
Create new request-tracker package
2 parents 448d11f + 973b210 commit 9d447fd

10 files changed

Lines changed: 803 additions & 2 deletions

File tree

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ module.exports = {
2929
project('web workers', ['web-worker'], {
3030
testEnvironment: '<rootDir>/jest/FixJSDOMEnvironment.js'
3131
}),
32-
project('shared plugins', ['plugin-app-duration', 'plugin-stackframe-path-normaliser']),
32+
project('shared plugins', ['plugin-app-duration', 'plugin-stackframe-path-normaliser', 'request-tracker']),
3333
project('browser', [
3434
'browser',
3535
'delivery-x-domain-request',

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/request-tracker/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const RequestTracker = require('./lib/request-tracker')
2+
const createFetchTracker = require('./lib/fetch-tracker')
3+
const createXhrTracker = require('./lib/xhr-tracker')
4+
const { createUrlFilter, getDuration } = require('./lib/url-helpers')
5+
6+
/**
7+
* Create an auto-loading request tracker plugin
8+
* @param {Array} ignoredUrls - Additional URLs to ignore
9+
* @param {Object} global - Global object (window or global)
10+
* @returns {Object} Bugsnag plugin
11+
*/
12+
function createRequestTrackerPlugin (ignoredUrls = [], global = window) {
13+
return {
14+
name: 'requestTracker',
15+
load: (client) => {
16+
try {
17+
const fetchTracker = createFetchTracker(global)
18+
const xhrTracker = createXhrTracker(global)
19+
const urlFilter = createUrlFilter(client, ignoredUrls)
20+
21+
return {
22+
fetchTracker,
23+
xhrTracker,
24+
urlFilter,
25+
getDuration
26+
}
27+
} catch (error) {
28+
client._logger.error('Failed to load request tracker:', error)
29+
throw new Error('Request tracking is not available: ' + error.message)
30+
}
31+
}
32+
}
33+
}
34+
35+
module.exports = {
36+
RequestTracker,
37+
createFetchTracker,
38+
createXhrTracker,
39+
createUrlFilter,
40+
getDuration,
41+
createRequestTrackerPlugin
42+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
const RequestTracker = require('./request-tracker')
2+
3+
/**
4+
* Create fetch request tracker with singleton pattern
5+
* @param {Object} global - Global object (window or global)
6+
* @param {Object} options - Configuration options
7+
* @returns {Object} Tracker instance
8+
*/
9+
function createFetchTracker (global, options = {}) {
10+
// only patch it if it exists and if it is not a polyfill (patching a polyfilled
11+
// fetch() results in duplicate breadcrumbs for the same request because the
12+
// implementation uses XMLHttpRequest which is also patched)
13+
if (!('fetch' in global) || global.fetch.polyfill) return
14+
15+
// Use singleton pattern - one tracker per global context
16+
if (!global.__bugsnag_fetch_tracker__) {
17+
const tracker = new RequestTracker()
18+
const originalFetch = global.fetch
19+
20+
global.fetch = function wrappedFetch (urlOrRequest, options = {}) {
21+
let url = null
22+
let method = 'GET'
23+
24+
if (urlOrRequest && typeof urlOrRequest === 'object') {
25+
url = urlOrRequest.url
26+
if (options && 'method' in options) {
27+
method = options.method
28+
} else if (urlOrRequest && 'method' in urlOrRequest) {
29+
method = urlOrRequest.method
30+
}
31+
} else {
32+
url = urlOrRequest
33+
if (options && 'method' in options) {
34+
method = options.method
35+
}
36+
}
37+
38+
if (method === undefined) {
39+
method = 'GET'
40+
}
41+
42+
const startTime = Date.now()
43+
const context = {
44+
url: String(url),
45+
method: String(method),
46+
startTime,
47+
type: 'fetch',
48+
input: urlOrRequest,
49+
init: options
50+
}
51+
52+
const { onRequestEnd } = tracker.start(context)
53+
54+
// Call original fetch
55+
return originalFetch.call(this, ...arguments).then(
56+
response => {
57+
onRequestEnd({
58+
endTime: Date.now(),
59+
status: response.status,
60+
state: 'success',
61+
response
62+
})
63+
return response
64+
},
65+
error => {
66+
onRequestEnd({
67+
endTime: Date.now(),
68+
state: 'error',
69+
error
70+
})
71+
throw error
72+
}
73+
)
74+
}
75+
76+
// Store tracker and mark as active
77+
global.__bugsnag_fetch_tracker__ = tracker
78+
79+
// Restore function for development
80+
if (process.env.NODE_ENV !== 'production') {
81+
tracker._restore = () => {
82+
global.fetch = originalFetch
83+
delete global.__bugsnag_fetch_tracker__
84+
}
85+
}
86+
}
87+
88+
return global.__bugsnag_fetch_tracker__
89+
}
90+
91+
module.exports = createFetchTracker
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Singleton RequestTracker class for managing HTTP request instrumentation
3+
* Allows multiple plugins to register callbacks for the same requests
4+
*/
5+
class RequestTracker {
6+
constructor () {
7+
this.callbacks = []
8+
}
9+
10+
/**
11+
* Register a callback to be called when a request starts
12+
* @param {Function} callback - Function to call with request context
13+
*/
14+
onStart (callback) {
15+
if (typeof callback !== 'function') {
16+
throw new Error('RequestTracker onStart callback must be a function')
17+
}
18+
this.callbacks.push(callback)
19+
}
20+
21+
/**
22+
* Notify all registered callbacks about a request start
23+
* @param {Object} context - Request start context
24+
* @returns {Object} Combined result with onRequestEnd callbacks
25+
*/
26+
start (context) {
27+
const results = this.callbacks
28+
.map(callback => {
29+
try {
30+
return callback(context)
31+
} catch (error) {
32+
// Isolate plugin errors - don't let one plugin break others
33+
console.error('RequestTracker callback error:', error)
34+
return null
35+
}
36+
})
37+
.filter(result => result && typeof result === 'object')
38+
39+
return {
40+
onRequestEnd: (endContext) => {
41+
results.forEach(result => {
42+
if (typeof result.onRequestEnd === 'function') {
43+
try {
44+
result.onRequestEnd(endContext)
45+
} catch (error) {
46+
console.error('RequestTracker onRequestEnd callback error:', error)
47+
}
48+
}
49+
})
50+
},
51+
extraRequestHeaders: results
52+
.map(result => result.extraRequestHeaders)
53+
.filter(headers => headers && typeof headers === 'object')
54+
.reduce((combined, headers) => Object.assign(combined, headers), {})
55+
}
56+
}
57+
58+
/**
59+
* Reset tracker (for testing)
60+
*/
61+
_reset () {
62+
this.callbacks = []
63+
}
64+
}
65+
66+
module.exports = RequestTracker
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const includes = require('@bugsnag/core/lib/es-utils/includes')
2+
3+
/**
4+
* Check if a URL should be ignored for tracking
5+
* @param {string} url - URL to check
6+
* @param {Array} ignoredUrls - Array of URLs to ignore
7+
* @returns {boolean} True if URL should be ignored
8+
*/
9+
function shouldIgnoreUrl (url, ignoredUrls = []) {
10+
if (!url || typeof url !== 'string') return true
11+
12+
// Remove query parameters for comparison
13+
const urlWithoutQuery = url.replace(/\?.*$/, '')
14+
15+
return includes(ignoredUrls, urlWithoutQuery)
16+
}
17+
18+
/**
19+
* Create URL filtering function for Bugsnag endpoints and custom ignored URLs
20+
* @param {Object} client - Bugsnag client instance
21+
* @param {Array} additionalIgnoredUrls - Additional URLs to ignore
22+
* @returns {Function} URL filtering function
23+
*/
24+
function createUrlFilter (client, additionalIgnoredUrls = []) {
25+
const ignoredUrls = [
26+
client._config.endpoints.notify,
27+
client._config.endpoints.sessions
28+
].concat(additionalIgnoredUrls).filter(Boolean)
29+
30+
return (url) => shouldIgnoreUrl(url, ignoredUrls)
31+
}
32+
33+
/**
34+
* Calculate duration from start time
35+
* @param {number} startTime - Start timestamp
36+
* @returns {number} Duration in milliseconds
37+
*/
38+
function getDuration (startTime) {
39+
return startTime && Date.now() - startTime
40+
}
41+
42+
module.exports = {
43+
shouldIgnoreUrl,
44+
createUrlFilter,
45+
getDuration
46+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
const RequestTracker = require('./request-tracker')
2+
3+
/**
4+
* Create XHR request tracker with singleton pattern
5+
* @param {Object} global - Global object (window or global)
6+
* @param {Object} options - Configuration options
7+
* @returns {Object} Tracker instance
8+
*/
9+
function createXhrTracker (global, options = {}) {
10+
if (!('addEventListener' in global.XMLHttpRequest.prototype) || !('WeakMap' in global)) return
11+
12+
// Use singleton pattern - one tracker per global context
13+
if (!global.__bugsnag_xhr_tracker__) {
14+
const tracker = new RequestTracker()
15+
16+
const trackedRequests = new WeakMap()
17+
const requestHandlers = new WeakMap()
18+
19+
const originalOpen = global.XMLHttpRequest.prototype.open
20+
const originalSend = global.XMLHttpRequest.prototype.send
21+
22+
global.XMLHttpRequest.prototype.open = function open (method, url) {
23+
// it's possible for `this` to be `undefined`, which is not a valid key for a WeakMap
24+
if (this) {
25+
trackedRequests.set(this, { method: String(method), url: String(url) })
26+
}
27+
originalOpen.apply(this, arguments)
28+
}
29+
30+
global.XMLHttpRequest.prototype.send = function send (body) {
31+
const requestData = trackedRequests.get(this)
32+
if (requestData) {
33+
// if we have already setup listeners then this request instance is being reused,
34+
// so we need to remove the listeners from the previous send
35+
const listeners = requestHandlers.get(this)
36+
if (listeners) {
37+
this.removeEventListener('load', listeners.load)
38+
this.removeEventListener('error', listeners.error)
39+
}
40+
41+
const startTime = Date.now()
42+
const context = {
43+
url: requestData.url,
44+
method: requestData.method,
45+
startTime,
46+
type: 'xmlhttprequest',
47+
body,
48+
xhr: this
49+
}
50+
51+
const { onRequestEnd } = tracker.start(context)
52+
53+
const handleLoad = () => {
54+
onRequestEnd({
55+
endTime: Date.now(),
56+
status: this.status,
57+
state: 'success',
58+
xhr: this
59+
})
60+
}
61+
62+
const handleError = () => {
63+
onRequestEnd({
64+
endTime: Date.now(),
65+
state: 'error',
66+
xhr: this
67+
})
68+
}
69+
70+
this.addEventListener('load', handleLoad)
71+
this.addEventListener('error', handleError)
72+
73+
// it's possible for `this` to be `undefined`, which is not a valid key for a WeakMap
74+
if (this) {
75+
requestHandlers.set(this, { load: handleLoad, error: handleError })
76+
}
77+
}
78+
79+
originalSend.apply(this, arguments)
80+
}
81+
82+
// Store tracker and mark as active
83+
global.__bugsnag_xhr_tracker__ = tracker
84+
85+
// Restore function for development
86+
if (process.env.NODE_ENV !== 'production') {
87+
tracker._restore = () => {
88+
global.XMLHttpRequest.prototype.open = originalOpen
89+
global.XMLHttpRequest.prototype.send = originalSend
90+
delete global.__bugsnag_xhr_tracker__
91+
}
92+
}
93+
}
94+
95+
return global.__bugsnag_xhr_tracker__
96+
}
97+
98+
module.exports = createXhrTracker

0 commit comments

Comments
 (0)