Describe the bug
With @datadog/mobile-react-native-babel-plugin enabled, Metro bundles @datadog/mobile-react-native twice: 75 modules from lib/module and 75 from lib/commonjs. packages/core/package.json keeps the builds separate (exports["."] maps import to ./lib/module/index.js and require to ./lib/commonjs/index.js), so DdBabelInteractionTracking becomes two classes with independent static state.
enableFeatures() assigns DdBabelInteractionTracking.config (DdSdkReactNative.tsx:529) on the copy the app imported. Instrumented handlers call getInstance().wrapRumAction() on the copy the plugin's injected import resolved to, where config is still the default trackInteractions: false (DdBabelInteractionTracking.ts:36). wrapRumAction reads that guard at L137, skips addAction, and falls through to func?.(...args).
No action is ever recorded. Views stay at action.count: 0, no @type:action events are produced, nothing errors or warns at any verbosity, and press handlers behave normally. Views, Session Replay, long tasks and crash reporting keep working, because those run through the configured copy.
A broken setup is therefore indistinguishable from a working one. Every obvious check reads healthy, because it reads the configured copy: globalThis.__DD_RN_BABEL_PLUGIN_ENABLED__ is true, config.trackInteractions is true, the instance has a ddRum, and calling wrapRumAction directly records an action correctly. Metro caching, flag load order, NativeWind's JSX runtime and tracking consent all look fine for the same reason.
Reproduction steps
Expo SDK 56 / RN 0.85.3, SDK and plugin installed per the Expo setup docs, trackInteractions: true, DatadogProvider at the root, SDK imported with ESM.
// babel.config.js
module.exports = { presets: ['babel-preset-expo'],
plugins: ['@datadog/mobile-react-native-babel-plugin'] };
- Build Release, tap any
Pressable. The handler runs, no action event is emitted.
- Count the copies:
npx expo export:embed --platform ios --dev false \
--bundle-output out.js --sourcemap-output out.map
node -e "const s=require('./out.map').sources;
console.log('module :', s.filter(x=>x.includes('mobile-react-native/lib/module/')).length);
console.log('commonjs:', s.filter(x=>x.includes('mobile-react-native/lib/commonjs/')).length)"
# module: 75 commonjs: 75
- Remove the plugin from
babel.config.js and re-bundle: module: 75, commonjs: 0.
- Patch
wrapRumAction on the instance from the app's own import inside onInitialization. A direct call records an action; a real press never reaches the patched method, while the handler still fires.
SDK logs
None. wrapRumAction returns at its trackInteractions guard before any logging or telemetry, so SdkVerbosity.DEBUG shows nothing and no BabelActionTrack telemetry fires. No output distinguishes this from "no interactions occurred".
Expected behavior
Instrumented handlers and enableFeatures() should share one DdBabelInteractionTracking. Any of:
- Don't hold
config and instance in per-build module statics, where a duplicated module silently disables the feature.
- Have the plugin's injected import resolve to the build the host app imports.
- Warn when
wrapRumAction runs against an instance whose config was never assigned, or when getInstance() is created without a ddRum.
The third alone would make this diagnosable without inspecting the bundle's module graph.
Affected SDK versions
2.14.7, plugin 2.14.9. 3.6.0 ships the same exports map and the same static-config pattern.
Latest working SDK version
None known. The trigger is the dual-build layout plus the plugin's injected import, not a regression.
Did you confirm if the latest SDK version fixes the bug?
No. Not verified at runtime on 3.x. By inspection, 3.6.0's packages/core/package.json has the same import/require split and config is still a class static, so we expect it to reproduce.
Integration Methods
NPM
React Native Version
0.85.3 (Expo SDK 56.0.13)
Platform
iOS (iPhone 17 Pro simulator, iOS 26.1, Release). The mechanism is JS bundling, so Android should behave the same.
Package.json Contents
"expo": "~56.0.13", "react-native": "0.85.3", "react": "19.2.3",
"@datadog/mobile-react-native": "^2.14.0",
"@datadog/mobile-react-native-babel-plugin": "^2.14.0",
"@datadog/mobile-react-native-session-replay": "^2.14.0",
"nativewind": "^4.2.6"
Other relevant information
| config |
lib/module |
lib/commonjs |
actions |
| plugin disabled |
75 |
0 |
n/a |
| plugin enabled |
75 |
75 |
none |
plugin enabled, transformer.experimentalImportSupport: true |
75 |
75 |
none |
plugin enabled, resolver pinned to lib/module |
75 |
0 |
yes |
plugin enabled, app imports SDK with require |
0 |
75 |
n/a |
The last row shows either build works in isolation. Matching the app's import style to the plugin's require also collapses the graph to one copy. We did not use that approach: require returns any so the SDK types are lost unless the import is split, it trips @typescript-eslint/no-require-imports, and it only works while Metro defaults experimentalImportSupport to false. If that default flips, the plugin emits import, resolves to lib/module, and tracking breaks again with no signal.
Ruled out: forcing ESM does not help, so we could not pin the exact resolution mechanism, only that the plugin's injected import is needed to produce the second copy. Removing our custom babelTransformerPath still gives 75/75. A cold Metro cache produces a byte-identical bundle. @datadog/mobile-react-native-session-replay resolves to its own src/*.ts and does not import core, so it is not a second entry point.
Workaround in metro.config.js:
const datadogRoot = path.dirname(require.resolve("@datadog/mobile-react-native/package.json"));
const datadogEntry = path.join(datadogRoot, "lib/module/index.js");
resolveRequest: (context, moduleName, platform) =>
moduleName === "@datadog/mobile-react-native"
? { type: "sourceFile", filePath: datadogEntry }
: context.resolveRequest(context, moduleName, platform)
Actions record immediately after this and Session Replay is unaffected.
Describe the bug
With
@datadog/mobile-react-native-babel-pluginenabled, Metro bundles@datadog/mobile-react-nativetwice: 75 modules fromlib/moduleand 75 fromlib/commonjs.packages/core/package.jsonkeeps the builds separate (exports["."]mapsimportto./lib/module/index.jsandrequireto./lib/commonjs/index.js), soDdBabelInteractionTrackingbecomes two classes with independent static state.enableFeatures()assignsDdBabelInteractionTracking.config(DdSdkReactNative.tsx:529) on the copy the app imported. Instrumented handlers callgetInstance().wrapRumAction()on the copy the plugin's injected import resolved to, whereconfigis still the defaulttrackInteractions: false(DdBabelInteractionTracking.ts:36).wrapRumActionreads that guard at L137, skipsaddAction, and falls through tofunc?.(...args).No action is ever recorded. Views stay at
action.count: 0, no@type:actionevents are produced, nothing errors or warns at any verbosity, and press handlers behave normally. Views, Session Replay, long tasks and crash reporting keep working, because those run through the configured copy.A broken setup is therefore indistinguishable from a working one. Every obvious check reads healthy, because it reads the configured copy:
globalThis.__DD_RN_BABEL_PLUGIN_ENABLED__is true,config.trackInteractionsis true, the instance has addRum, and callingwrapRumActiondirectly records an action correctly. Metro caching, flag load order, NativeWind's JSX runtime and tracking consent all look fine for the same reason.Reproduction steps
Expo SDK 56 / RN 0.85.3, SDK and plugin installed per the Expo setup docs,
trackInteractions: true,DatadogProviderat the root, SDK imported with ESM.Pressable. The handler runs, no action event is emitted.babel.config.jsand re-bundle:module: 75, commonjs: 0.wrapRumActionon the instance from the app's own import insideonInitialization. A direct call records an action; a real press never reaches the patched method, while the handler still fires.SDK logs
None.
wrapRumActionreturns at itstrackInteractionsguard before any logging or telemetry, soSdkVerbosity.DEBUGshows nothing and noBabelActionTracktelemetry fires. No output distinguishes this from "no interactions occurred".Expected behavior
Instrumented handlers and
enableFeatures()should share oneDdBabelInteractionTracking. Any of:configandinstancein per-build module statics, where a duplicated module silently disables the feature.wrapRumActionruns against an instance whoseconfigwas never assigned, or whengetInstance()is created without addRum.The third alone would make this diagnosable without inspecting the bundle's module graph.
Affected SDK versions
2.14.7, plugin 2.14.9. 3.6.0 ships the same exports map and the same static-config pattern.
Latest working SDK version
None known. The trigger is the dual-build layout plus the plugin's injected import, not a regression.
Did you confirm if the latest SDK version fixes the bug?
No. Not verified at runtime on 3.x. By inspection,
3.6.0'spackages/core/package.jsonhas the sameimport/requiresplit andconfigis still a class static, so we expect it to reproduce.Integration Methods
NPM
React Native Version
0.85.3 (Expo SDK 56.0.13)
Platform
iOS (iPhone 17 Pro simulator, iOS 26.1, Release). The mechanism is JS bundling, so Android should behave the same.
Package.json Contents
Other relevant information
lib/modulelib/commonjstransformer.experimentalImportSupport: truelib/modulerequireThe last row shows either build works in isolation. Matching the app's import style to the plugin's
requirealso collapses the graph to one copy. We did not use that approach:requirereturnsanyso the SDK types are lost unless the import is split, it trips@typescript-eslint/no-require-imports, and it only works while Metro defaultsexperimentalImportSupportto false. If that default flips, the plugin emitsimport, resolves tolib/module, and tracking breaks again with no signal.Ruled out: forcing ESM does not help, so we could not pin the exact resolution mechanism, only that the plugin's injected import is needed to produce the second copy. Removing our custom
babelTransformerPathstill gives 75/75. A cold Metro cache produces a byte-identical bundle.@datadog/mobile-react-native-session-replayresolves to its ownsrc/*.tsand does not import core, so it is not a second entry point.Workaround in
metro.config.js:Actions record immediately after this and Session Replay is unaffected.