Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { __ddExtractText } from './rum/instrumentation/interactionTracking/ddBabelUtils';
import { DatadogTracingContext } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingContext';
import { DatadogTracingIdentifier } from './rum/instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier';
import {
Expand Down Expand Up @@ -77,7 +78,8 @@ export {
TracingIdFormat,
DatadogTracingIdentifier,
DatadogTracingContext,
DdBabelInteractionTracking
DdBabelInteractionTracking,
__ddExtractText
};
export type {
Timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ type BabelConfig = {
};

type TargetObject = {
compoenentName: string;
'dd-action-name': string;
accessibilityLabel: string;
[key: string]: string;
getContent: (() => string[]) | undefined;
options: { useContent: boolean; useNamePrefix: boolean };
handlerArgs: any[];
componentName: string;
'dd-action-name': string[];
accessibilityLabel: string[];
[key: string]: any;
};

export class DdBabelInteractionTracking {
Expand Down Expand Up @@ -64,6 +67,9 @@ export class DdBabelInteractionTracking {

private getTargetName(targetObject: TargetObject) {
const {
getContent,
options,
handlerArgs,
componentName,
'dd-action-name': actionName,
accessibilityLabel,
Expand All @@ -72,20 +78,44 @@ export class DdBabelInteractionTracking {

const { useAccessibilityLabel } = DdBabelInteractionTracking.config;

if (actionName) {
return actionName;
}
const tryContent = () => {
const content = getContent?.();
if (content && content.length > 0) {
return content;
}

const keys = Object.keys(attrs);
if (keys.length) {
return attrs[keys[0]];
}
return null;
};

if (useAccessibilityLabel && accessibilityLabel) {
return accessibilityLabel;
const getAccessibilityLabel = () =>
useAccessibilityLabel && accessibilityLabel
? accessibilityLabel
: null;

const index = handlerArgs
? handlerArgs.find(x => typeof x === 'number') || 0
: 0;

// Order: content → actionName → actionNameAttribute → accessibilityLabel
const selectedContent =
tryContent() ||
actionName ||
Object.values(attrs)[0] ||
getAccessibilityLabel();

if (!selectedContent) {
return componentName;
}

return componentName;
// Fail-safe in case the our 'index' value turns out to not be a real index
const output =
index + 1 > selectedContent.length || index < 0
? selectedContent[0]
: selectedContent[index];

return options.useNamePrefix
? `${componentName} ("${output}")`
: output;
}

wrapRumAction(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from 'react';

type ExtractChild =
| string
| number
| boolean
| React.ReactElement
| Iterable<React.ReactNode>
| React.ReactPortal;

const LABEL_PROPS = ['children', 'label', 'title', 'text'];

const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();

/**
* Extracts readable text from arbitrary values commonly found in React trees.
*
* @param node - Any value: primitives, arrays, iterables, functions, or React elements.
* @param prefer - Optional list of preferred values (e.g., title/label) to attempt first.
* @returns Array of strings.
*/
export function __ddExtractText(node: any, prefer?: any[]): string[] {
// If caller provided preferred values (title/label/etc.), use those first.
if (Array.isArray(prefer)) {
const preferred = prefer
.flatMap(v => __ddExtractText(v)) // recurse so expressions/arrays work
.map(normalize)
.filter(Boolean);

if (preferred.length) {
return preferred;
}
}

// Base cases
if (node == null || typeof node === 'boolean') {
return [];
}

if (typeof node === 'string' || typeof node === 'number') {
return [normalize(String(node))];
}

// Arrays / iterables → flatten results (don’t concatenate yet)
if (Array.isArray(node)) {
return node
.flatMap(x => __ddExtractText(x))
.map(normalize)
.filter(Boolean);
}

if (typeof node === 'object' && Symbol.iterator in node) {
return Array.from(node as Iterable<any>)
.flatMap(x => __ddExtractText(x))
.map(normalize)
.filter(Boolean);
}

// Zero-arg render prop
if (typeof node === 'function' && node.length === 0) {
try {
return __ddExtractText(node());
} catch {
return [];
}
}

// React elements
if (React.isValidElement(node)) {
const props: any = (node as any).props ?? {};

// If the element itself has a direct label-ish prop, prefer it.
for (const propKey of LABEL_PROPS) {
if (propKey === 'children') {
continue; // handle children below
}

const propValue = props[propKey];
if (propValue != null) {
const got = __ddExtractText(propValue)
.map(normalize)
.filter(Boolean);

if (got.length) {
return got;
}
}
}

// Inspect children. Decide whether to return ONE joined label or MANY.
const rawChildData = (Array.isArray(props.children)
? props.children
: [props.children]) as ExtractChild[];

const children = rawChildData.filter(c => c != null && c !== false);

if (children.length === 0) {
return [];
}

// Extract each child to a list of strings (not joined)
const perChild = children.map(child => __ddExtractText(child));

// Heuristic: treat as *compound* if multiple children look like “items”
// e.g., at least two direct children have a label-ish prop or yield non-empty text individually.
let labeledChildCount = 0;
children.forEach((child, i) => {
let hasLabelProp = false;

if (React.isValidElement(child)) {
const childProps: any = (child as any).props ?? {};
hasLabelProp = LABEL_PROPS.some(k => childProps?.[k] != null);
}

const childTextCount = perChild[i].filter(Boolean).length;
if (hasLabelProp || childTextCount > 0) {
labeledChildCount++;
}
});

const flat = perChild.flat().map(normalize).filter(Boolean);

// If there are multiple *direct* labelled children, return many (compound).
// Otherwise, return a single joined label.
if (labeledChildCount > 1) {
// De-duplicate while preserving order
const seen = new Set<string>();
const out: string[] = [];

for (const str of flat) {
const key = str;
if (!seen.has(key)) {
seen.add(key);
out.push(str);
}
}
return out;
}

// Not “compound”: join everything into one readable string
const joined = normalize(flat.join(' '));
return joined ? [joined] : [];
}

return [];
}
81 changes: 76 additions & 5 deletions packages/react-native-babel-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Babel Plugin for React Native

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.
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.

## Setup

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

To install with NPM, run:

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

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

Example configuration:
**Example configuration:**

```js
module.exports = {
Expand All @@ -31,7 +31,53 @@ module.exports = {
};
```

If you are currently using `actionNameAttribute` in your datadog SDK configuration, you'll need to also specify it here:
### Configuration Options

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.

#### Top-level options

| Option | Type | Default | Description |
|-----------------------|--------|---------|-------------|
| `actionNameAttribute` | string | – | The chosen attribute name to use for action names. |
| `components` | object | – | Component tracking configuration. |

---

#### `components` options

| Option | Type | Default | Description |
|-----------------|---------|---------|-------------|
| `useContent` | boolean | true | Whether to use component content (for example: children, props) as the action name. |
| `useNamePrefix` | boolean | true | Whether to prefix actions with the component name. |
| `tracked` | array | – | List of component-specific tracking configs. |

---

#### `components.tracked[]` (per component)

Each entry in the `tracked` array is an object with the following shape:

| Option | Type | Default | Description |
|-----------------|---------|----------------------|-------------|
| `name` | string | – | The component name to track (e.g., `Button`). |
| `useContent` | boolean | inherits from global | Override `useContent` for this component. |
| `useNamePrefix` | boolean | inherits from global | Override `useNamePrefix` for this component. |
| `contentProp` | string | – | Property name to use for content instead of children (for example: `"subTitle"`). |
| `handlers` | array | – | List of event/action pairs to track. |

---

#### `components.tracked[].handlers[]`

| Field | Type | Description |
|---------|--------|-------------|
| `event` | string | The event name to intercept (such as `"onPress"`). |
| `action`| string | The RUM action name to associate with this event. _(Only `"TAP"` actions are currently supported)_ |

---

**Example configuration (_using configuration options_):**

```js
module.exports = {
Expand All @@ -40,12 +86,37 @@ module.exports = {
[
'@datadog/mobile-react-native-babel-plugin',
{actionNameAttribute: 'custom-prop-value'},
{
components: {
useContent: true,
useNamePrefix: true,
tracked: [
{
name: 'CustomButton',
contentProp: 'text'
handlers: [{event: 'onPress', action: 'TAP'}],
},
{
name: 'CustomTextInput',
handlers: [{event: 'onFocus', action: 'TAP'}],
},
{
useNamePrefix: false,
useContent: false,
name: 'Tab',
handlers: [{event: 'onChange', action: 'TAP'}],
},
],
},
},
],
],
};
```

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`.
## Troubleshooting

**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`.

To install with NPM, run:

Expand Down
Loading
Loading