Skip to content

Commit 84b4b04

Browse files
authored
Merge pull request #968 from DataDog/carlosnogueira/RUM-11519/babel-plugin-rum-action-tracking-config-options
[RUM-11519] Add RUM Action Tracking configuration options to the Babel Plugin
2 parents bd30e49 + 9ab4982 commit 84b4b04

File tree

14 files changed

+1259
-153
lines changed

14 files changed

+1259
-153
lines changed

packages/core/src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { TrackingConsent } from './TrackingConsent';
2424
import { DdLogs } from './logs/DdLogs';
2525
import { DdRum } from './rum/DdRum';
2626
import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking';
27+
import { __ddExtractText } from './rum/instrumentation/interactionTracking/ddBabelUtils';
2728
import { DatadogTracingContext } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingContext';
2829
import { DatadogTracingIdentifier } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier';
2930
import {
@@ -77,7 +78,8 @@ export {
7778
TracingIdFormat,
7879
DatadogTracingIdentifier,
7980
DatadogTracingContext,
80-
DdBabelInteractionTracking
81+
DdBabelInteractionTracking,
82+
__ddExtractText
8183
};
8284
export type {
8385
Timestamp,

packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ type BabelConfig = {
2626
};
2727

2828
type TargetObject = {
29-
compoenentName: string;
30-
'dd-action-name': string;
31-
accessibilityLabel: string;
32-
[key: string]: string;
29+
getContent: (() => string[]) | undefined;
30+
options: { useContent: boolean; useNamePrefix: boolean };
31+
handlerArgs: any[];
32+
componentName: string;
33+
'dd-action-name': string[];
34+
accessibilityLabel: string[];
35+
[key: string]: any;
3336
};
3437

3538
export class DdBabelInteractionTracking {
@@ -64,6 +67,9 @@ export class DdBabelInteractionTracking {
6467

6568
private getTargetName(targetObject: TargetObject) {
6669
const {
70+
getContent,
71+
options,
72+
handlerArgs,
6773
componentName,
6874
'dd-action-name': actionName,
6975
accessibilityLabel,
@@ -72,20 +78,44 @@ export class DdBabelInteractionTracking {
7278

7379
const { useAccessibilityLabel } = DdBabelInteractionTracking.config;
7480

75-
if (actionName) {
76-
return actionName;
77-
}
81+
const tryContent = () => {
82+
const content = getContent?.();
83+
if (content && content.length > 0) {
84+
return content;
85+
}
7886

79-
const keys = Object.keys(attrs);
80-
if (keys.length) {
81-
return attrs[keys[0]];
82-
}
87+
return null;
88+
};
8389

84-
if (useAccessibilityLabel && accessibilityLabel) {
85-
return accessibilityLabel;
90+
const getAccessibilityLabel = () =>
91+
useAccessibilityLabel && accessibilityLabel
92+
? accessibilityLabel
93+
: null;
94+
95+
const index = handlerArgs
96+
? handlerArgs.find(x => typeof x === 'number') || 0
97+
: 0;
98+
99+
// Order: content → actionName → actionNameAttribute → accessibilityLabel
100+
const selectedContent =
101+
tryContent() ||
102+
actionName ||
103+
Object.values(attrs)[0] ||
104+
getAccessibilityLabel();
105+
106+
if (!selectedContent) {
107+
return componentName;
86108
}
87109

88-
return componentName;
110+
// Fail-safe in case the our 'index' value turns out to not be a real index
111+
const output =
112+
index + 1 > selectedContent.length || index < 0
113+
? selectedContent[0]
114+
: selectedContent[index];
115+
116+
return options.useNamePrefix
117+
? `${componentName} ("${output}")`
118+
: output;
89119
}
90120

91121
wrapRumAction(
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import * as React from 'react';
2+
3+
type ExtractChild =
4+
| string
5+
| number
6+
| boolean
7+
| React.ReactElement
8+
| Iterable<React.ReactNode>
9+
| React.ReactPortal;
10+
11+
const LABEL_PROPS = ['children', 'label', 'title', 'text'];
12+
13+
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();
14+
15+
/**
16+
* Extracts readable text from arbitrary values commonly found in React trees.
17+
*
18+
* @param node - Any value: primitives, arrays, iterables, functions, or React elements.
19+
* @param prefer - Optional list of preferred values (e.g., title/label) to attempt first.
20+
* @returns Array of strings.
21+
*/
22+
export function __ddExtractText(node: any, prefer?: any[]): string[] {
23+
// If caller provided preferred values (title/label/etc.), use those first.
24+
if (Array.isArray(prefer)) {
25+
const preferred = prefer
26+
.flatMap(v => __ddExtractText(v)) // recurse so expressions/arrays work
27+
.map(normalize)
28+
.filter(Boolean);
29+
30+
if (preferred.length) {
31+
return preferred;
32+
}
33+
}
34+
35+
// Base cases
36+
if (node == null || typeof node === 'boolean') {
37+
return [];
38+
}
39+
40+
if (typeof node === 'string' || typeof node === 'number') {
41+
return [normalize(String(node))];
42+
}
43+
44+
// Arrays / iterables → flatten results (don’t concatenate yet)
45+
if (Array.isArray(node)) {
46+
return node
47+
.flatMap(x => __ddExtractText(x))
48+
.map(normalize)
49+
.filter(Boolean);
50+
}
51+
52+
if (typeof node === 'object' && Symbol.iterator in node) {
53+
return Array.from(node as Iterable<any>)
54+
.flatMap(x => __ddExtractText(x))
55+
.map(normalize)
56+
.filter(Boolean);
57+
}
58+
59+
// Zero-arg render prop
60+
if (typeof node === 'function' && node.length === 0) {
61+
try {
62+
return __ddExtractText(node());
63+
} catch {
64+
return [];
65+
}
66+
}
67+
68+
// React elements
69+
if (React.isValidElement(node)) {
70+
const props: any = (node as any).props ?? {};
71+
72+
// If the element itself has a direct label-ish prop, prefer it.
73+
for (const propKey of LABEL_PROPS) {
74+
if (propKey === 'children') {
75+
continue; // handle children below
76+
}
77+
78+
const propValue = props[propKey];
79+
if (propValue != null) {
80+
const got = __ddExtractText(propValue)
81+
.map(normalize)
82+
.filter(Boolean);
83+
84+
if (got.length) {
85+
return got;
86+
}
87+
}
88+
}
89+
90+
// Inspect children. Decide whether to return ONE joined label or MANY.
91+
const rawChildData = (Array.isArray(props.children)
92+
? props.children
93+
: [props.children]) as ExtractChild[];
94+
95+
const children = rawChildData.filter(c => c != null && c !== false);
96+
97+
if (children.length === 0) {
98+
return [];
99+
}
100+
101+
// Extract each child to a list of strings (not joined)
102+
const perChild = children.map(child => __ddExtractText(child));
103+
104+
// Heuristic: treat as *compound* if multiple children look like “items”
105+
// e.g., at least two direct children have a label-ish prop or yield non-empty text individually.
106+
let labeledChildCount = 0;
107+
children.forEach((child, i) => {
108+
let hasLabelProp = false;
109+
110+
if (React.isValidElement(child)) {
111+
const childProps: any = (child as any).props ?? {};
112+
hasLabelProp = LABEL_PROPS.some(k => childProps?.[k] != null);
113+
}
114+
115+
const childTextCount = perChild[i].filter(Boolean).length;
116+
if (hasLabelProp || childTextCount > 0) {
117+
labeledChildCount++;
118+
}
119+
});
120+
121+
const flat = perChild.flat().map(normalize).filter(Boolean);
122+
123+
// If there are multiple *direct* labelled children, return many (compound).
124+
// Otherwise, return a single joined label.
125+
if (labeledChildCount > 1) {
126+
// De-duplicate while preserving order
127+
const seen = new Set<string>();
128+
const out: string[] = [];
129+
130+
for (const str of flat) {
131+
const key = str;
132+
if (!seen.has(key)) {
133+
seen.add(key);
134+
out.push(str);
135+
}
136+
}
137+
return out;
138+
}
139+
140+
// Not “compound”: join everything into one readable string
141+
const joined = normalize(flat.join(' '));
142+
return joined ? [joined] : [];
143+
}
144+
145+
return [];
146+
}

packages/react-native-babel-plugin/README.md

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Babel Plugin for React Native
22

3-
The `@datadog/mobile-react-native-babel-plugin` enhances the Datadog React Native SDK by automatically enriching React components with contextual metadata. It helps improve the accuracy of features such as RUM event correlation, Session Replay, and UI tracking.
3+
The `@datadog/mobile-react-native-babel-plugin` enhances the Datadog React Native SDK by automatically enriching React components with contextual metadata. This helps improve the accuracy of features such as RUM Action tracking and Session Replay.
44

55
## Setup
66

7-
**Note**: Make sure youve already integrated the [Datadog React Native SDK][1].
7+
**Note**: Make sure you've already integrated the [Datadog React Native SDK][1].
88

99
To install with NPM, run:
1010

@@ -22,7 +22,7 @@ yarn add @datadog/mobile-react-native-babel-plugin
2222

2323
Add the plugin to your Babel configuration. Depending on your setup, you might be using a `babel.config.js`, `.babelrc`, or similar.
2424

25-
Example configuration:
25+
**Example configuration:**
2626

2727
```js
2828
module.exports = {
@@ -31,7 +31,53 @@ module.exports = {
3131
};
3232
```
3333

34-
If you are currently using `actionNameAttribute` in your datadog SDK configuration, you'll need to also specify it here:
34+
### Configuration Options
35+
36+
You can configure the plugin to adjust how it processes your code, giving you control over its behavior and allowing you to tailor it to your project’s needs.
37+
38+
#### Top-level options
39+
40+
| Option | Type | Default | Description |
41+
|-----------------------|--------|---------|-------------|
42+
| `actionNameAttribute` | string || The chosen attribute name to use for action names. |
43+
| `components` | object || Component tracking configuration. |
44+
45+
---
46+
47+
#### `components` options
48+
49+
| Option | Type | Default | Description |
50+
|-----------------|---------|---------|-------------|
51+
| `useContent` | boolean | true | Whether to use component content (for example: children, props) as the action name. |
52+
| `useNamePrefix` | boolean | true | Whether to prefix actions with the component name. |
53+
| `tracked` | array || List of component-specific tracking configs. |
54+
55+
---
56+
57+
#### `components.tracked[]` (per component)
58+
59+
Each entry in the `tracked` array is an object with the following shape:
60+
61+
| Option | Type | Default | Description |
62+
|-----------------|---------|----------------------|-------------|
63+
| `name` | string || The component name to track (e.g., `Button`). |
64+
| `useContent` | boolean | inherits from global | Override `useContent` for this component. |
65+
| `useNamePrefix` | boolean | inherits from global | Override `useNamePrefix` for this component. |
66+
| `contentProp` | string || Property name to use for content instead of children (for example: `"subTitle"`). |
67+
| `handlers` | array || List of event/action pairs to track. |
68+
69+
---
70+
71+
#### `components.tracked[].handlers[]`
72+
73+
| Field | Type | Description |
74+
|---------|--------|-------------|
75+
| `event` | string | The event name to intercept (such as `"onPress"`). |
76+
| `action`| string | The RUM action name to associate with this event. _(Only `"TAP"` actions are currently supported)_ |
77+
78+
---
79+
80+
**Example configuration (_using configuration options_):**
3581

3682
```js
3783
module.exports = {
@@ -40,12 +86,37 @@ module.exports = {
4086
[
4187
'@datadog/mobile-react-native-babel-plugin',
4288
{actionNameAttribute: 'custom-prop-value'},
89+
{
90+
components: {
91+
useContent: true,
92+
useNamePrefix: true,
93+
tracked: [
94+
{
95+
name: 'CustomButton',
96+
contentProp: 'text'
97+
handlers: [{event: 'onPress', action: 'TAP'}],
98+
},
99+
{
100+
name: 'CustomTextInput',
101+
handlers: [{event: 'onFocus', action: 'TAP'}],
102+
},
103+
{
104+
useNamePrefix: false,
105+
useContent: false,
106+
name: 'Tab',
107+
handlers: [{event: 'onChange', action: 'TAP'}],
108+
},
109+
],
110+
},
111+
},
43112
],
44113
],
45114
};
46115
```
47116

48-
For more recent React Native versions this should be all that is needed. However, if you're on an older version and using Typescript in your project, you may need to install the preset `@babel/preset-typescript`.
117+
## Troubleshooting
118+
119+
**Note**: If you're on an older React Native version, and using Typescript in your project, you may need to install the preset `@babel/preset-typescript`.
49120

50121
To install with NPM, run:
51122

0 commit comments

Comments
 (0)