Skip to content

Interaction tracking records nothing: the babel plugin's injected import makes Metro bundle a second copy of the SDK, so DdBabelInteractionTracking.config is set on a different class than instrumented handlers call #1377

Description

@jacobhummel-vetcove

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'] };
  1. Build Release, tap any Pressable. The handler runs, no action event is emitted.
  2. 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
  1. Remove the plugin from babel.config.js and re-bundle: module: 75, commonjs: 0.
  2. 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:

  1. Don't hold config and instance in per-build module statics, where a duplicated module silently disables the feature.
  2. Have the plugin's injected import resolve to the build the host app imports.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions