diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1db335..2718bf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Add automatic instrumentation for `CapacitorHttp` requests, including spans, breadcrumbs, and trace propagation using `tracePropagationTargets` and `propagateTraceparent`. ([#1387](https://github.com/getsentry/sentry-capacitor/pull/1387)) +- ### Fixes - iOS: `enableCaptureFailedRequests` and `sendDefaultPii` are now correctly passed into Sentry Cocoa SDK ([#1384](https://github.com/getsentry/sentry-capacitor/pull/1384)) diff --git a/src/integrations/capacitorHttp.ts b/src/integrations/capacitorHttp.ts new file mode 100644 index 00000000..b24aea08 --- /dev/null +++ b/src/integrations/capacitorHttp.ts @@ -0,0 +1,250 @@ +import { + Capacitor, + type HttpOptions, + type HttpResponse, +} from '@capacitor/core'; +import { + addBreadcrumb, + type Client, + debug, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getTraceData, + hasSpanStreamingEnabled, + type Integration, + setHttpStatus, + shouldPropagateTraceForUrl, + type Span, + startSpan, + stripUrlQueryAndFragment, +} from '@sentry/core'; +import { fillTyped } from '../utils/fill'; + +const INTEGRATION_NAME = 'CapacitorHttp'; + +type HttpMethod = 'request' | 'get' | 'post' | 'put' | 'patch' | 'delete'; + +type NativePromise = ( + pluginName: string, + methodName: string, + options?: unknown, +) => Promise; + +type CapacitorWithNativePromise = typeof Capacitor & { + nativePromise: NativePromise; +}; + +const HTTP_METHODS: HttpMethod[] = [ + 'request', + 'get', + 'post', + 'put', + 'patch', + 'delete', +]; + +function isHttpMethod(method: string): method is HttpMethod { + return HTTP_METHODS.includes(method as HttpMethod); +} + +function isHttpOptions(options: unknown): options is HttpOptions { + return ( + typeof options === 'object' && + options !== null && + 'url' in options && + typeof options.url === 'string' + ); +} + +export const capacitorHttpIntegration = (): Integration => ({ + name: INTEGRATION_NAME, + + setupOnce(): void { + if (!Capacitor.isNativePlatform()) { + debug.warn( + `[${INTEGRATION_NAME}] is disabled by not running on a native platform`, + ); + return; + } + + const capacitor = Capacitor as CapacitorWithNativePromise; + + if (typeof capacitor.nativePromise !== 'function') { + return; + } + + fillTyped(capacitor, 'nativePromise', original => { + return function ( + this: CapacitorWithNativePromise, + pluginName: string, + methodName: string, + options?: unknown, + ): Promise { + if ( + pluginName !== INTEGRATION_NAME || + !isHttpMethod(methodName) || + !isHttpOptions(options) + ) { + return original.call(this, pluginName, methodName, options); + } + + const nativeRequest = ( + requestOptions: HttpOptions, + ): Promise => + original.call( + this, + pluginName, + methodName, + requestOptions, + ) as Promise; + + return instrumentRequest(nativeRequest, this, methodName, options); + }; + }); + }, +}); + +function getMethod(method: HttpMethod, options: HttpOptions): string { + return method === 'request' + ? (options.method?.toUpperCase() ?? 'GET') + : method.toUpperCase(); +} + +function addTracingHeaders( + options: HttpOptions, + span: Span, + client: Client, +): HttpOptions { + if (!client) { + return options; + } + const { tracePropagationTargets, propagateTraceparent } = client.getOptions(); + if (!shouldPropagateTraceForUrl(options.url, tracePropagationTargets)) { + return options; + } + + const traceData = getTraceData({ + span, + propagateTraceparent, + }); + + const headers = { ...options.headers }; + + setHeaderIfMissing(headers, 'sentry-trace', traceData['sentry-trace']); + setHeaderIfMissing(headers, 'traceparent', traceData.traceparent); + mergeBaggageHeader(headers, traceData.baggage); + + return { + ...options, + headers, + }; +} + +function findHeaderKey( + headers: Record, + name: string, +): string | undefined { + return Object.keys(headers).find( + key => key.toLowerCase() === name.toLowerCase(), + ); +} + +function setHeaderIfMissing( + headers: Record, + name: string, + value: string | undefined, +): void { + if (!value || findHeaderKey(headers, name)) { + return; + } + + headers[name] = value; +} + +function mergeBaggageHeader( + headers: Record, + sentryBaggage: string | undefined, +): void { + if (!sentryBaggage) { + return; + } + + const key = findHeaderKey(headers, 'baggage') ?? 'baggage'; + const existingValue = headers[key]; + + // Preserve baggage wich already contains Sentry values + if (existingValue && /(?:^|,)\s*sentry-[^=]*=/.test(existingValue)) { + return; + } + + headers[key] = existingValue + ? `${existingValue},${sentryBaggage}` + : sentryBaggage; +} + +async function instrumentRequest( + original: (this: unknown, options: HttpOptions) => Promise, + thisArg: unknown, + methodName: HttpMethod, + options: HttpOptions, +): Promise { + const client = getClient(); + + if (!client) { + return original.call(thisArg, options); + } + + const method = getMethod(methodName, options); + const spanName = `${method} ${stripUrlQueryAndFragment(options.url)}`; + + return startSpan( + { + name: spanName, + op: 'http.client', + onlyIfParent: !hasSpanStreamingEnabled(client), + attributes: { + 'http.request.method': method, + 'url.full': options.url, + 'sentry.origin': 'auto.http.capacitor', + }, + }, + async span => { + // Trace headers, request, status and breadcrumb + const requestOptions = addTracingHeaders(options, span, client); + + try { + const response = (await original.call( + thisArg, + requestOptions, + )) as HttpResponse; + + setHttpStatus(span, response.status); + + addBreadcrumb({ + category: 'capacitor.http', + type: 'http', + level: getBreadcrumbLogLevelFromHttpStatusCode(response.status), + data: { + method, + url: options.url, + status_code: response.status, + }, + }); + + return response; + } catch (error) { + addBreadcrumb({ + category: 'capacitor.http', + type: 'http', + level: 'error', + data: { + method, + url: options.url, + }, + }); + + throw error; + } + }, + ); +} diff --git a/src/integrations/default.ts b/src/integrations/default.ts index 3437e3d4..217eacec 100644 --- a/src/integrations/default.ts +++ b/src/integrations/default.ts @@ -1,5 +1,17 @@ -import { breadcrumbsIntegration, browserApiErrorsIntegration, browserSessionIntegration, globalHandlersIntegration, httpContextIntegration } from '@sentry/browser'; -import { dedupeIntegration, eventFiltersIntegration, functionToStringIntegration, type Integration, linkedErrorsIntegration } from '@sentry/core'; +import { + breadcrumbsIntegration, + browserApiErrorsIntegration, + browserSessionIntegration, + globalHandlersIntegration, + httpContextIntegration, +} from '@sentry/browser'; +import { + dedupeIntegration, + eventFiltersIntegration, + functionToStringIntegration, + type Integration, + linkedErrorsIntegration, +} from '@sentry/core'; import type { CapacitorOptions } from '../options'; import { deviceContextIntegration } from './devicecontext'; import { eventOriginIntegration } from './eventorigin'; @@ -24,8 +36,7 @@ export function getDefaultIntegrations( if (options.enableNative) { integrations.push(deviceContextIntegration()); integrations.push(logEnricherIntegration()); - } - else { + } else { integrations.push(httpContextIntegration()); } diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 6ce72bf8..4c2424fd 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -5,3 +5,4 @@ export { nativeReleaseIntegration } from './release'; export { capacitorRewriteFramesIntegration } from './rewriteframes'; export { sdkInfoIntegration } from './sdkinfo'; export { spotlightIntegration } from './spotlight'; +export { capacitorHttpIntegration } from './capacitorHttp'; diff --git a/test/integrations/capacitorHttp.test.ts b/test/integrations/capacitorHttp.test.ts new file mode 100644 index 00000000..78333590 --- /dev/null +++ b/test/integrations/capacitorHttp.test.ts @@ -0,0 +1,426 @@ +const mockSpan = {}; +const mockIsNativePlatform = jest.fn(() => true); + +const mockRequest = jest.fn(); +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockPut = jest.fn(); +const mockPatch = jest.fn(); +const mockDelete = jest.fn(); + +const mockHttpMethods: Record = { + request: mockRequest, + get: mockGet, + post: mockPost, + put: mockPut, + patch: mockPatch, + delete: mockDelete, +}; + +const mockNativePromise = jest.fn( + (_pluginName: string, methodName: string, options?: unknown) => + mockHttpMethods[methodName]?.(options), +); + +const mockCapacitor = { + isNativePlatform: mockIsNativePlatform, + nativePromise: mockNativePromise, +}; + +const mockCapacitorHttp = new Proxy( + {}, + { + get(_target, property) { + return (options: unknown) => + mockCapacitor.nativePromise( + 'CapacitorHttp', + String(property), + options, + ); + }, + }, +); + +jest.mock('@capacitor/core', () => ({ + Capacitor: mockCapacitor, + CapacitorHttp: mockCapacitorHttp, +})); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + + return { + ...actual, + addBreadcrumb: jest.fn(), + getClient: jest.fn(), + getTraceData: jest.fn(), + setHttpStatus: jest.fn(), + shouldPropagateTraceForUrl: jest.fn(), + startSpan: jest.fn((_options, callback) => callback(mockSpan)), + }; +}); + +import { CapacitorHttp } from '@capacitor/core'; +import { + addBreadcrumb, + getClient, + getTraceData, + setHttpStatus, + shouldPropagateTraceForUrl, + startSpan, +} from '@sentry/core'; +import { capacitorHttpIntegration } from '../../src/integrations/capacitorHttp'; + +beforeAll(() => { + capacitorHttpIntegration().setupOnce?.(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + + (getClient as jest.Mock).mockReturnValue({ + getOptions: () => ({ + tracePropagationTargets: ['example.com'], + propagateTraceparent: true, + }), + }); + + (shouldPropagateTraceForUrl as jest.Mock).mockReturnValue(true); + + (getTraceData as jest.Mock).mockReturnValue({ + 'sentry-trace': 'trace-value', + 'baggage': 'sentry-release=1.0.0', + 'traceparent': 'traceparent-value', + }); +}); + +it('instruments methods returned as fresh wrappers by the plugin proxy', async () => { + expect(CapacitorHttp.get).not.toBe(CapacitorHttp.get); + + mockGet.mockResolvedValue({ + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }); + + await CapacitorHttp.get({ url: 'https://example.com/users' }); + + expect(startSpan).toHaveBeenCalledTimes(1); +}); + +it('does not instrument native calls for other plugins', async () => { + const options = { value: 'test' }; + + mockNativePromise.mockResolvedValueOnce(undefined); + + await mockCapacitor.nativePromise('OtherPlugin', 'get', options); + + expect(mockNativePromise).toHaveBeenCalledWith( + 'OtherPlugin', + 'get', + options, + ); + expect(startSpan).not.toHaveBeenCalled(); +}); + +it('instruments a successful GET request', async () => { + const response = { + data: { success: true }, + headers: { + 'content-type': 'application/json', + }, + status: 200, + url: 'https://example.com/users', + }; + + mockGet.mockResolvedValue(response); + + const result = await CapacitorHttp.get({ + url: 'https://example.com/users?active=true', + }); + + expect(result).toBe(response); + + expect(startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET https://example.com/users', + op: 'http.client', + onlyIfParent: true, + }), + expect.any(Function), + ); + + expect(mockGet).toHaveBeenCalledWith({ + url: 'https://example.com/users?active=true', + headers: { + 'sentry-trace': 'trace-value', + 'baggage': 'sentry-release=1.0.0', + 'traceparent': 'traceparent-value', + }, + }); + + expect(setHttpStatus).toHaveBeenCalledWith(mockSpan, 200); + + expect(addBreadcrumb).toHaveBeenCalledWith({ + category: 'capacitor.http', + type: 'http', + level: undefined, + data: { + method: 'GET', + url: 'https://example.com/users?active=true', + status_code: 200, + }, + }); +}); + +it('adds an error breadcrumb and preserves request rejection', async () => { + const error = new Error('Network request failed'); + + mockGet.mockRejectedValue(error); + + await expect( + CapacitorHttp.get({ + url: 'https://example.com/users', + }), + ).rejects.toBe(error); + + expect(setHttpStatus).not.toHaveBeenCalled(); + + expect(addBreadcrumb).toHaveBeenCalledWith({ + category: 'capacitor.http', + type: 'http', + level: 'error', + data: { + method: 'GET', + url: 'https://example.com/users', + }, + }); +}); + +it('does not inject headers when the URL does not match', async () => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://other.example/users', + }; + + const options = { + url: 'https://other.example/users', + headers: { + authorization: 'Bearer token', + }, + }; + + mockGet.mockResolvedValue(response); + (shouldPropagateTraceForUrl as jest.Mock).mockReturnValue(false); + + await CapacitorHttp.get(options); + + expect(shouldPropagateTraceForUrl).toHaveBeenCalledWith(options.url, [ + 'example.com', + ]); + + expect(getTraceData).not.toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith(options); + + expect(options).toEqual({ + url: 'https://other.example/users', + headers: { + authorization: 'Bearer token', + }, + }); +}); + +it('preserves existing trace headers and merges non-Sentry baggage', async () => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }; + + const options = { + url: 'https://example.com/users', + headers: { + 'Sentry-Trace': 'existing-trace', + 'Traceparent': 'existing-traceparent', + 'Baggage': 'vendor=value', + }, + }; + + mockGet.mockResolvedValue(response); + + await CapacitorHttp.get(options); + + expect(getTraceData).toHaveBeenCalledWith({ + span: mockSpan, + propagateTraceparent: true, + }); + + expect(mockGet).toHaveBeenCalledWith({ + url: 'https://example.com/users', + headers: { + 'Sentry-Trace': 'existing-trace', + 'Traceparent': 'existing-traceparent', + 'Baggage': 'vendor=value,sentry-release=1.0.0', + }, + }); + + expect(options).toEqual({ + url: 'https://example.com/users', + headers: { + 'Sentry-Trace': 'existing-trace', + 'Traceparent': 'existing-traceparent', + 'Baggage': 'vendor=value', + }, + }); +}); + +it('does not add traceparent when propagateTraceparent is disabled', async () => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }; + + (getClient as jest.Mock).mockReturnValue({ + getOptions: () => ({ + tracePropagationTargets: ['example.com'], + propagateTraceparent: false, + }), + }); + + (getTraceData as jest.Mock).mockReturnValue({ + 'sentry-trace': 'trace-value', + 'baggage': 'sentry-release=1.0.0', + }); + + mockGet.mockResolvedValue(response); + + await CapacitorHttp.get({ + url: 'https://example.com/users', + }); + + expect(getTraceData).toHaveBeenCalledWith({ + span: mockSpan, + propagateTraceparent: false, + }); + + expect(mockGet).toHaveBeenCalledWith({ + url: 'https://example.com/users', + headers: { + 'sentry-trace': 'trace-value', + 'baggage': 'sentry-release=1.0.0', + }, + }); +}); + +it.each([ + ['get', mockGet, 'GET', undefined], + ['post', mockPost, 'POST', undefined], + ['put', mockPut, 'PUT', undefined], + ['patch', mockPatch, 'PATCH', undefined], + ['delete', mockDelete, 'DELETE', undefined], + ['request', mockRequest, 'GET', undefined], + ['request', mockRequest, 'HEAD', 'head'], +] as const)( + 'instruments CapacitorHttp.%s using the %s method', + async (method, originalMock, expectedMethod, requestMethod) => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }; + + originalMock.mockResolvedValue(response); + + const options = { + url: 'https://example.com/users', + ...(requestMethod ? { method: requestMethod } : {}), + }; + + await CapacitorHttp[method](options); + + expect(startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: `${expectedMethod} https://example.com/users`, + attributes: expect.objectContaining({ + 'http.request.method': expectedMethod, + }), + }), + expect.any(Function), + ); + + expect(originalMock).toHaveBeenCalled(); + }, +); + +it('passes the request through unchanged when there is no client', async () => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }; + + const options = { + url: 'https://example.com/users', + headers: { + authorization: 'Bearer token', + }, + }; + + (getClient as jest.Mock).mockReturnValue(undefined); + mockGet.mockResolvedValue(response); + + const result = await CapacitorHttp.get(options); + + expect(result).toBe(response); + expect(mockGet.mock.calls[0]?.[0]).toBe(options); + + expect(startSpan).not.toHaveBeenCalled(); + expect(shouldPropagateTraceForUrl).not.toHaveBeenCalled(); + expect(getTraceData).not.toHaveBeenCalled(); + expect(setHttpStatus).not.toHaveBeenCalled(); + expect(addBreadcrumb).not.toHaveBeenCalled(); +}); + +it('preserves baggage that already contains Sentry values', async () => { + const response = { + data: {}, + headers: {}, + status: 200, + url: 'https://example.com/users', + }; + + const options = { + url: 'https://example.com/users', + headers: { + Baggage: 'vendor=value, sentry-release=existing', + }, + }; + + mockGet.mockResolvedValue(response); + + await CapacitorHttp.get(options); + + expect(mockGet).toHaveBeenCalledWith({ + url: 'https://example.com/users', + headers: { + 'Baggage': 'vendor=value, sentry-release=existing', + 'sentry-trace': 'trace-value', + 'traceparent': 'traceparent-value', + }, + }); + + expect(options).toEqual({ + url: 'https://example.com/users', + headers: { + Baggage: 'vendor=value, sentry-release=existing', + }, + }); +}); diff --git a/test/integrations/capacitorHttp.web.test.ts b/test/integrations/capacitorHttp.web.test.ts new file mode 100644 index 00000000..465f7163 --- /dev/null +++ b/test/integrations/capacitorHttp.web.test.ts @@ -0,0 +1,37 @@ +jest.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: jest.fn(() => false), + }, + CapacitorHttp: { + request: jest.fn(), + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }, +})); + +import { Capacitor, CapacitorHttp } from '@capacitor/core'; +import { capacitorHttpIntegration } from '../../src/integrations/capacitorHttp'; + +it('does not instrument CapacitorHttp on web platforms', () => { + const originalMethods = { + request: CapacitorHttp.request, + get: CapacitorHttp.get, + post: CapacitorHttp.post, + put: CapacitorHttp.put, + patch: CapacitorHttp.patch, + delete: CapacitorHttp.delete, + }; + + capacitorHttpIntegration().setupOnce?.(); + + expect(Capacitor.isNativePlatform).toHaveBeenCalledTimes(1); + expect(CapacitorHttp.request).toBe(originalMethods.request); + expect(CapacitorHttp.get).toBe(originalMethods.get); + expect(CapacitorHttp.post).toBe(originalMethods.post); + expect(CapacitorHttp.put).toBe(originalMethods.put); + expect(CapacitorHttp.patch).toBe(originalMethods.patch); + expect(CapacitorHttp.delete).toBe(originalMethods.delete); +});