Skip to content

Commit 01d5852

Browse files
authored
feat(rn): track custom events (#2705)
* feat(rn): add custom events * chore(rn): update custom event tracking * chore(ios): update attribute validation logic
1 parent 1c9f323 commit 01d5852

19 files changed

+521
-56
lines changed

react-native/example/expo/src/HomeScreen.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ const simulateInfiniteLoop = () => {
4949
while (true) {}
5050
};
5151

52+
const trackCustomEvent = () => {
53+
Measure.trackEvent('button_click', {
54+
screen: 'Home',
55+
action: 'Track Custom Event',
56+
timestamped: true,
57+
objecttype: { id: '12345', name: 'Test Object' },
58+
arraytype: ['value1', 'value2'],
59+
});
60+
console.log('Custom event tracked: button_click');
61+
};
62+
5263
export default function HomeScreen() {
5364
const navigation = useNavigation<HomeScreenNavigationProp>();
5465

@@ -70,7 +81,7 @@ export default function HomeScreen() {
7081
{
7182
id: 'event',
7283
title: 'Track Custom Event',
73-
onPress: () => console.log('Event pressed'),
84+
onPress: trackCustomEvent,
7485
},
7586
],
7687
},
@@ -153,4 +164,4 @@ const styles = StyleSheet.create({
153164
fontSize: 16,
154165
textAlign: 'left',
155166
},
156-
});
167+
});

react-native/example/rnExample/App.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ const App = (): React.JSX.Element => {
6565
.catch((error: any) => console.error('Failed to stop Measure SDK:', error));
6666
};
6767

68+
const trackCustomEvent = () => {
69+
Measure.trackEvent('button_click', {
70+
screen: 'Home',
71+
action: 'Track Custom Event',
72+
timestamped: true,
73+
});
74+
};
75+
6876
/** === Simulation Helpers === */
6977
const simulateJSException = () => {
7078
throw new Error('Simulated JavaScript exception');
@@ -98,7 +106,12 @@ const App = (): React.JSX.Element => {
98106
{
99107
id: 'event',
100108
title: 'Track Custom Event',
101-
onPress: () => console.log('Event pressed'),
109+
onPress: () => trackCustomEvent(),
110+
},
111+
{
112+
id: 'crash',
113+
title: 'Simulate Crash',
114+
onPress: () => console.log('Simulate crash'),
102115
},
103116
],
104117
},

react-native/ios/MeasureModule.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,7 @@ class MeasureModule: NSObject, RCTBridgeModule {
6161
rejecter reject: @escaping RCTPromiseRejectBlock) {
6262
var mutableData = data as? [String: Any?] ?? [:]
6363

64-
// Convert userDefinedAttrs if needed (depends on your AttributeValue bridging)
65-
let userAttrs = userDefinedAttrs as? [String: Any?] ?? [:]
64+
let userAttrs = userDefinedAttrs.transformAttributes()
6665

6766
// Attachments mapping (depends on how you expose MsrAttachment from JS → native)
6867
let msrAttachments: [MsrAttachment] = [] // TODO: map properly later
@@ -72,12 +71,12 @@ class MeasureModule: NSObject, RCTBridgeModule {
7271
type: type as String,
7372
timestamp: timestamp.int64Value,
7473
attributes: attributes as? [String: Any?] ?? [:],
75-
userDefinedAttrs: userAttrs as? [String: AttributeValue] ?? [:],
74+
userDefinedAttrs: userAttrs,
7675
userTriggered: userTriggered,
7776
sessionId: sessionId as String?,
7877
threadName: threadName as String?,
7978
attachments: msrAttachments
8079
)
8180
resolve("Event tracked successfully")
82-
}
81+
}
8382
}

react-native/ios/NSDictionary+Extensions.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Measure
23

