From 6b6d49a80beb11dc9f355f65eb032cb5316935bc Mon Sep 17 00:00:00 2001 From: MuriloZF Date: Thu, 3 Sep 2026 08:46:52 -0300 Subject: [PATCH 1/6] feature: add CapacitorHttp support - Issue#820 --- CHANGELOG.md | 4 + src/integrations/capacitorHttp.ts | 205 +++++++++++ src/integrations/default.ts | 2 + src/integrations/index.ts | 1 + test/integrations/capacitorHttp.test.ts | 372 ++++++++++++++++++++ test/integrations/capacitorHttp.web.test.ts | 37 ++ 6 files changed, 621 insertions(+) create mode 100644 src/integrations/capacitorHttp.ts create mode 100644 test/integrations/capacitorHttp.test.ts create mode 100644 test/integrations/capacitorHttp.web.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a2eec5..23d951ea 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`. + ### Break Changes - Remove `SentryCapacitor.podspec`, dropping CocoaPods support for iOS ([#1352](https://github.com/getsentry/sentry-capacitor/pull/1352)) diff --git a/src/integrations/capacitorHttp.ts b/src/integrations/capacitorHttp.ts new file mode 100644 index 00000000..600620bd --- /dev/null +++ b/src/integrations/capacitorHttp.ts @@ -0,0 +1,205 @@ +import { + Capacitor, + CapacitorHttp, + type HttpOptions, + type HttpResponse, +} from '@capacitor/core'; +import { + addBreadcrumb, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getTraceData, + 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'; + +const HTTP_METHODS: HttpMethod[] = [ + 'request', + 'get', + 'post', + 'put', + 'patch', + 'delete', +]; + +export const capacitorHttpIntegration = (): Integration => ({ + name: INTEGRATION_NAME, + + setupOnce(): void { + if (!Capacitor.isNativePlatform()) { + return; + } + + HTTP_METHODS.forEach(method => { + fillTyped(CapacitorHttp, method, original => { + return function ( + this: typeof CapacitorHttp, + options: HttpOptions, + ): Promise { + return instrumentRequest(original, this, method, options); + }; + }); + }); + }, +}); + +function getMethod(method: HttpMethod, options: HttpOptions): string { + return method === 'request' + ? (options.method ?? 'GET').toUpperCase() + : method.toUpperCase(); +} + +function addTracingHeaders(options: HttpOptions, span: Span): HttpOptions { + const client = getClient(); + + 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 existingKey = findHeaderKey(headers, 'baggage'); + + if (!existingKey) { + headers.baggage = sentryBaggage; + return; + } + + const existingValue = headers[existingKey]; + + if (!existingValue) { + headers[existingKey] = sentryBaggage; + return; + } + + // Preserve baggage which already contains Sentry Values + if (/(?:^|,)\s*sentry-[^=]*=/.test(existingValue)) { + return; + } + + headers[existingKey] = `${existingValue},${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: true, + 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); + + 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..8c9b56d3 100644 --- a/src/integrations/default.ts +++ b/src/integrations/default.ts @@ -1,6 +1,7 @@ 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 { capacitorHttpIntegration } from './capacitorHttp'; import { deviceContextIntegration } from './devicecontext'; import { eventOriginIntegration } from './eventorigin'; import { logEnricherIntegration } from './logEnricherIntegration'; @@ -20,6 +21,7 @@ export function getDefaultIntegrations( integrations.push(nativeReleaseIntegration()); integrations.push(eventOriginIntegration()); integrations.push(sdkInfoIntegration()); + integrations.push(capacitorHttpIntegration()); if (options.enableNative) { integrations.push(deviceContextIntegration()); 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..8af18830 --- /dev/null +++ b/test/integrations/capacitorHttp.test.ts @@ -0,0 +1,372 @@ +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(); + +jest.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: mockIsNativePlatform, + }, + CapacitorHttp: { + request: mockRequest, + get: mockGet, + post: mockPost, + put: mockPut, + patch: mockPatch, + delete: mockDelete, + }, +})); + +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 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); +}); From 38ed60a16b4b4b614533176c0907ba3d191a31d4 Mon Sep 17 00:00:00 2001 From: MuriloZF Date: Fri, 4 Sep 2026 13:03:19 -0300 Subject: [PATCH 2/6] fix: instrument CapacitorHTTP through native bridge --- src/integrations/capacitorHttp.ts | 66 +++++++++++++++++---- test/integrations/capacitorHttp.test.ts | 76 +++++++++++++++++++++---- 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/src/integrations/capacitorHttp.ts b/src/integrations/capacitorHttp.ts index 600620bd..65d07caf 100644 --- a/src/integrations/capacitorHttp.ts +++ b/src/integrations/capacitorHttp.ts @@ -1,6 +1,5 @@ import { Capacitor, - CapacitorHttp, type HttpOptions, type HttpResponse, } from '@capacitor/core'; @@ -22,6 +21,16 @@ 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', @@ -31,6 +40,19 @@ const HTTP_METHODS: HttpMethod[] = [ '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, @@ -39,15 +61,39 @@ export const capacitorHttpIntegration = (): Integration => ({ return; } - HTTP_METHODS.forEach(method => { - fillTyped(CapacitorHttp, method, original => { - return function ( - this: typeof CapacitorHttp, - options: HttpOptions, - ): Promise { - return instrumentRequest(original, this, method, options); - }; - }); + 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); + }; }); }, }); diff --git a/test/integrations/capacitorHttp.test.ts b/test/integrations/capacitorHttp.test.ts index 8af18830..78333590 100644 --- a/test/integrations/capacitorHttp.test.ts +++ b/test/integrations/capacitorHttp.test.ts @@ -8,18 +8,42 @@ const mockPut = jest.fn(); const mockPatch = jest.fn(); const mockDelete = jest.fn(); -jest.mock('@capacitor/core', () => ({ - Capacitor: { - isNativePlatform: mockIsNativePlatform, - }, - CapacitorHttp: { - request: mockRequest, - get: mockGet, - post: mockPost, - put: mockPut, - patch: mockPatch, - delete: mockDelete, +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', () => { @@ -70,6 +94,36 @@ beforeEach(() => { }); }); +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 }, From c6aff7b784f2592dea81c5122d303d02831908a8 Mon Sep 17 00:00:00 2001 From: Murilo Zimerman Fortaleza <166302769+MuriloZF@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:45:09 -0300 Subject: [PATCH 3/6] Update CHANGELOG.md Co-authored-by: LucasZF --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72a5b76..3cf433b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ ### Features -- Add automatic instrumentation for `CapacitorHttp` requests, including spans, breadcrumbs, and trace propagation using `tracePropagationTargets` and `propagateTraceparent`. +- 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 From 5d4f794b317cde45be34fd6e10666dbdf6b60ff4 Mon Sep 17 00:00:00 2001 From: LucasZF Date: Fri, 4 Sep 2026 19:47:22 -0300 Subject: [PATCH 4/6] Apply suggestion from @lucas-zimerman --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf433b7..49329df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ - 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)) From 40c766fec689113d47c04d4d6ec33d1cd567a6ac Mon Sep 17 00:00:00 2001 From: LucasZF Date: Fri, 4 Sep 2026 20:37:37 -0300 Subject: [PATCH 5/6] Apply suggestion from @lucas-zimerman --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49329df1..2718bf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ - 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)) From 79cfd4d97bd5c1c79028d2097d8d54a86b69ec36 Mon Sep 17 00:00:00 2001 From: MuriloZF Date: Sat, 5 Sep 2026 16:34:22 -0300 Subject: [PATCH 6/6] fixed CapactiorHttp instrumentation --- src/integrations/capacitorHttp.ts | 43 +++++++++++++++---------------- src/integrations/default.ts | 21 ++++++++++----- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/integrations/capacitorHttp.ts b/src/integrations/capacitorHttp.ts index 65d07caf..b24aea08 100644 --- a/src/integrations/capacitorHttp.ts +++ b/src/integrations/capacitorHttp.ts @@ -5,9 +5,12 @@ import { } from '@capacitor/core'; import { addBreadcrumb, + type Client, + debug, getBreadcrumbLogLevelFromHttpStatusCode, getClient, getTraceData, + hasSpanStreamingEnabled, type Integration, setHttpStatus, shouldPropagateTraceForUrl, @@ -58,6 +61,9 @@ export const capacitorHttpIntegration = (): Integration => ({ setupOnce(): void { if (!Capacitor.isNativePlatform()) { + debug.warn( + `[${INTEGRATION_NAME}] is disabled by not running on a native platform`, + ); return; } @@ -100,13 +106,15 @@ export const capacitorHttpIntegration = (): Integration => ({ function getMethod(method: HttpMethod, options: HttpOptions): string { return method === 'request' - ? (options.method ?? 'GET').toUpperCase() + ? (options.method?.toUpperCase() ?? 'GET') : method.toUpperCase(); } -function addTracingHeaders(options: HttpOptions, span: Span): HttpOptions { - const client = getClient(); - +function addTracingHeaders( + options: HttpOptions, + span: Span, + client: Client, +): HttpOptions { if (!client) { return options; } @@ -161,26 +169,17 @@ function mergeBaggageHeader( return; } - const existingKey = findHeaderKey(headers, 'baggage'); - - if (!existingKey) { - headers.baggage = sentryBaggage; - return; - } - - const existingValue = headers[existingKey]; - - if (!existingValue) { - headers[existingKey] = sentryBaggage; - return; - } + const key = findHeaderKey(headers, 'baggage') ?? 'baggage'; + const existingValue = headers[key]; - // Preserve baggage which already contains Sentry Values - if (/(?:^|,)\s*sentry-[^=]*=/.test(existingValue)) { + // Preserve baggage wich already contains Sentry values + if (existingValue && /(?:^|,)\s*sentry-[^=]*=/.test(existingValue)) { return; } - headers[existingKey] = `${existingValue},${sentryBaggage}`; + headers[key] = existingValue + ? `${existingValue},${sentryBaggage}` + : sentryBaggage; } async function instrumentRequest( @@ -202,7 +201,7 @@ async function instrumentRequest( { name: spanName, op: 'http.client', - onlyIfParent: true, + onlyIfParent: !hasSpanStreamingEnabled(client), attributes: { 'http.request.method': method, 'url.full': options.url, @@ -211,7 +210,7 @@ async function instrumentRequest( }, async span => { // Trace headers, request, status and breadcrumb - const requestOptions = addTracingHeaders(options, span); + const requestOptions = addTracingHeaders(options, span, client); try { const response = (await original.call( diff --git a/src/integrations/default.ts b/src/integrations/default.ts index 8c9b56d3..217eacec 100644 --- a/src/integrations/default.ts +++ b/src/integrations/default.ts @@ -1,7 +1,18 @@ -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 { capacitorHttpIntegration } from './capacitorHttp'; import { deviceContextIntegration } from './devicecontext'; import { eventOriginIntegration } from './eventorigin'; import { logEnricherIntegration } from './logEnricherIntegration'; @@ -21,13 +32,11 @@ export function getDefaultIntegrations( integrations.push(nativeReleaseIntegration()); integrations.push(eventOriginIntegration()); integrations.push(sdkInfoIntegration()); - integrations.push(capacitorHttpIntegration()); if (options.enableNative) { integrations.push(deviceContextIntegration()); integrations.push(logEnricherIntegration()); - } - else { + } else { integrations.push(httpContextIntegration()); }