diff --git a/docs/troubleshooting_no_data.md b/docs/troubleshooting_no_data.md index 646f839c7..dca2f8e11 100644 --- a/docs/troubleshooting_no_data.md +++ b/docs/troubleshooting_no_data.md @@ -18,8 +18,12 @@ it is only printed when `trackResources` is enabled, and its absence is the sing reason an app reports views but no API calls. (Both strings still carry the upstream name this SDK was forked from; grep for them verbatim.) -If neither line appears, initialization never ran: check that the provider is actually mounted, -and that no exception is being swallowed around it. +A third line, `Datadog SDK could not start `, means one instrumentation failed to +install. The others, and the native SDK itself, still start — you lose only that feature's +events. Section 3 covers the most common cause. + +If neither of the first two lines appears, initialization never ran: check that the provider is +actually mounted, and that no exception is being swallowed around it. ## 2. Views and crashes arrive, but no API calls @@ -43,7 +47,46 @@ in the tree as you can — the provider only covers what renders below it. See react-native-navigation (Wix) has no single React root to wrap, so it must keep the manual call. Put it at module scope in your entry file, before any screen is registered. -## 3. Resources arrive but are not linked to backend traces +## 3. Views and API calls arrive, but no tap actions + +Check whether your Babel config sets a custom `jsxImportSource`. nativewind does, and so does +any other styling library built on `react-native-css-interop`: + +```javascript +// babel.config.js +presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }]]; +``` + +With that in place your app's JSX no longer compiles to `react/jsx-runtime` — it compiles to +the library's runtime, which wraps React's element factories **while the bundle is evaluated**, +long before the SDK starts. Patching `react/jsx-runtime` afterwards can no longer reach your +elements, so no `onPress` is instrumented and no action is ever recorded. + +The SDK cannot require those modules itself: Metro resolves requires statically, so a +hard-coded one would break bundling for every app that does not depend on it. Pass them in: + +```javascript +import * as NativeWindJsxRuntime from 'nativewind/jsx-runtime'; + +config.jsxRuntimes = [NativeWindJsxRuntime]; +``` + +A runtime's factories may be exposed through accessors rather than plain properties, depending +on how the module was built and how your bundler models the import. The SDK replaces them +either way, and only gives up when the property is also non-configurable — a frozen ES module +namespace, for instance. That case is logged as: + +``` +Datadog SDK could not instrument a JSX runtime: its element factories are read-only. +No RUM action will be recorded for elements it renders. +``` + +There is no configuration that recovers from it: the factories have to be instrumented while +the app is built instead. Two things to try, in order — import the runtime with `require()` +rather than `import * as`, which skips the interop layer that may have frozen it, and if that +still fails, open an issue. Views, resources and errors are unaffected either way. + +## 4. Resources arrive but are not linked to backend traces Set `firstPartyHosts`. The SDK adds tracing headers only to requests whose host matches, so with it unset every resource is a dead end: @@ -56,7 +99,7 @@ Pass bare hosts, not URLs — no scheme, port or path. Also check `resourceTracingSamplingRate`, which defaults to `20`: at that value four out of five matching requests carry no tracing headers by design. -## 4. Nothing arrives at all +## 5. Nothing arrives at all - **Wrong destination.** `site` accepts `'CN'` (default) and `'STAGING'`. For a private deployment leave `site` alone and set `customEndpoints` to your own intake URLs instead; @@ -69,7 +112,7 @@ requests carry no tracing headers by design. - **Consent.** Nothing is collected under `TrackingConsent.NOT_GRANTED`, and events collected under `PENDING` are discarded unless consent is later granted. -## 5. Only in development +## 6. Only in development Two request kinds are filtered on purpose in dev builds, and only in dev builds: the Expo `/logs` endpoint and the React Native packager's `/symbolicate`. Both are noise from the diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index aa7e2ec36..44ac83ee4 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -40,6 +40,11 @@ import { GlobalState } from './sdk/GlobalState/GlobalState'; import { UserInfoSingleton } from './sdk/UserInfoSingleton/UserInfoSingleton'; import type { UserInfo } from './sdk/UserInfoSingleton/types'; import { DdSdkConfiguration } from './types'; +import { + getErrorMessage, + getErrorName, + getErrorStackTrace +} from './utils/errorUtils'; import { adaptLongTaskThreshold } from './utils/longTasksUtils'; import { version as sdkVersion } from './version'; @@ -359,6 +364,26 @@ export class DdSdkReactNative { ); }; + private static startFeature(name: string, start: () => void): void { + try { + start(); + } catch (error) { + InternalLog.log( + `Datadog SDK could not start ${name}: ${getErrorMessage( + error + )}`, + SdkVerbosity.ERROR + ); + DdSdk?.telemetryError?.( + `Failed to start ${name}: ${getErrorMessage(error)}`, + getErrorStackTrace(error), + getErrorName(error) + )?.catch(() => { + // reporting the failure must not become a second failure + }); + } + } + private static enableFeatures( configuration: AutoInstrumentationParameters ) { @@ -379,27 +404,43 @@ export class DdSdkReactNative { return; } + // Each feature is isolated: one of them failing to install must cost only its own + // events. Before this, an exception here propagated out of enableFeatures, and since + // _initializeFromDatadogProvider calls it before initializeNativeSDK - on a promise + // nobody awaits - it also silently aborted the native initialization, leaving the app + // with no RUM data at all rather than with one missing event type. if ( configuration.trackInteractions && !globalThis.__DD_RN_BABEL_PLUGIN_ENABLED__ ) { - DdRumUserInteractionTracking.startTracking({ - actionNameAttribute: configuration.actionNameAttribute, - useAccessibilityLabel: configuration.useAccessibilityLabel - }); + DdSdkReactNative.startFeature('interaction tracking', () => + DdRumUserInteractionTracking.startTracking( + { + actionNameAttribute: configuration.actionNameAttribute, + useAccessibilityLabel: + configuration.useAccessibilityLabel + }, + configuration.jsxRuntimes + ) + ); } if (configuration.trackResources) { - DdRumResourceTracking.startTracking({ - tracingSamplingRate: configuration.resourceTracingSamplingRate, - firstPartyHosts: formatFirstPartyHosts( - configuration.firstPartyHosts - ) - }); + DdSdkReactNative.startFeature('resource tracking', () => + DdRumResourceTracking.startTracking({ + tracingSamplingRate: + configuration.resourceTracingSamplingRate, + firstPartyHosts: formatFirstPartyHosts( + configuration.firstPartyHosts + ) + }) + ); } if (configuration.trackErrors) { - DdRumErrorTracking.startTracking(); + DdSdkReactNative.startFeature('error tracking', () => + DdRumErrorTracking.startTracking() + ); } if (configuration.logEventMapper) { diff --git a/packages/core/src/DdSdkReactNativeConfiguration.tsx b/packages/core/src/DdSdkReactNativeConfiguration.tsx index 272c3673a..6eee5ba79 100644 --- a/packages/core/src/DdSdkReactNativeConfiguration.tsx +++ b/packages/core/src/DdSdkReactNativeConfiguration.tsx @@ -11,6 +11,7 @@ import type { LogEventMapper } from './logs/types'; import type { ActionEventMapper } from './rum/eventMappers/actionEventMapper'; import type { ErrorEventMapper } from './rum/eventMappers/errorEventMapper'; import type { ResourceEventMapper } from './rum/eventMappers/resourceEventMapper'; +import type { JsxRuntimeModule } from './rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import type { FirstPartyHost } from './rum/types'; import { PropagatorType } from './rum/types'; @@ -126,6 +127,7 @@ export const DEFAULTS = { nativeViewTracking: false, nativeInteractionTracking: false, getFirstPartyHosts: () => [], + getJsxRuntimes: () => [], getAdditionalConfiguration: () => ({}), trackingConsent: TrackingConsent.GRANTED, telemetrySampleRate: 20.0, @@ -354,6 +356,23 @@ export class DdSdkReactNativeConfiguration { */ public actionNameAttribute?: string; + /** + * Additional JSX runtimes the app compiles its own JSX to, on top of React's. + * + * Set this whenever the app uses a custom `jsxImportSource` - nativewind and other + * css-interop based styling libraries do. Such a runtime wraps React's element factories + * while the bundle is evaluated, long before the SDK starts, so patching + * `react/jsx-runtime` afterwards no longer reaches the app's elements and no RUM action + * is ever recorded. The SDK cannot require these modules itself: Metro resolves requires + * statically, so a hard-coded one would break bundling for apps that do not depend on it. + * + * ```js + * import * as NativeWindJsxRuntime from 'nativewind/jsx-runtime'; + * config.jsxRuntimes = [NativeWindJsxRuntime]; + * ``` + */ + public jsxRuntimes: JsxRuntimeModule[] = DEFAULTS.getJsxRuntimes(); + public logEventMapper: LogEventMapper | null = DEFAULTS.logEventMapper; public errorEventMapper: ErrorEventMapper | null = @@ -399,6 +418,7 @@ export type AutoInstrumentationConfiguration = { readonly actionEventMapper?: ActionEventMapper | null; readonly useAccessibilityLabel?: boolean; readonly actionNameAttribute?: string; + readonly jsxRuntimes?: JsxRuntimeModule[]; }; /** @@ -416,6 +436,7 @@ export type AutoInstrumentationParameters = { readonly actionEventMapper: ActionEventMapper | null; readonly useAccessibilityLabel: boolean; readonly actionNameAttribute?: string; + readonly jsxRuntimes: JsxRuntimeModule[]; }; /** @@ -449,7 +470,8 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( features.actionEventMapper === undefined ? DEFAULTS.actionEventMapper : features.actionEventMapper, - useAccessibilityLabel: DEFAULTS.useAccessibilityLabel + useAccessibilityLabel: DEFAULTS.useAccessibilityLabel, + jsxRuntimes: features.jsxRuntimes || DEFAULTS.getJsxRuntimes() }; }; diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 4beaf8557..4d5a8bcad 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -43,6 +43,7 @@ describe('DdSdkReactNativeConfiguration', () => { "env": "fake-env", "errorEventMapper": null, "firstPartyHosts": [], + "jsxRuntimes": [], "logEventMapper": null, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -149,6 +150,7 @@ describe('DdSdkReactNativeConfiguration', () => { "api.com", ], "initialResourceThreshold": 0.123, + "jsxRuntimes": [], "logEventMapper": [Function], "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -229,6 +231,7 @@ describe('DdSdkReactNativeConfiguration', () => { "env": "", "errorEventMapper": null, "firstPartyHosts": [], + "jsxRuntimes": [], "logEventMapper": null, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx index d94d9bb82..4540c1a5d 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx @@ -444,6 +444,100 @@ describe('startTracking memoization', () => { }); }); +describe('startTracking with injected jsx runtimes', () => { + it('M wrap onPress W the app compiles JSX to another runtime', async () => { + const jsx = jest.fn((_type: any, props: any) => props); + const jsxs = jest.fn((_type: any, props: any) => props); + const runtime: Record = { jsx, jsxs }; + + DdRumUserInteractionTracking.startTracking({}, [runtime]); + + // both factories matter: a single-child element compiles to jsx, several to jsxs + expect(runtime.jsx).not.toBe(jsx); + expect(runtime.jsxs).not.toBe(jsxs); + + for (const key of ['jsx', 'jsxs']) { + const onPress = jest.fn(); + const props: Record = { onPress }; + (runtime[key] as any)('View', props); + + expect(props.onPress).not.toBe(onPress); + expect(props.__DATADOG_INTERNAL_ORIGINAL_ON_PRESS__).toBe(onPress); + props.onPress(); + expect(onPress).toHaveBeenCalledTimes(1); + } + }); + + it('M keep tracking W a runtime factory is read-only', async () => { + // nativewind's react-native-css-interop exposes its factories as getter-only + // properties; assigning to one throws, and that used to abort the whole SDK startup + const runtime = {}; + Object.defineProperty(runtime, 'jsx', { + get: () => () => null, + enumerable: true, + configurable: true + }); + + expect(() => + DdRumUserInteractionTracking.startTracking({}, [runtime]) + ).not.toThrow(); + expect(DdRumUserInteractionTracking['isTracking']).toBe(true); + }); + + it('M patch through an accessor W the property is configurable', async () => { + // a namespace object built by an interop helper can expose accessors rather than + // plain properties; assignment does nothing there, redefining still works + const runtime = {}; + const original = jest.fn(); + Object.defineProperty(runtime, 'jsx', { + get: () => original, + enumerable: true, + configurable: true + }); + + DdRumUserInteractionTracking.startTracking({}, [runtime]); + + expect((runtime as any).jsx).not.toBe(original); + + const onPress = jest.fn(); + const props: Record = { onPress }; + (runtime as any).jsx('View', props); + expect(props.onPress).not.toBe(onPress); + }); + + it('M say what it costs W a runtime cannot be patched at all', async () => { + const runtime = {}; + Object.defineProperty(runtime, 'jsx', { + get: () => jest.fn(), + enumerable: true, + configurable: false + }); + + expect(() => + DdRumUserInteractionTracking.startTracking({}, [runtime]) + ).not.toThrow(); + expect(DdRumUserInteractionTracking['isTracking']).toBe(true); + // the integrator must learn the consequence, not just that a property was skipped + expect(DdSdk.telemetryError).toHaveBeenCalledWith( + expect.stringContaining('No RUM action will be recorded'), + '', + 'JsxRuntimeNotPatchable' + ); + }); + + it('M restore the injected runtime W stopTracking is called', async () => { + const jsx = jest.fn(); + const jsxs = jest.fn(); + const runtime: Record = { jsx, jsxs }; + + DdRumUserInteractionTracking.startTracking({}, [runtime]); + DdRumUserInteractionTracking.stopTracking(); + + expect(runtime.jsx).toBe(jsx); + expect(runtime.jsxs).toBe(jsxs); + }); +}); + describe('startTracking', () => { /** * WARNING: Because of caching in the require, the following 2 tests need diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 9332354dc..7995f537b 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -24,6 +24,7 @@ import { TrackingConsent } from './TrackingConsent'; import { DdLogs } from './logs/DdLogs'; import { DdRum } from './rum/DdRum'; import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking'; +import type { JsxRuntimeModule } from './rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { __ddExtractText } from './rum/instrumentation/interactionTracking/ddBabelUtils'; import { DatadogTracingContext } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingContext'; import { DatadogTracingIdentifier } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier'; @@ -85,5 +86,6 @@ export type { Timestamp, FirstPartyHost, AutoInstrumentationConfiguration, - PartialInitializationConfiguration + PartialInitializationConfiguration, + JsxRuntimeModule }; diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx index 03fe98262..f78e7fd8f 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx @@ -20,6 +20,71 @@ import { NoOpEventsInterceptor } from './NoOpEventsInterceptor'; import { areObjectShallowEqual } from './ShallowObjectEqualityChecker'; import { getJsxRuntimes } from './getJsxRuntime'; +/** + * A JSX runtime: any module exposing `jsx` / `jsxs` / `jsxDEV` element factories. + * `react/jsx-runtime` is one; a module that wraps it is another. + */ +export type JsxRuntimeModule = Record; + +const JSX_FACTORY_KEYS = ['jsx', 'jsxs', 'jsxDEV'] as const; + +type JsxFactoryKey = typeof JSX_FACTORY_KEYS[number]; + +type PatchedRuntime = { + runtime: JsxRuntimeModule; + originals: Partial>; +}; + +/** + * Replaces a property, and reports whether it actually took. + * + * A host framework can expose its element factories through accessors rather than plain + * properties. Plain assignment then silently does nothing in sloppy mode and throws in strict + * mode, so this checks the result instead of trusting either, and falls back to redefining the + * property - which still works as long as it is configurable. + * + * This runs inside `enableFeatures`, where a throw used to take resource and error tracking + * down with it and, through `DatadogProvider`, abort the native initialization that follows. + * Auto-instrumentation is best-effort: failing to patch one factory must never be the reason + * the SDK does not start. + */ +const replaceProperty = ( + target: Record, + key: string, + value: unknown +): boolean => { + try { + target[key] = value; + if (target[key] === value) { + return true; + } + } catch (error) { + // accessor without a setter in strict mode - fall through to defineProperty + } + + try { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true + }); + if (target[key] === value) { + return true; + } + } catch (error) { + // non-configurable - nothing left to try + } + + InternalLog.log( + `Datadog SDK can't replace "${key}": the property is neither writable nor configurable`, + SdkVerbosity.WARN + ); + return false; +}; + +const reactModule = (React as unknown) as Record; + /** * Provides RUM auto-instrumentation feature to track user interaction as RUM events. * For now we are only covering the "onPress" events. @@ -29,8 +94,7 @@ export class DdRumUserInteractionTracking { private static eventsInterceptor: EventsInterceptor = new NoOpEventsInterceptor(); private static originalCreateElement = React.createElement; private static originalMemo = React.memo; - private static originalJsx = null; - private static originalDevJsx = null; + private static patchedRuntimes: PatchedRuntime[] = []; private static patchCreateElementFunction = ( originalFunction: typeof React.createElement, @@ -57,12 +121,90 @@ export class DdRumUserInteractionTracking { return originalFunction(element, props, ...rest); }; + /** + * Wraps every element factory a runtime exposes. + * + * All three keys matter: under the automatic JSX transform an element with a single child + * compiles to `jsx` and one with several children to `jsxs`, so patching only `jsx` leaves + * every multi-child element uninstrumented. `jsxDEV` lives on the dev runtime, which is a + * different module - handling the keys per runtime keeps install and uninstall symmetric. + */ + private static patchJsxRuntime = (runtime: JsxRuntimeModule): void => { + if (!runtime) { + return; + } + + const originals: PatchedRuntime['originals'] = {}; + let patchedAnyFactory = false; + let foundAnyFactory = false; + + for (const key of JSX_FACTORY_KEYS) { + const originalFactory = runtime[key]; + if (typeof originalFactory !== 'function') { + continue; + } + foundAnyFactory = true; + + const patchedFactory = ( + ...args: Parameters + ): ReturnType => + DdRumUserInteractionTracking.patchCreateElementFunction( + originalFactory as typeof React.createElement, + args + ); + + if (replaceProperty(runtime, key, patchedFactory)) { + originals[key] = originalFactory; + patchedAnyFactory = true; + } + } + + if (patchedAnyFactory) { + DdRumUserInteractionTracking.patchedRuntimes.push({ + runtime, + originals + }); + return; + } + + if (foundAnyFactory) { + // Saying only that a property could not be replaced leaves the integrator to work + // out what that costs them. Name the consequence: no action will be recorded for + // anything this runtime renders, and this is not something they can fix in + // configuration - the element factories have to be instrumented at build time + // instead. + const message = + 'Datadog SDK could not instrument a JSX runtime: its element factories are read-only. No RUM action will be recorded for elements it renders.'; + InternalLog.log(message, SdkVerbosity.ERROR); + DdSdk?.telemetryError?.( + message, + '', + 'JsxRuntimeNotPatchable' + )?.catch(() => { + // reporting the failure must not become a second failure + }); + } + }; + /** * Starts tracking user interactions and sends a RUM Action event every time a new interaction was detected. * Please note that we are only considering as valid - for - tracking only the user interactions that have * a visible output (either an UI state change or a Resource request) + * + * @param options interception options + * @param jsxRuntimes additional JSX runtimes the app compiles its own JSX to, on top of + * React's. Required whenever the app sets a custom `jsxImportSource` (nativewind and any + * other css-interop based styling library): such a runtime wraps React's factories at + * import time, which happens while the bundle is evaluated - long before this function + * runs - so patching `react/jsx-runtime` afterwards can no longer reach the app's elements. + * They cannot be required from here: Metro resolves requires statically, so a hard-coded + * `require('nativewind/jsx-runtime')` would break bundling for every app that does not + * depend on it. */ - static startTracking(options: DdEventsInterceptorOptions): void { + static startTracking( + options: DdEventsInterceptorOptions, + jsxRuntimes: JsxRuntimeModule[] = [] + ): void { // extra safety to avoid wrapping more than 1 time this function if (DdRumUserInteractionTracking.isTracking) { InternalLog.log( @@ -72,7 +214,7 @@ export class DdRumUserInteractionTracking { return; } - DdSdk?.sendTelemetryLog( + DdSdk?.sendTelemetryLog?.( BABEL_PLUGIN_TELEMETRY, DdBabelInteractionTracking.getTelemetryConfig(), { onlyOnce: true } @@ -82,74 +224,67 @@ export class DdRumUserInteractionTracking { options ); - const original = React.createElement; - React.createElement = ( - ...args: Parameters - ): any => { - return this.patchCreateElementFunction(original, args); - }; + const originalCreateElement = React.createElement; + replaceProperty( + reactModule, + 'createElement', + (...args: Parameters): any => { + return this.patchCreateElementFunction( + originalCreateElement, + args + ); + } + ); + const runtimes: JsxRuntimeModule[] = []; try { const [jsxRuntime, jsxDevRuntime] = getJsxRuntimes(); - const originalJsx = jsxRuntime?.jsx; - const originalDevJsx = jsxDevRuntime?.jsxDEV; - - this.originalJsx = originalJsx; - this.originalDevJsx = originalDevJsx; - - if (originalJsx) { - jsxRuntime.jsx = ( - ...args: Parameters - ): ReturnType => { - return this.patchCreateElementFunction(originalJsx, args); - }; + if (jsxRuntime) { + runtimes.push(jsxRuntime); } - - if (originalDevJsx) { - jsxRuntime.jsxDEV = ( - ...args: Parameters - ): ReturnType => { - return this.patchCreateElementFunction( - originalDevJsx, - args - ); - }; + if (jsxDevRuntime) { + runtimes.push(jsxDevRuntime); } } catch (e) { - DdSdk.telemetryDebug(getErrorMessage(e)); + DdSdk?.telemetryDebug?.(getErrorMessage(e)); } + runtimes.push(...jsxRuntimes); + runtimes.forEach(DdRumUserInteractionTracking.patchJsxRuntime); const originalMemo = React.memo; + replaceProperty( + reactModule, + 'memo', + ( + component: any, + propsAreEqual?: (prevProps: any, newProps: any) => boolean + ) => { + return originalMemo(component, (prev, next) => { + if (!next.onPress || !prev.onPress) { + return propsAreEqual + ? propsAreEqual(prev, next) + : areObjectShallowEqual(prev, next); + } + // we replace "our" onPress from the props by the original for comparison + const { onPress: _prevOnPress, ...partialPrevProps } = prev; + const prevProps = { + ...partialPrevProps, + onPress: prev.__DATADOG_INTERNAL_ORIGINAL_ON_PRESS__ + }; + + const { onPress: _nextOnPress, ...partialNextProps } = next; + const nextProps = { + ...partialNextProps, + onPress: next.__DATADOG_INTERNAL_ORIGINAL_ON_PRESS__ + }; - React.memo = ( - component: any, - propsAreEqual?: (prevProps: any, newProps: any) => boolean - ) => { - return originalMemo(component, (prev, next) => { - if (!next.onPress || !prev.onPress) { + // if no comparison function is provided we do shallow comparison return propsAreEqual - ? propsAreEqual(prev, next) - : areObjectShallowEqual(prev, next); - } - // we replace "our" onPress from the props by the original for comparison - const { onPress: _prevOnPress, ...partialPrevProps } = prev; - const prevProps = { - ...partialPrevProps, - onPress: prev.__DATADOG_INTERNAL_ORIGINAL_ON_PRESS__ - }; - - const { onPress: _nextOnPress, ...partialNextProps } = next; - const nextProps = { - ...partialNextProps, - onPress: next.__DATADOG_INTERNAL_ORIGINAL_ON_PRESS__ - }; - - // if no comparison function is provided we do shallow comparison - return propsAreEqual - ? propsAreEqual(prevProps, nextProps) - : areObjectShallowEqual(nextProps, prevProps); - }); - }; + ? propsAreEqual(prevProps, nextProps) + : areObjectShallowEqual(nextProps, prevProps); + }); + } + ); DdRumUserInteractionTracking.isTracking = true; InternalLog.log( @@ -158,18 +293,30 @@ export class DdRumUserInteractionTracking { ); } - static stopTracking() { - React.createElement = this.originalCreateElement; - React.memo = this.originalMemo; - DdRumUserInteractionTracking.isTracking = false; - if (this.originalJsx || this.originalDevJsx) { - const [jsxRuntime, jsxDevRuntime] = getJsxRuntimes(); - - jsxRuntime.jsx = this.originalJsx; - jsxDevRuntime.jsxDEV = this.originalDevJsx; + static stopTracking(): void { + replaceProperty( + reactModule, + 'createElement', + DdRumUserInteractionTracking.originalCreateElement + ); + replaceProperty( + reactModule, + 'memo', + DdRumUserInteractionTracking.originalMemo + ); - this.originalJsx = null; - this.originalDevJsx = null; + for (const { + runtime, + originals + } of DdRumUserInteractionTracking.patchedRuntimes) { + for (const key of JSX_FACTORY_KEYS) { + if (key in originals) { + replaceProperty(runtime, key, originals[key]); + } + } } + DdRumUserInteractionTracking.patchedRuntimes = []; + + DdRumUserInteractionTracking.isTracking = false; } } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts index f06a6258f..1f32393d7 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../../SdkVerbosity'; import { DdRum } from '../../../../../DdRum'; import { TracingIdFormat } from '../../../distributedTracing/TracingIdentifier'; import type { RUMResource } from '../../interfaces/RumResource'; @@ -29,7 +31,15 @@ export class ResourceReporter { } } - reportResource(modifiedResource); + // Returning the promise, and catching it, is what makes a failure on the way to + // DdRum.startResource observable at all: dropping it turned any rejection into a + // silent one, and the resource simply never showed up with nothing logged anywhere. + return reportResource(modifiedResource).catch(error => { + InternalLog.log( + `Error reporting RUM resource ${error}`, + SdkVerbosity.ERROR + ); + }); }; } diff --git a/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx b/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx index 61bdfc07c..378bd0afd 100644 --- a/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx +++ b/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx @@ -66,7 +66,19 @@ const initializeDatadog = async ( configuration: DatadogProviderConfiguration, onInitialization?: () => void ) => { - await DdSdkReactNative._initializeFromDatadogProvider(configuration); + try { + await DdSdkReactNative._initializeFromDatadogProvider(configuration); + } catch (error) { + // This promise is started during render and nobody awaits it, so without this the + // rejection leaves no trace at all - not even a console entry in a release build where + // the app strips console calls. A failure here means the SDK never initialized and no + // event will ever be sent, which is exactly the case worth shouting about. + InternalLog.log( + `Error initializing the Datadog SDK ${error}`, + SdkVerbosity.ERROR + ); + return; + } if (onInitialization) { try { onInitialization(); diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index a237bc4e6..e26a81a6e 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -8,7 +8,10 @@ import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { InitializationMode } from '../../../DdSdkReactNativeConfiguration'; +import { DdSdkReactNative } from '../../../DdSdkReactNative'; import { DdRum } from '../../../rum/DdRum'; +import { DdRumErrorTracking } from '../../../rum/instrumentation/DdRumErrorTracking'; +import { DdRumUserInteractionTracking } from '../../../rum/instrumentation/interactionTracking/DdRumUserInteractionTracking'; import { RumActionType } from '../../../rum/types'; import { DdTrace } from '../../../trace/DdTrace'; import { DefaultTimeProvider } from '../../../utils/time-provider/DefaultTimeProvider'; @@ -48,6 +51,35 @@ describe('DatadogProvider', () => { (nowMock as any).mockReturnValue('timestamp_not_specified'); }); describe('initialization', () => { + it('initializes the native SDK when an instrumentation fails to start', async () => { + // enableFeatures runs before initializeNativeSDK here, on a promise nobody awaits. + // An instrumentation throwing - nativewind makes the element factory read-only, + // which is one way to get there - used to abort the native initialization too, so + // the app ended up with no RUM data at all instead of one missing event type. + (DdSdkReactNative as any).wasAutoInstrumented = false; + const interactionTracking = jest + .spyOn(DdRumUserInteractionTracking, 'startTracking') + .mockImplementation(() => { + throw new Error('Cannot assign to read only property'); + }); + const errorTracking = jest.spyOn( + DdRumErrorTracking, + 'startTracking' + ); + + try { + renderWithProvider(); + await flushPromises(); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + // the features after the failing one still get installed + expect(errorTracking).toHaveBeenCalled(); + } finally { + interactionTracking.mockRestore(); + errorTracking.mockRestore(); + } + }); + it('renders its children and initializes the SDK once', async () => { const { getByText, diff --git a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts index f6066cb88..be5b870be 100644 --- a/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/sdk/FileBasedConfiguration/__tests__/FileBasedConfiguration.test.ts @@ -73,6 +73,7 @@ describe('FileBasedConfiguration', () => { }, ], "initializationMode": "SYNC", + "jsxRuntimes": [], "logEventMapper": null, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -126,6 +127,7 @@ describe('FileBasedConfiguration', () => { "errorEventMapper": null, "firstPartyHosts": [], "initializationMode": "SYNC", + "jsxRuntimes": [], "logEventMapper": null, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false,