34
extension NSDictionary {
45
func decoded<T: Decodable>(as type: T.Type) -> T? {
@@ -13,4 +14,30 @@ extension NSDictionary {
1314
return nil
1415
}
1516
}
17+
18+
func transformAttributes() -> [String: AttributeValue] {
19+
guard let attributes = self as? [String: Any] else {
20+
return [:]
21+
}
22+
23+
var transformedAttributes: [String: AttributeValue] = [:]
24+
25+
for (key, value) in attributes {
26+
if let stringVal = value as? String {
27+
transformedAttributes[key] = .string(stringVal)
28+
} else if let boolVal = value as? Bool {
29+
transformedAttributes[key] = .boolean(boolVal)
30+
} else if let intVal = value as? Int {
31+
transformedAttributes[key] = .int(intVal)
32+
} else if let longVal = value as? Int64 {
33+
transformedAttributes[key] = .long(longVal)
34+
} else if let floatVal = value as? Float {
35+
transformedAttributes[key] = .float(floatVal)
36+
} else if let doubleVal = value as? Double {
37+
transformedAttributes[key] = .double(doubleVal)
38+
}
39+
}
40+
41+
return transformedAttributes
42+
}
1643
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { CustomEventCollector } from '../../events/customEventCollector';
2+
import { EventType } from '../../events/eventType';
3+
import { validateAttributes } from '../../utils/attributeValueValidator';
4+
5+
jest.mock('../../native/measureBridge', () => ({
6+
trackEvent: jest.fn(),
7+
}));
8+
9+
const mockTrackEvent = require('../../native/measureBridge')
10+
.trackEvent as jest.Mock;
11+
12+
describe('CustomEventCollector', () => {
13+
let logger: { log: jest.Mock };
14+
let timeProvider: { now: jest.Mock };
15+
let configProvider: {
16+
maxEventNameLength: number;
17+
customEventNameRegex: string;
18+
};
19+
let collector: CustomEventCollector;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
logger = { log: jest.fn() };
24+
timeProvider = { now: jest.fn(() => 123456789) };
25+
configProvider = {
26+
maxEventNameLength: 10,
27+
customEventNameRegex: '^[a-zA-Z0-9_]+$',
28+
};
29+
collector = new CustomEventCollector({
30+
logger: logger as any,
31+
timeProvider: timeProvider as any,
32+
configProvider: configProvider as any,
33+
});
34+
collector.register();
35+
});
36+
37+
it('does nothing when disabled', async () => {
38+
collector.unregister();
39+
await collector.trackCustomEvent('test');
40+
expect(mockTrackEvent).not.toHaveBeenCalled();
41+
expect(logger.log).not.toHaveBeenCalled();
42+
});
43+
44+
it('logs error if name is empty', async () => {
45+
await collector.trackCustomEvent('');
46+
expect(logger.log).toHaveBeenCalledWith(
47+
'error',
48+
'Invalid event: name is empty'
49+
);
50+
expect(mockTrackEvent).not.toHaveBeenCalled();
51+
});
52+
53+
it('logs error if name is too long', async () => {
54+
await collector.trackCustomEvent('toolongnamehere');
55+
expect(logger.log).toHaveBeenCalledWith(
56+
'error',
57+
'Invalid event(toolongnamehere): exceeds maximum length of 10 characters'
58+
);
59+
expect(mockTrackEvent).not.toHaveBeenCalled();
60+
});
61+
62+
it('logs error if name fails regex', async () => {
63+
await collector.trackCustomEvent('bad-name!');
64+
expect(logger.log).toHaveBeenCalledWith(
65+
'error',
66+
'Invalid event(bad-name!) format'
67+
);
68+
expect(mockTrackEvent).not.toHaveBeenCalled();
69+
});
70+
71+
it('does not track event when attributes are invalid', async () => {
72+
const attrs = { good: 'ok', bad: { nested: true } } as any;
73+
74+
// Run the call — should detect invalid attributes and not track
75+
await collector.trackCustomEvent('event1', attrs, 111);
76+
77+
// Expect that native trackEvent was never called
78+
expect(mockTrackEvent).not.toHaveBeenCalled();
79+
80+
// Expect that an error was logged instead
81+
expect(logger.log).toHaveBeenCalledWith(
82+
'error',
83+
'Invalid attributes provided for event(event1). Dropping the event.'
84+
);
85+
});
86+
87+
it('logs error if nativeTrackEvent throws', async () => {
88+
mockTrackEvent.mockRejectedValueOnce(new Error('boom'));
89+
await collector.trackCustomEvent('event2');
90+
expect(logger.log).toHaveBeenCalledWith(
91+
'error',
92+
expect.stringContaining('Failed to track custom event event2')
93+
);
94+
});
95+
});

react-native/src/__tests__/exceptionBuilder.test.ts renamed to react-native/src/__tests__/exception/exceptionBuilder.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildExceptionPayload } from "../exception/exceptionBuilder";
1+
import { buildExceptionPayload } from "../../exception/exceptionBuilder";
22

