Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

## Unreleased

### Internal

- Track `sentryWebViewProps` adoption via a feature marker ([#6938](https://github.com/getsentry/sentry-react-native/pull/6938))

### Dependencies

- Bump CLI from v3.8.1 to v3.8.2 ([#6933](https://github.com/getsentry/sentry-react-native/pull/6933))
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/js/tracing/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,18 @@ import type { SerializedTraceData } from '@sentry/core';

import { debug, getTraceData, objectToBaggageHeader, parseBaggageHeader } from '@sentry/core';

import { registerFeatureMarker } from '../utils/featureMarkers';

const WEBVIEW_LABEL = 'Sentry WebView';

/**
* Name registered as a no-op integration when the WebView helper is used, so the
* feature flows through to `event.sdk.integrations` โ€” the channel Sentry uses to
* measure feature adoption across SDKs. Kept in sync with the equivalent marker on
* the other SDKs (Apple, Android, Flutter).
*/
export const WEBVIEW_INTEGRATION_NAME = 'WebViewTracePropagation';

/**
* `baggage` key carrying the root span's name. It is free-form text and can
* contain user data (for example a parameterized URL), so it is removed from the
Expand Down Expand Up @@ -85,6 +95,12 @@ export function sentryWebViewProps(options: SentryWebViewOptions): {
injectedJavaScriptBeforeContentLoaded: string;
onMessage: () => void;
} {
// Record feature adoption. `sentryWebViewProps` is a helper rather than an
// integration, so without this it would be invisible to adoption telemetry. The
// marker is registered on use (not on injection success) so it reflects that the
// app wired up the feature. Idempotent and fails quietly โ€” never breaks rendering.
registerFeatureMarker(WEBVIEW_INTEGRATION_NAME);

return {
injectedJavaScriptBeforeContentLoaded: createSentryWebViewInjection(options),
// Required on Android: `react-native-webview` only runs injected JavaScript
Expand Down
65 changes: 63 additions & 2 deletions packages/core/test/tracing/webview.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { getTraceData } from '@sentry/core';
import { getClient, getTraceData } from '@sentry/core';

import { sentryWebViewProps } from '../../src/js/tracing/webview';
import { sentryWebViewProps, WEBVIEW_INTEGRATION_NAME } from '../../src/js/tracing/webview';

jest.mock('@sentry/core', () => {
const actual = jest.requireActual('@sentry/core');
return {
...actual,
getTraceData: jest.fn(),
getClient: jest.fn(),
};
});

const mockGetTraceData = getTraceData as jest.Mock;
const mockGetClient = getClient as jest.Mock;

const SENTRY_TRACE = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1';
const BAGGAGE = 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod';
Expand Down Expand Up @@ -407,3 +409,62 @@ describe('sentryWebViewProps', () => {
});
});
});

describe('sentryWebViewProps feature adoption marker', () => {
afterEach(() => {
mockGetClient.mockReset();
mockGetTraceData.mockReset();
});

it('registers the WebView feature marker on the client when used', () => {
const integrations: Record<string, { name: string }> = {};
mockGetClient.mockReturnValue({
getIntegrationByName: (name: string) => integrations[name],
addIntegration: (integration: { name: string }) => {
integrations[integration.name] = integration;
},
});
mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE, baggage: BAGGAGE });

sentryWebViewProps({ allowedHosts: ['example.com'] });

// The marker flows into `event.sdk.integrations`, the feature-adoption channel.
expect(integrations[WEBVIEW_INTEGRATION_NAME]).toEqual({ name: WEBVIEW_INTEGRATION_NAME });
});

it('registers the marker on use even when there is no active trace to inject', () => {
const addIntegration = jest.fn();
mockGetClient.mockReturnValue({ getIntegrationByName: () => undefined, addIntegration });
// No trace -> the injection is a no-op, but adoption is still recorded.
mockGetTraceData.mockReturnValue({});

sentryWebViewProps({ allowedHosts: ['example.com'] });

expect(addIntegration).toHaveBeenCalledWith({ name: WEBVIEW_INTEGRATION_NAME });
});

it('does not register twice (idempotent)', () => {
const addIntegration = jest.fn();
let registered = false;
mockGetClient.mockReturnValue({
getIntegrationByName: () => (registered ? { name: WEBVIEW_INTEGRATION_NAME } : undefined),
addIntegration: (integration: { name: string }) => {
registered = true;
addIntegration(integration);
},
});
mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE });

sentryWebViewProps({ allowedHosts: ['example.com'] });
sentryWebViewProps({ allowedHosts: ['example.com'] });

expect(addIntegration).toHaveBeenCalledTimes(1);
});

it('does not throw when no client is available', () => {
mockGetClient.mockReturnValue(undefined);
mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE });

expect(() => sentryWebViewProps({ allowedHosts: ['example.com'] })).not.toThrow();
});
});
Loading