From f3c7a61fbed9e9d5f60292a1d4a2d0fd89cef1b1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:30:06 -0700 Subject: [PATCH 1/4] fix(core): instrument the JSX runtime the app actually compiles to Interaction tracking wrapped `React.createElement` and `react/jsx-runtime`'s `jsx`, which is only correct when the app compiles JSX to React's own runtime. An app that sets a custom `jsxImportSource` - anything built on react-native-css-interop does - compiles to that library's runtime instead, and that runtime captures React's factories while the bundle is evaluated, long before the SDK starts. Patching `react/jsx-runtime` afterwards can no longer reach the app's elements, so no onPress was wrapped and no action was ever recorded. Such a runtime cannot be required from here: Metro resolves requires statically, so a hard-coded require would break bundling for every app that does not depend on it. The app passes its runtimes in through the new `jsxRuntimes` configuration instead, and each one is patched the same way React's is. This drops the assumption that the JSX runtime is necessarily React's rather than special-casing one library. Three related defects, all in the same code path: - `jsxs` was never patched at all. Under the automatic transform an element with a single child compiles to `jsx` and one with several to `jsxs`, so every multi-child element went uninstrumented even without a custom runtime. Both keys, and `jsxDEV`, are now handled per runtime, which also makes install and uninstall symmetric - `jsxDEV` used to be written to the production runtime and restored from the dev one. - Assignments to the element factories are guarded: a host can expose them as getter-only properties, and the resulting TypeError propagated out of `startTracking`. Failing to patch one factory now logs and moves on. - `telemetryDebug` and `sendTelemetryLog` are called through optional call syntax. The object check alone still threw when the native module exposed no such method, which in the catch block meant the handler itself raised a second error. --- docs/troubleshooting_no_data.md | 42 ++- .../src/DdSdkReactNativeConfiguration.tsx | 24 +- .../DdSdkReactNativeConfiguration.test.ts | 3 + .../DdRumUserInteractionTracking.test.tsx | 53 ++++ packages/core/src/index.tsx | 4 +- .../DdRumUserInteractionTracking.tsx | 257 +++++++++++++----- .../__tests__/FileBasedConfiguration.test.ts | 2 + 7 files changed, 306 insertions(+), 79 deletions(-) diff --git a/docs/troubleshooting_no_data.md b/docs/troubleshooting_no_data.md index 646f839c7..b36bb703e 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,35 @@ 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]; +``` + +Their factories are sometimes exposed as getter-only properties. The SDK detects that, skips +them, and logs `Datadog SDK can't patch "jsx"` rather than failing — so check the internal logs +here as well. + +## 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 +88,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 +101,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/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..946e9c12a 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx @@ -444,6 +444,59 @@ 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 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..e9f3d2f72 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx @@ -20,6 +20,58 @@ 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>; +}; + +/** + * Assigns a property, but only when the host lets us. + * + * A host framework can expose its element factories as getter-only properties - nativewind's + * `react-native-css-interop` does exactly that - and assigning to one throws a TypeError in + * strict mode. This code runs inside `enableFeatures`, so such a throw used to take resource + * and error tracking down with it, and through `DatadogProvider` it aborted the native + * initialization that follows, leaving the app with no RUM data at all. Auto-instrumentation + * is best-effort: failing to patch one factory must never be the reason the SDK does not start. + */ +const assignIfWritable = ( + target: Record, + key: string, + value: unknown +): boolean => { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor && !descriptor.writable && !descriptor.set) { + InternalLog.log( + `Datadog SDK can't patch "${key}": the property is read-only, the host framework likely owns it`, + SdkVerbosity.WARN + ); + return false; + } + try { + target[key] = value; + return true; + } catch (error) { + InternalLog.log( + `Datadog SDK can't patch "${key}": ${getErrorMessage(error)}`, + 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 +81,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 +108,69 @@ 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; + + for (const key of JSX_FACTORY_KEYS) { + const originalFactory = runtime[key]; + if (typeof originalFactory !== 'function') { + continue; + } + + const patchedFactory = ( + ...args: Parameters + ): ReturnType => + DdRumUserInteractionTracking.patchCreateElementFunction( + originalFactory as typeof React.createElement, + args + ); + + if (assignIfWritable(runtime, key, patchedFactory)) { + originals[key] = originalFactory; + patchedAnyFactory = true; + } + } + + if (patchedAnyFactory) { + DdRumUserInteractionTracking.patchedRuntimes.push({ + runtime, + originals + }); + } + }; + /** * 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 +180,7 @@ export class DdRumUserInteractionTracking { return; } - DdSdk?.sendTelemetryLog( + DdSdk?.sendTelemetryLog?.( BABEL_PLUGIN_TELEMETRY, DdBabelInteractionTracking.getTelemetryConfig(), { onlyOnce: true } @@ -82,74 +190,67 @@ export class DdRumUserInteractionTracking { options ); - const original = React.createElement; - React.createElement = ( - ...args: Parameters - ): any => { - return this.patchCreateElementFunction(original, args); - }; + const originalCreateElement = React.createElement; + assignIfWritable( + 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; + assignIfWritable( + 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 +259,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 { + assignIfWritable( + reactModule, + 'createElement', + DdRumUserInteractionTracking.originalCreateElement + ); + assignIfWritable( + 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) { + assignIfWritable(runtime, key, originals[key]); + } + } } + DdRumUserInteractionTracking.patchedRuntimes = []; + + DdRumUserInteractionTracking.isTracking = false; } } 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, From 716121d271a7442cc70fde7e662f0a571f9d931b Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:30:16 -0700 Subject: [PATCH 2/4] fix(core): keep a failing instrumentation from aborting SDK startup `enableFeatures` installed the three auto-instrumentations in sequence with no protection, and `_initializeFromDatadogProvider` calls it *before* `initializeNativeSDK`. So an exception while installing the first one did not merely cost that feature's events: it skipped the other two and aborted the native initialization that followed, and the app got no RUM data whatsoever - not even the buffered `DdRum` calls, which never flushed because the SDK never came up. The legacy `initialize()` path installs the features after the native SDK, so the same exception there left views and errors working while silently dropping all three instrumentations. One cause, two very different-looking symptoms, neither of them pointing at the instrumentation that actually failed. Each feature now installs independently and reports its own failure through InternalLog and telemetry. Instrumentation is best-effort; it must never decide whether the SDK starts. `DatadogProvider` also catches the initialization promise it starts during render. Nothing awaited it, so a rejection left no trace at all - and in a release build that strips console calls, not even that. --- packages/core/src/DdSdkReactNative.tsx | 63 +++++++++++++++---- .../sdk/DatadogProvider/DatadogProvider.tsx | 14 ++++- .../__tests__/initialization.test.tsx | 32 ++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) 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/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, From c751b5e01c023578fa4b4291180d9ecc9c2986b7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:30:23 -0700 Subject: [PATCH 3/4] fix(core): stop discarding the promise that reports a RUM resource `ResourceReporter#reportResource` called the module-level reporter and dropped the promise it returned. Any rejection on the way to `DdRum.startResource` was therefore unhandled and invisible: the resource simply never appeared, with nothing logged and no way for an integrator to tell a swallowed error from a request the proxy never saw. Return the promise and log the failure. --- .../XHRProxy/DatadogRumResource/ResourceReporter.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 + ); + }); }; } From dac4b27e504d56cbb3df5577459dbfb27ab90094 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 21:18:15 -0700 Subject: [PATCH 4/4] fix(core): replace factories through accessors, and name the cost when it fails Checking `writable` before assigning was too narrow. A module namespace can expose its element factories through accessors rather than plain properties - which of the two you get depends on how the module was built and how the bundler models the import - and assignment to one silently does nothing in sloppy mode. Verify the result instead of trusting the descriptor, then fall back to redefining the property, which still works as long as it is configurable. When even that fails, the log said only that a property could not be replaced, leaving the integrator to work out what it cost them. Say the consequence instead: no RUM action will be recorded for anything that runtime renders, and no configuration recovers from it. Report it through telemetry too, so it is visible without a debug build. Documents the dead end rather than leaving it to be discovered, and covers both the accessor and the non-configurable case with tests. --- docs/troubleshooting_no_data.md | 17 +++- .../DdRumUserInteractionTracking.test.tsx | 41 +++++++++ .../DdRumUserInteractionTracking.tsx | 90 +++++++++++++------ 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/docs/troubleshooting_no_data.md b/docs/troubleshooting_no_data.md index b36bb703e..dca2f8e11 100644 --- a/docs/troubleshooting_no_data.md +++ b/docs/troubleshooting_no_data.md @@ -71,9 +71,20 @@ import * as NativeWindJsxRuntime from 'nativewind/jsx-runtime'; config.jsxRuntimes = [NativeWindJsxRuntime]; ``` -Their factories are sometimes exposed as getter-only properties. The SDK detects that, skips -them, and logs `Datadog SDK can't patch "jsx"` rather than failing — so check the internal logs -here as well. +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 diff --git a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx index 946e9c12a..4540c1a5d 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdRumUserInteractionTracking.test.tsx @@ -484,6 +484,47 @@ describe('startTracking with injected jsx runtimes', () => { 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(); diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx index e9f3d2f72..f78e7fd8f 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdRumUserInteractionTracking.tsx @@ -36,38 +36,51 @@ type PatchedRuntime = { }; /** - * Assigns a property, but only when the host lets us. + * Replaces a property, and reports whether it actually took. * - * A host framework can expose its element factories as getter-only properties - nativewind's - * `react-native-css-interop` does exactly that - and assigning to one throws a TypeError in - * strict mode. This code runs inside `enableFeatures`, so such a throw used to take resource - * and error tracking down with it, and through `DatadogProvider` it aborted the native - * initialization that follows, leaving the app with no RUM data at all. Auto-instrumentation - * is best-effort: failing to patch one factory must never be the reason the SDK does not start. + * 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 assignIfWritable = ( +const replaceProperty = ( target: Record, key: string, value: unknown ): boolean => { - const descriptor = Object.getOwnPropertyDescriptor(target, key); - if (descriptor && !descriptor.writable && !descriptor.set) { - InternalLog.log( - `Datadog SDK can't patch "${key}": the property is read-only, the host framework likely owns it`, - SdkVerbosity.WARN - ); - return false; - } try { target[key] = value; - return true; + if (target[key] === value) { + return true; + } } catch (error) { - InternalLog.log( - `Datadog SDK can't patch "${key}": ${getErrorMessage(error)}`, - SdkVerbosity.WARN - ); - return false; + // 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; @@ -123,12 +136,14 @@ export class DdRumUserInteractionTracking { 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 @@ -138,7 +153,7 @@ export class DdRumUserInteractionTracking { args ); - if (assignIfWritable(runtime, key, patchedFactory)) { + if (replaceProperty(runtime, key, patchedFactory)) { originals[key] = originalFactory; patchedAnyFactory = true; } @@ -149,6 +164,25 @@ export class DdRumUserInteractionTracking { 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 + }); } }; @@ -191,7 +225,7 @@ export class DdRumUserInteractionTracking { ); const originalCreateElement = React.createElement; - assignIfWritable( + replaceProperty( reactModule, 'createElement', (...args: Parameters): any => { @@ -218,7 +252,7 @@ export class DdRumUserInteractionTracking { runtimes.forEach(DdRumUserInteractionTracking.patchJsxRuntime); const originalMemo = React.memo; - assignIfWritable( + replaceProperty( reactModule, 'memo', ( @@ -260,12 +294,12 @@ export class DdRumUserInteractionTracking { } static stopTracking(): void { - assignIfWritable( + replaceProperty( reactModule, 'createElement', DdRumUserInteractionTracking.originalCreateElement ); - assignIfWritable( + replaceProperty( reactModule, 'memo', DdRumUserInteractionTracking.originalMemo @@ -277,7 +311,7 @@ export class DdRumUserInteractionTracking { } of DdRumUserInteractionTracking.patchedRuntimes) { for (const key of JSX_FACTORY_KEYS) { if (key in originals) { - assignIfWritable(runtime, key, originals[key]); + replaceProperty(runtime, key, originals[key]); } } }