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

Open
#1,377 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
48/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
babel, react-native, typescript
Domain
analytics, mobile

Research direction

Start by reproducing the Expo bundle and source-map counts, then read packages/core/package.json, packages/core/src/DdSdkReactNative.tsx around line 529, and packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts around lines 36 and 137. Inspect the Babel plugin's injected import and verify which build it resolves to. Done means the app and instrumented handlers use one SDK copy and a Pressable emits an action without the Metro workaround.

Written by the indexing model from the issue text.

Description

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.

Dominant language
TypeScript
Stars
146
Forks
63
Avg merge
1d 10h
Merged PRs (30d)
11

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from DataDog/dd-sdk-reactnative

All issues in DataDog/dd-sdk-reactnative

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.