diff --git a/.buildkite/package_manifest.json b/.buildkite/package_manifest.json index 63344e0083..cbd5decc6f 100644 --- a/.buildkite/package_manifest.json +++ b/.buildkite/package_manifest.json @@ -20,6 +20,7 @@ "packages/plugin-interaction-breadcrumbs", "packages/plugin-navigation-breadcrumbs", "packages/plugin-network-breadcrumbs", + "packages/plugin-network-instrumentation", "packages/plugin-simple-throttle", "packages/plugin-strip-query-string", "packages/plugin-window-onerror", @@ -97,6 +98,7 @@ "packages/delivery-react-native", "packages/plugin-console-breadcrumbs", "packages/plugin-network-breadcrumbs", + "packages/plugin-network-instrumentation", "packages/plugin-react", "packages/plugin-react-native-client-sync", "packages/plugin-react-native-event-sync", @@ -121,6 +123,7 @@ "packages/delivery-react-native", "packages/plugin-console-breadcrumbs", "packages/plugin-network-breadcrumbs", + "packages/plugin-network-instrumentation", "packages/plugin-react", "packages/plugin-react-native-client-sync", "packages/plugin-react-native-event-sync", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f90a74287..ba1619b009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Added + +- (delivery-react-native) Handle request and response parameters [#2667](https://github.com/bugsnag/bugsnag-js/pull/2667) + ### Changed - (plugin-network-instrumentation) Manually parse URLs to improve React Native compatibility [#2674](https://github.com/bugsnag/bugsnag-js/pull/2674) +- (plugin-network-instrumentation) Refactor URL parsing to improve performance [#2667](https://github.com/bugsnag/bugsnag-js/pull/2667) ### Fixed diff --git a/packages/delivery-react-native/delivery.js b/packages/delivery-react-native/delivery.js index 97c3e86d2e..9f4f4a0215 100644 --- a/packages/delivery-react-native/delivery.js +++ b/packages/delivery-react-native/delivery.js @@ -48,6 +48,8 @@ module.exports = (client, NativeClient) => ({ breadcrumbs: derecursify(event.breadcrumbs), context: event.context, user: event._user, + request: event.request, + response: event.response, metadata: derecursify(event._metadata), groupingHash: event.groupingHash, groupingDiscriminator: event._groupingDiscriminator, diff --git a/packages/delivery-react-native/test/delivery.test.ts b/packages/delivery-react-native/test/delivery.test.ts index 109badb23f..e7b8c607d7 100644 --- a/packages/delivery-react-native/test/delivery.test.ts +++ b/packages/delivery-react-native/test/delivery.test.ts @@ -17,6 +17,8 @@ type NativeClientEvent = Pick { expect(sent[0].context).toBe('test screen') expect(sent[0].user).toEqual({ id: '123', email: undefined, name: undefined }) expect(sent[0].metadata).toEqual({}) + expect(sent[0].request).toEqual({}) + expect(sent[0].response).toEqual({}) expect(sent[0].groupingHash).toEqual('ER_GRP_098') expect(sent[0].apiKey).toBe('abcdef123456abcdef123456abcdef123456') expect(sent[0].correlation).toEqual({ traceId: 'trace-id', spanId: 'span-id' }) @@ -286,6 +290,8 @@ describe('delivery: react native', () => { expect(sent[0].context).toBe('test screen') expect(sent[0].user).toEqual({ id: '123', email: undefined, name: undefined }) expect(sent[0].metadata).toEqual({}) + expect(sent[0].request).toEqual({}) + expect(sent[0].response).toEqual({}) expect(sent[0].groupingHash).toEqual('ER_GRP_098') expect(sent[0].apiKey).toBe('abcdef123456abcdef123456abcdef123456') done() diff --git a/packages/plugin-network-instrumentation/lib/extract-domain.js b/packages/plugin-network-instrumentation/lib/extract-domain.js deleted file mode 100644 index 010febc501..0000000000 --- a/packages/plugin-network-instrumentation/lib/extract-domain.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Extract domain from URL - * @param {string} url - URL string - * @returns {string} Domain - */ -module.exports = function (url) { - try { - const isAbsolute = /^https?:\/\//i.test(url) - if (!isAbsolute) { - return 'unknown' - } - - const urlWithoutProtocol = url.replace(/^https?:\/\//i, '') - - // Find the earliest occurrence of '/', '?', or '#' to determine the domain boundary - const slashIndex = urlWithoutProtocol.indexOf('/') - const queryIndex = urlWithoutProtocol.indexOf('?') - const hashIndex = urlWithoutProtocol.indexOf('#') - let endIndex = urlWithoutProtocol.length - if (slashIndex !== -1 && slashIndex < endIndex) { - endIndex = slashIndex - } - if (queryIndex !== -1 && queryIndex < endIndex) { - endIndex = queryIndex - } - if (hashIndex !== -1 && hashIndex < endIndex) { - endIndex = hashIndex - } - - return urlWithoutProtocol.substring(0, endIndex) - } catch (e) { - return 'unknown' - } -} diff --git a/packages/plugin-network-instrumentation/lib/parse-query-params.js b/packages/plugin-network-instrumentation/lib/parse-query-params.js deleted file mode 100644 index fabb4e5222..0000000000 --- a/packages/plugin-network-instrumentation/lib/parse-query-params.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Parse query parameters from URL - * @param {string} url - URL string - * @returns {Object} Parsed query parameters - */ -module.exports = function (url) { - try { - const queryStart = url.indexOf('?') - const hashStart = url.indexOf('#') - - let queryString = '' - if (queryStart !== -1) { - const queryEnd = hashStart !== -1 && hashStart > queryStart ? hashStart : url.length - queryString = url.substring(queryStart + 1, queryEnd) - } - - // convert query string to object without using UrlSearchParams - const queryStringObject = {} - const pairs = queryString.split('&').filter(pair => pair.length > 0) - pairs.forEach(pair => { - const [key, value] = pair.split('=') - queryStringObject[decodeURIComponent(key)] = decodeURIComponent(value || '') - }) - - return queryStringObject - } catch (e) { - return {} - } -} diff --git a/packages/plugin-network-instrumentation/lib/parse-query-string.js b/packages/plugin-network-instrumentation/lib/parse-query-string.js new file mode 100644 index 0000000000..165c030db0 --- /dev/null +++ b/packages/plugin-network-instrumentation/lib/parse-query-string.js @@ -0,0 +1,19 @@ +/** + * Parse a query string into an object + * @param {string} queryString - Query string (e.g., "key=value&foo=bar") + * @returns {Object} Parsed query parameters as key-value pairs + */ +module.exports = function (queryString) { + const params = {} + if (!queryString) { + return params + } + + const pairs = queryString.split('&').filter(pair => pair.length > 0) + pairs.forEach(pair => { + const [key, value] = pair.split('=') + params[decodeURIComponent(key)] = decodeURIComponent(value || '') + }) + + return params +} diff --git a/packages/plugin-network-instrumentation/lib/parse-url.js b/packages/plugin-network-instrumentation/lib/parse-url.js new file mode 100644 index 0000000000..e3b453929f --- /dev/null +++ b/packages/plugin-network-instrumentation/lib/parse-url.js @@ -0,0 +1,64 @@ +/** + * Parse a URL in a single pass to extract domain, clean URL, and query string + * @param {string} url - URL string + * @returns {{ domain: string, cleanUrl: string, queryString: string }} Object with domain, cleanUrl, and queryString + */ +module.exports = function (url) { + try { + const isAbsolute = /^https?:\/\//i.test(url) + + // Extract query string from the full URL + const queryStart = url.indexOf('?') + const hashStart = url.indexOf('#') + + let queryString = '' + if (queryStart !== -1) { + const queryEnd = hashStart !== -1 && hashStart > queryStart ? hashStart : url.length + queryString = url.substring(queryStart + 1, queryEnd) + } + + // Extract domain + let domain = 'unknown' + if (isAbsolute) { + const urlWithoutProtocol = url.replace(/^https?:\/\//i, '') + + // Find the earliest occurrence of '/', '?', or '#' to determine the domain boundary + const slashIndex = urlWithoutProtocol.indexOf('/') + const domainQueryIndex = urlWithoutProtocol.indexOf('?') + const domainHashIndex = urlWithoutProtocol.indexOf('#') + let endIndex = urlWithoutProtocol.length + if (slashIndex !== -1 && slashIndex < endIndex) { + endIndex = slashIndex + } + if (domainQueryIndex !== -1 && domainQueryIndex < endIndex) { + endIndex = domainQueryIndex + } + if (domainHashIndex !== -1 && domainHashIndex < endIndex) { + endIndex = domainHashIndex + } + + domain = urlWithoutProtocol.substring(0, endIndex) + } + + // Strip query string while preserving hash + const hash = hashStart !== -1 ? url.substring(hashStart) : '' + let urlWithoutHash = queryStart !== -1 ? url.substring(0, queryStart) : url + if (hashStart !== -1 && queryStart === -1) { + // If there's a hash but no query string, remove the hash first + urlWithoutHash = url.substring(0, hashStart) + } + const cleanUrl = urlWithoutHash + hash + + return { + domain, + cleanUrl, + queryString + } + } catch (e) { + return { + domain: 'unknown', + cleanUrl: url, + queryString: '' + } + } +} diff --git a/packages/plugin-network-instrumentation/lib/redact-query-parameters.js b/packages/plugin-network-instrumentation/lib/redact-query-parameters.js deleted file mode 100644 index 2c36acde1f..0000000000 --- a/packages/plugin-network-instrumentation/lib/redact-query-parameters.js +++ /dev/null @@ -1,23 +0,0 @@ -const parseQueryParams = require('./parse-query-params') -const redactValues = require('./redact-values') - -module.exports = function (url, redactedKeys) { - const paramsObject = parseQueryParams(url) - const redactedParams = redactValues(paramsObject, redactedKeys) - const redactedQueryString = Object.entries(redactedParams).map(([key, value]) => `${key}=${value}`).join('&') - - const queryStart = url.indexOf('?') - const hashStart = url.indexOf('#') - const hash = hashStart !== -1 ? url.substring(hashStart) : '' - let result = queryStart !== -1 ? url.substring(0, queryStart) : url - - // Build the result URL manually - if (redactedQueryString && redactedQueryString.length > 0) { - result += '?' + redactedQueryString - } - if (hash) { - result += hash - } - - return result -} diff --git a/packages/plugin-network-instrumentation/network-instrumentation.js b/packages/plugin-network-instrumentation/network-instrumentation.js index 009b8651dd..b28d777515 100644 --- a/packages/plugin-network-instrumentation/network-instrumentation.js +++ b/packages/plugin-network-instrumentation/network-instrumentation.js @@ -3,9 +3,9 @@ * A plugin to automatically capture and report HTTP errors */ -const extractDomain = require('./lib/extract-domain') -const parseQueryParams = require('./lib/parse-query-params') -const redactQueryParameters = require('./lib/redact-query-parameters') +const parseUrl = require('./lib/parse-url') +const parseQueryString = require('./lib/parse-query-string') +const redactValues = require('./lib/redact-values') const shouldCaptureStatusCode = require('./lib/should-capture-status-code') const truncate = require('./lib/truncate') @@ -84,22 +84,25 @@ module.exports = (config = {}, global = window) => { try { // Extract request information - const url = startContext.url - const requestParams = parseQueryParams(url) + const originalUrl = startContext.url + const { domain, cleanUrl, queryString } = parseUrl(originalUrl) + const url = cleanUrl const method = startContext.method - const domain = extractDomain(url) + + // Parse query string into object + const requestParams = parseQueryString(queryString) // Create request and response objects for callback const requestObj = { - url: startContext.url, - httpMethod: startContext.method, + url, + httpMethod: method, headers: startContext.headers, - params: requestParams + params: redactValues(requestParams, client._config.redactedKeys), + bodyLength: startContext.body ? startContext.body.length : undefined } const responseObj = { statusCode: endContext.status, headers: endContext.headers, - body: endContext.body, bodyLength: endContext.body ? endContext.body.length : undefined } @@ -116,22 +119,15 @@ module.exports = (config = {}, global = window) => { // Truncate request body if (maxRequestSize > 0 && startContext.body) { requestObj.body = truncate(startContext.body, maxRequestSize) - requestObj.bodyLength = startContext.body.length } // Truncate response body - XHR only if (maxResponseSize > 0 && endContext.body) { responseObj.body = truncate(endContext.body, maxResponseSize) - responseObj.bodyLength = endContext.body.length - } - - // Strip query parameters from URL - if (requestObj.url !== '[REDACTED]') { - requestObj.url = redactQueryParameters(requestObj.url, client._config.redactedKeys) } // Create error and notify - const error = new Error(`${responseObj.statusCode}: ${requestObj.url}`) + const error = new Error(`${responseObj.statusCode}: ${url}`) error.name = 'HTTPError' const handledState = { diff --git a/packages/plugin-network-instrumentation/test/network-instrumentation-xhr.test.ts b/packages/plugin-network-instrumentation/test/network-instrumentation-xhr.test.ts index f9c64a2825..f65931ff9c 100644 --- a/packages/plugin-network-instrumentation/test/network-instrumentation-xhr.test.ts +++ b/packages/plugin-network-instrumentation/test/network-instrumentation-xhr.test.ts @@ -210,32 +210,5 @@ describe('plugin-network-instrumentation', () => { // since they don't have status codes, but let's verify the behavior expect(notifyCallbacks.length).toBe(0) }) - - it('should redact specified query parameters in XHR URLs', async () => { - const notifyCallbacks: Event[] = [] - - plugin = createPlugin({ - httpErrorCodes: { min: 400, max: 499 } - }) - - const client = new Client({ apiKey: 'api_key', plugins: [plugin], redactedKeys: ['token', 'userId'] }) - client._setDelivery(createMockDelivery(notifyCallbacks)) - - const xhr = new XMLHttpRequest() as any - xhr.status = 403 - xhr.statusText = 'Forbidden' - xhr.response = 'Forbidden' - - xhr.open('GET', 'https://api.example.com/data?userId=42') - xhr.send() - - await new Promise(resolve => setTimeout(resolve, 20)) - - expect(notifyCallbacks.length).toBe(1) - const event = notifyCallbacks[0].toJSON() - - // Verify that sensitive query parameters are redacted - expect(event.request.url).toBe('https://api.example.com/data?userId=[REDACTED]') - }) }) }) diff --git a/packages/plugin-network-instrumentation/test/network-instrumentation.test.ts b/packages/plugin-network-instrumentation/test/network-instrumentation.test.ts index e8aba988d0..33cb2fa696 100644 --- a/packages/plugin-network-instrumentation/test/network-instrumentation.test.ts +++ b/packages/plugin-network-instrumentation/test/network-instrumentation.test.ts @@ -55,6 +55,45 @@ describe('plugin-network-instrumentation', () => { }) }) + describe('config.redactedKeys', () => { + it('should redact specified query parameters from request.params', async () => { + const notifyCallbacks: Event[] = [] + + plugin = createPlugin() + + const client = new Client({ apiKey: 'api_key', plugins: [plugin], redactedKeys: ['password', 'token'] }) + client._setDelivery(createMockDelivery(notifyCallbacks)) + + mockFetch.mockResolvedValue(createMockResponse({ + ok: false, + status: 404, + statusText: 'Not Found', + url: 'https://example.com/api/users', + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => '{"error": "User not found"}' + })) + + await fetch('https://example.com/api/users?page=1&limit=10&password=secret&token=abc123') + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(notifyCallbacks.length).toBe(1) + const event = notifyCallbacks[0].toJSON() + + expect(event.exceptions[0].errorClass).toBe('HTTPError') + expect(event.exceptions[0].errorMessage).toBe('404: https://example.com/api/users') + expect(event.exceptions[0].stacktrace).toEqual([]) // Stacktrace should be empty for HTTP errors + expect(event.context).toBe('GET example.com') + expect(event.request.params).toStrictEqual({ + page: '1', + limit: '10', + password: '[REDACTED]', + token: '[REDACTED]' + }) + }) + }) + describe('config.httpErrorCodes - single range', () => { it('should capture 4xx errors when configured with single range', async () => { const notifyCallbacks: Event[] = [] @@ -313,7 +352,7 @@ describe('plugin-network-instrumentation', () => { const requestMetadata = event.request expect(requestMetadata.body).toBeUndefined() - expect(requestMetadata.bodyLength).toBeUndefined() + expect(requestMetadata.bodyLength).toBe(10000) }) }) @@ -555,7 +594,7 @@ describe('plugin-network-instrumentation', () => { expect(notifyCallbacks.length).toBe(1) const event = notifyCallbacks[0].toJSON() - expect(event.request.url).toBe('https://example.com/api/users?page=1&limit=10') + expect(event.request.url).toBe('https://example.com/api/users') expect(event.request.httpMethod).toBe('POST') expect(event.request.headers).toBeDefined() expect(event.request.headers?.['content-type']).toBe('application/json') diff --git a/packages/plugin-network-instrumentation/test/parse-query-string.test.ts b/packages/plugin-network-instrumentation/test/parse-query-string.test.ts new file mode 100644 index 0000000000..b5c61a928f --- /dev/null +++ b/packages/plugin-network-instrumentation/test/parse-query-string.test.ts @@ -0,0 +1,85 @@ +import parseQueryString from '../lib/parse-query-string' + +describe('parseQueryString', () => { + it('should parse a query string into an object', () => { + const queryString = 'token=abc123&user=john&active=true' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + token: 'abc123', + user: 'john', + active: 'true' + }) + }) + + it('should handle empty query string', () => { + const result = parseQueryString('') + + expect(result).toEqual({}) + }) + + it('should handle null/undefined gracefully', () => { + // @ts-expect-error + expect(parseQueryString(null)).toEqual({}) + // @ts-expect-error + expect(parseQueryString(undefined)).toEqual({}) + }) + + it('should decode URI components', () => { + const queryString = 'email=test%40example.com&name=John%20Doe&path=%2Fhome%2Fuser' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + email: 'test@example.com', + name: 'John Doe', + path: '/home/user' + }) + }) + + it('should handle empty parameter values', () => { + const queryString = 'flag&value=test&empty=' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + flag: '', + value: 'test', + empty: '' + }) + }) + + it('should handle single parameter', () => { + const queryString = 'id=123' + const result = parseQueryString(queryString) + + expect(result).toEqual({ id: '123' }) + }) + + it('should skip empty pairs', () => { + const queryString = 'key=value&&&another=test' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + key: 'value', + another: 'test' + }) + }) + + it('should preserve special characters in values', () => { + const queryString = 'data={"key":"value"}&text=hello%20world' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + data: '{"key":"value"}', + text: 'hello world' + }) + }) + + it('should handle duplicate parameter names (last one wins)', () => { + const queryString = 'key=first&key=second&key=third' + const result = parseQueryString(queryString) + + expect(result).toEqual({ + key: 'third' + }) + }) +}) diff --git a/packages/plugin-network-instrumentation/test/parse-url.test.ts b/packages/plugin-network-instrumentation/test/parse-url.test.ts new file mode 100644 index 0000000000..65975cc54b --- /dev/null +++ b/packages/plugin-network-instrumentation/test/parse-url.test.ts @@ -0,0 +1,117 @@ +import parseUrl from '../lib/parse-url' + +describe('parseUrl', () => { + it('should extract domain, clean URL, and query string from absolute URL', () => { + const url = 'https://api.example.com/path?token=abc123&user=john' + const result = parseUrl(url) + + expect(result.domain).toBe('api.example.com') + expect(result.cleanUrl).toBe('https://api.example.com/path') + expect(result.queryString).toBe('token=abc123&user=john') + }) + + it('should handle URLs without query strings', () => { + const url = 'https://example.com/path' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('https://example.com/path') + expect(result.queryString).toBe('') + }) + + it('should preserve hash fragments', () => { + const url = 'https://example.com/path?key=value#section' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('https://example.com/path#section') + expect(result.queryString).toBe('key=value') + }) + + it('should handle hash without query string', () => { + const url = 'https://example.com/path#section' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('https://example.com/path#section') + expect(result.queryString).toBe('') + }) + + it('should preserve encoded URI components in query string', () => { + const url = 'https://example.com?email=test%40example.com&name=John%20Doe' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.queryString).toBe('email=test%40example.com&name=John%20Doe') + }) + + it('should handle empty query parameter values', () => { + const url = 'https://example.com?flag&value=test' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.queryString).toBe('flag&value=test') + }) + + it('should handle multiple slashes in path', () => { + const url = 'https://example.com/api/v1/users/profile?id=123' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('https://example.com/api/v1/users/profile') + expect(result.queryString).toBe('id=123') + }) + + it('should handle ports in domain', () => { + const url = 'https://example.com:8080/path?query=value' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com:8080') + expect(result.cleanUrl).toBe('https://example.com:8080/path') + expect(result.queryString).toBe('query=value') + }) + + it('should return unknown domain for relative URLs', () => { + const url = '/api/endpoint?param=value' + const result = parseUrl(url) + + expect(result.domain).toBe('unknown') + expect(result.cleanUrl).toBe('/api/endpoint') + expect(result.queryString).toBe('param=value') + }) + + it('should handle http protocol', () => { + const url = 'http://example.com/path?key=value' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('http://example.com/path') + expect(result.queryString).toBe('key=value') + }) + + it('should be case-insensitive for protocol matching', () => { + const url = 'HTTPS://example.com/path?key=value' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('HTTPS://example.com/path') + expect(result.queryString).toBe('key=value') + }) + + it('should handle special characters in query string', () => { + const url = 'https://example.com?data={"key":"value"}&text=hello%20world' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.queryString).toBe('data={"key":"value"}&text=hello%20world') + }) + + it('should handle malformed query strings', () => { + const url = 'https://example.com/path?key=value&&&another=test' + const result = parseUrl(url) + + expect(result.domain).toBe('example.com') + expect(result.cleanUrl).toBe('https://example.com/path') + expect(result.queryString).toBe('key=value&&&another=test') + }) +}) diff --git a/packages/plugin-network-instrumentation/test/redact-query-parameters.test.ts b/packages/plugin-network-instrumentation/test/redact-query-parameters.test.ts deleted file mode 100644 index 1e5889df21..0000000000 --- a/packages/plugin-network-instrumentation/test/redact-query-parameters.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import redactQueryParameters from '../lib/redact-query-parameters' - -describe('redact-query-parameters', () => { - it('redacts specified query parameters in a URL', () => { - const url = 'http://example.com/path?token=abc123&userId=42&status=active' - const redactedKeys = ['token', 'userId'] - const redactedUrl = redactQueryParameters(url, redactedKeys) - expect(redactedUrl).toBe('http://example.com/path?token=[REDACTED]&userId=[REDACTED]&status=active') - }) - - it('handles URLs with no query parameters', () => { - const url = 'http://example.com/path' - const redactedKeys = ['token'] - const redactedUrl = redactQueryParameters(url, redactedKeys) - expect(redactedUrl).toBe('http://example.com/path') - }) - - it('handles relative URLs', () => { - const url = '/path?token=abc123&userId=42' - const redactedKeys = ['token', 'userId'] - const redactedUrl = redactQueryParameters(url, redactedKeys) - expect(redactedUrl).toBe('/path?token=[REDACTED]&userId=[REDACTED]') - }) -}) diff --git a/packages/react-native/ios/BugsnagReactNative/BugsnagEventDeserializer.m b/packages/react-native/ios/BugsnagReactNative/BugsnagEventDeserializer.m index 5c9f28c535..93ce2e30bb 100644 --- a/packages/react-native/ios/BugsnagReactNative/BugsnagEventDeserializer.m +++ b/packages/react-native/ios/BugsnagReactNative/BugsnagEventDeserializer.m @@ -30,6 +30,8 @@ - (BugsnagEvent *)deserializeEvent:(NSDictionary *)payload { threads:[self deserializeThreads:payload[@"threads"]] session:nil /* set by -[BugsnagClient notifyInternal:block:] */]; event.context = payload[@"context"]; + event.request = [self deserializeRequest:payload[@"request"]]; + event.response = [self deserializeResponse:payload[@"response"]]; event.groupingHash = payload[@"groupingHash"]; event.groupingDiscriminator = payload[@"groupingDiscriminator"]; @@ -132,4 +134,18 @@ - (BugsnagHandledState *)deserializeHandledState:(NSDictionary *)payload { return array; } +- (BugsnagHttpRequest *)deserializeRequest:(NSDictionary *)request { + if (request != nil) { + return [BugsnagHttpRequest requestFromJson:request]; + } + return nil; +} + +- (BugsnagHttpResponse *)deserializeResponse:(NSDictionary *)response { + if (response != nil) { + return [BugsnagHttpResponse responseFromJson:response]; + } + return nil; +} + @end diff --git a/test/browser/features/http_errors.feature b/test/browser/features/http_errors.feature index 4041b00c91..075a42926b 100644 --- a/test/browser/features/http_errors.feature +++ b/test/browser/features/http_errors.feature @@ -10,8 +10,8 @@ Feature: HTTP Errors Then the error is a valid browser payload for the error reporting API And I define "expected.context" as "GET " - And I define "expected.exception.message" as "401: /reflect?status=401&userId=[REDACTED]" - And I define "expected.request.url" as "/reflect?status=401&userId=[REDACTED]" + And I define "expected.exception.message" as "401: /reflect" + And I define "expected.request.url" as "/reflect" And the exception "errorClass" equals "HTTPError" And the error payload field "events.0.exceptions.0.message" equals the stored value "expected.exception.message" @@ -41,8 +41,8 @@ Feature: HTTP Errors Then the error is a valid browser payload for the error reporting API And I define "expected.context" as "POST " - And I define "expected.exception.message" as "408: /reflect?status=408&userId=[REDACTED]" - And I define "expected.request.url" as "/reflect?status=408&userId=[REDACTED]" + And I define "expected.exception.message" as "408: /reflect" + And I define "expected.request.url" as "/reflect" And the exception "errorClass" equals "HTTPError" And the error payload field "events.0.exceptions.0.message" equals the stored value "expected.exception.message" @@ -74,8 +74,8 @@ Feature: HTTP Errors Then the error is a valid browser payload for the error reporting API And I define "expected.context" as "GET " - And I define "expected.exception.message" as "404: /reflect?status=404&userId=[REDACTED]" - And I define "expected.request.url" as "/reflect?status=404&userId=[REDACTED]" + And I define "expected.exception.message" as "404: /reflect" + And I define "expected.request.url" as "/reflect" And the exception "errorClass" equals "HTTPError" And the error payload field "events.0.exceptions.0.message" equals the stored value "expected.exception.message" @@ -106,8 +106,8 @@ Feature: HTTP Errors Then the error is a valid browser payload for the error reporting API And I define "expected.context" as "POST " - And I define "expected.exception.message" as "403: /reflect?status=403&userId=[REDACTED]" - And I define "expected.request.url" as "/reflect?status=403&userId=[REDACTED]" + And I define "expected.exception.message" as "403: /reflect" + And I define "expected.request.url" as "/reflect" And the exception "errorClass" equals "HTTPError" And the error payload field "events.0.exceptions.0.message" equals the stored value "expected.exception.message" diff --git a/test/react-native/features/fixtures/expected_http_errors/401.json b/test/react-native/features/fixtures/expected_http_errors/401/exceptions.json similarity index 66% rename from test/react-native/features/fixtures/expected_http_errors/401.json rename to test/react-native/features/fixtures/expected_http_errors/401/exceptions.json index ed32018bf4..e0a16a6cef 100644 --- a/test/react-native/features/fixtures/expected_http_errors/401.json +++ b/test/react-native/features/fixtures/expected_http_errors/401/exceptions.json @@ -1,6 +1,6 @@ [ { - "message": "^401: .*\/reflect[?]status=401$", + "message": "^401: http(s)?:\/\/.*\/reflect$", "errorClass": "HTTPError", "type": "reactnativejs", "stacktrace": "IGNORE" diff --git a/test/react-native/features/fixtures/expected_http_errors/401/request.json b/test/react-native/features/fixtures/expected_http_errors/401/request.json new file mode 100644 index 0000000000..0b0c889c72 --- /dev/null +++ b/test/react-native/features/fixtures/expected_http_errors/401/request.json @@ -0,0 +1,8 @@ +{ + "url": "^http(s)?:\/\/.*\/reflect$", + "headers": {}, + "params": { + "status": "401" + }, + "httpMethod": "GET" +} diff --git a/test/react-native/features/fixtures/expected_http_errors/401/response.json b/test/react-native/features/fixtures/expected_http_errors/401/response.json new file mode 100644 index 0000000000..6cae68527b --- /dev/null +++ b/test/react-native/features/fixtures/expected_http_errors/401/response.json @@ -0,0 +1,6 @@ +{ + "body": "[Binary Data]", + "statusCode": 401, + "bodyLength": 13, + "headers": "IGNORE" +} diff --git a/test/react-native/features/fixtures/expected_http_errors/500.json b/test/react-native/features/fixtures/expected_http_errors/500/exceptions.json similarity index 66% rename from test/react-native/features/fixtures/expected_http_errors/500.json rename to test/react-native/features/fixtures/expected_http_errors/500/exceptions.json index d65420dd36..35c000eeac 100644 --- a/test/react-native/features/fixtures/expected_http_errors/500.json +++ b/test/react-native/features/fixtures/expected_http_errors/500/exceptions.json @@ -1,6 +1,6 @@ [ { - "message": "^500: .*\/reflect[?]status=500$", + "message": "^500: http(s)?:\/\/.*\/reflect$", "errorClass": "HTTPError", "type": "reactnativejs", "stacktrace": "IGNORE" diff --git a/test/react-native/features/fixtures/expected_http_errors/500/request.json b/test/react-native/features/fixtures/expected_http_errors/500/request.json new file mode 100644 index 0000000000..9f7a3f5e65 --- /dev/null +++ b/test/react-native/features/fixtures/expected_http_errors/500/request.json @@ -0,0 +1,8 @@ +{ + "url": "^http(s)?:\/\/.*\/reflect$", + "headers": {}, + "params": { + "status": "500" + }, + "httpMethod": "GET" +} diff --git a/test/react-native/features/fixtures/expected_http_errors/500/response.json b/test/react-native/features/fixtures/expected_http_errors/500/response.json new file mode 100644 index 0000000000..3b7aa2d949 --- /dev/null +++ b/test/react-native/features/fixtures/expected_http_errors/500/response.json @@ -0,0 +1,6 @@ +{ + "body": "[Binary Data]", + "statusCode": 500, + "bodyLength": 13, + "headers": "IGNORE" +} \ No newline at end of file diff --git a/test/react-native/features/http_errors.feature b/test/react-native/features/http_errors.feature index 3f59d9ce5e..a87388a67c 100644 --- a/test/react-native/features/http_errors.feature +++ b/test/react-native/features/http_errors.feature @@ -4,7 +4,9 @@ Scenario Outline: Error is reported for network requests with error status code When I run "NetworkRequestScenario" with data "" And I wait to receive an error Then the error payload field "events.0.context" matches the regex "^GET [0-9.]*:[0-9]{4}$" - And the error payload field "events.0.exceptions" matches the JSON fixture in "features/fixtures/expected_http_errors/.json" + And the error payload field "events.0.exceptions" matches the JSON fixture in "features/fixtures/expected_http_errors//exceptions.json" + And the error payload field "events.0.request" matches the JSON fixture in "features/fixtures/expected_http_errors//request.json" + And the error payload field "events.0.response" matches the JSON fixture in "features/fixtures/expected_http_errors//response.json" Examples: | status_code | | 401 |