33
describe("buildExceptionPayload", () => {
44
it("builds payload for Error object", () => {

react-native/src/__tests__/measureErrorHandlers.test.ts renamed to react-native/src/__tests__/exception/measureErrorHandlers.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { buildExceptionPayload } from "../exception/exceptionBuilder";
2-
import { setupErrorHandlers } from "../exception/measureErrorHandlers";
1+
import { buildExceptionPayload } from "../../exception/exceptionBuilder";
2+
import { setupErrorHandlers } from "../../exception/measureErrorHandlers";
33

4-
jest.mock("../exception/exceptionBuilder", () => ({
4+
jest.mock("../../exception/exceptionBuilder", () => ({
55
buildExceptionPayload: jest.fn(() => ({ fake: "payload" })),
66
}));
7-
jest.mock("../native/measureBridge", () => ({
7+
jest.mock("../../native/measureBridge", () => ({
88
trackEvent: jest.fn(() => Promise.resolve()),
99
}));
1010

react-native/src/__tests__/stacktraceParser.test.ts renamed to react-native/src/__tests__/exception/stacktraceParser.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseStacktrace } from "../exception/stacktraceParser";
1+
import { parseStacktrace } from "../../exception/stacktraceParser";
22

33
describe("parseStacktrace", () => {
44
it("parses a Chrome/V8 style stack trace", () => {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { validateAttributes } from '../../utils/attributeValueValidator';
2+
3+
describe('validateAttributes', () => {
4+
beforeEach(() => {
5+
jest.spyOn(console, 'warn').mockImplementation(() => {}); // silence dev warnings
6+
// @ts-ignore
7+
global.__DEV__ = true;
8+
});
9+
10+
afterEach(() => {
11+
jest.restoreAllMocks();
12+
});
13+
14+
it('should return true when all attributes are valid', () => {
15+
const input = {
16+
name: 'event',
17+
count: 42,
18+
enabled: false,
19+
};
20+
21+
const result = validateAttributes(input);
22+
expect(result).toBe(true);
23+
});
24+
25+
it('should return false when any attribute is invalid', () => {
26+
const input = {
27+
name: 'event',
28+
invalid: { nested: true },
29+
};
30+
31+
const result = validateAttributes(input);
32+
expect(result).toBe(false);
33+
});
34+
35+
it('should log a warning in dev mode for invalid attributes', () => {
36+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
37+
const input = {
38+
valid: 'ok',
39+
bad: null,
40+
};
41+
42+
const result = validateAttributes(input);
43+
44+
expect(result).toBe(false);
45+
expect(warnSpy).toHaveBeenCalledWith(
46+
expect.stringContaining('[MeasureRN] Invalid attribute'),
47+
null
48+
);
49+
});
50+
51+
it('should return true for an empty object', () => {
52+
const result = validateAttributes({});
53+
expect(result).toBe(true);
54+
});
55+
56+
it('should handle undefined attributes gracefully', () => {
57+
const result = validateAttributes({ key: undefined });
58+
expect(result).toBe(false);
59+
});
60+
61+
it('should return false for attributes with unsupported types (function, symbol)', () => {
62+
const input = {
63+
fn: () => {},
64+
sym: Symbol('test'),
65+
};
66+
67+
const result = validateAttributes(input);
68+
expect(result).toBe(false);
69+
});
70+
71+
it('should stop validation at the first invalid attribute', () => {
72+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
73+
const input = {
74+
valid1: 'ok',
75+
bad: [],
76+
valid2: 'should not be checked',
77+
};
78+
79+
const result = validateAttributes(input);
80+
expect(result).toBe(false);
81+
expect(warnSpy).toHaveBeenCalledTimes(1);
82+
});
83+
});

react-native/src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class Config implements InternalConfig, MeasureConfigInterface {
1414
httpUrlAllowlist: string[];
1515
autoStart: boolean;
1616
trackViewControllerLoadTime: boolean;
17+
customEventNameRegex: string;
1718

1819
constructor(
1920
enableLogging?: boolean,
@@ -38,6 +39,7 @@ export class Config implements InternalConfig, MeasureConfigInterface {
3839
this.autoStart = autoStart ?? DefaultConfig.autoStart;
3940
this.trackViewControllerLoadTime = trackViewControllerLoadTime ?? DefaultConfig.trackViewControllerLoadTime;
4041
this.maxEventNameLength = 64;
42+
this.customEventNameRegex = DefaultConfig.customEventNameRegex;
4143

4244
if (!(this.samplingRateForErrorFreeSessions >= 0 && this.samplingRateForErrorFreeSessions <= 1)) {
4345
console.warn('samplingRateForErrorFreeSessions must be between 0.0 and 1.0');

0 commit comments

Comments
 (0)