Skip to content

Commit 1d79fca

Browse files
committed
support for custom with event names as methods
1 parent 44d27dc commit 1d79fca

File tree

12 files changed

+650
-119
lines changed

12 files changed

+650
-119
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ npx @flisk/analyze-tracking /path/to/project [options]
3939

4040
If you have your own in-house tracker or a wrapper function that calls other tracking libraries, you can specify the function signature with the `-c` or `--customFunction` option.
4141

42+
#### Standard Custom Function Format
43+
4244
Your function signature should be in the following format:
4345
```js
4446
yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES, customFieldOne, customFieldTwo)
@@ -57,11 +59,45 @@ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES)
5759

5860
If your function follows the standard format `yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES)`, you can simply pass in `yourCustomTrackFunctionName` to `--customFunction` as a shorthand.
5961

62+
#### Method-Name-as-Event Format
63+
64+
For tracking patterns where the method name itself is the event name (e.g., `yourClass.yourEventName({...})`), use the special `EVENT_NAME` placeholder in the method position:
65+
66+
```js
67+
yourClass.EVENT_NAME(PROPERTIES)
68+
```
69+
70+
This pattern tells the analyzer that:
71+
- `yourClass` is the object name to match
72+
- The method name after the dot (e.g., `viewItemList`, `addToCart`) is the event name
73+
- `PROPERTIES` is the properties object (defaults to the first argument if not specified)
74+
75+
**Example:**
76+
```typescript
77+
// Code in your project:
78+
yourClass.viewItemList({ items: [...] });
79+
yourClass.addToCart({ item: {...}, value: 100 });
80+
yourClass.purchase({ userId: '123', value: 100 });
81+
82+
// Command:
83+
npx @flisk/analyze-tracking /path/to/project --customFunction "yourClass.EVENT_NAME(PROPERTIES)"
84+
```
85+
86+
This will detect:
87+
- Event: `viewItemList` with properties from the first argument
88+
- Event: `addToCart` with properties from the first argument
89+
- Event: `purchase` with properties from the first argument
90+
91+
_**Note:** This pattern is currently only supported for JavaScript and TypeScript code._
92+
93+
#### Multiple Custom Functions
94+
6095
You can also pass in multiple custom function signatures by passing in the `--customFunction` option multiple times or by passing in a space-separated list of function signatures.
6196

6297
```sh
6398
npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
6499
npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
100+
npx @flisk/analyze-tracking /path/to/project -c "yourClass.EVENT_NAME(PROPERTIES)" "customTrack(EVENT_NAME, PROPERTIES)"
65101
```
66102

67103

src/analyze/javascript/detectors/analytics-source.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,25 @@ const { ANALYTICS_PROVIDERS, NODE_TYPES } = require('../constants');
88
/**
99
* Detects the analytics provider from a CallExpression node
1010
* @param {Object} node - AST CallExpression node
11-
* @param {string} [customFunction] - Custom function name to detect
11+
* @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
1212
* @returns {string} The detected analytics source or 'unknown'
1313
*/
14-
function detectAnalyticsSource(node, customFunction) {
14+
function detectAnalyticsSource(node, customFunctionOrConfig) {
1515
if (!node.callee) {
1616
return 'unknown';
1717
}
1818

1919
// Check for custom function first
20-
if (customFunction && isCustomFunction(node, customFunction)) {
20+
// Support both old string format and new config object format
21+
const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
22+
const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);
23+
24+
if (customConfig?.isMethodAsEvent) {
25+
// Method-as-event pattern: match any method on the specified object
26+
if (isMethodAsEventFunction(node, customConfig)) {
27+
return 'custom';
28+
}
29+
} else if (customFunction && isCustomFunction(node, customFunction)) {
2130
return 'custom';
2231
}
2332

@@ -36,6 +45,31 @@ function detectAnalyticsSource(node, customFunction) {
3645
return 'unknown';
3746
}
3847

48+
/**
49+
* Checks if the node matches a method-as-event custom function pattern
50+
* @param {Object} node - AST CallExpression node
51+
* @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
52+
* @returns {boolean}
53+
*/
54+
function isMethodAsEventFunction(node, customConfig) {
55+
if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
56+
return false;
57+
}
58+
59+
// Must be a MemberExpression: objectName.methodName(...)
60+
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
61+
return false;
62+
}
63+
64+
// The object part must match the configured objectName
65+
const objectNode = node.callee.object;
66+
if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
67+
return false;
68+
}
69+
70+
return objectNode.name === customConfig.objectName;
71+
}
72+
3973
/**
4074
* Checks if the node is a custom function call
4175
* @param {Object} node - AST CallExpression node
@@ -122,7 +156,7 @@ function detectFunctionBasedProvider(node) {
122156
}
123157

124158
const functionName = node.callee.name;
125-
159+
126160
for (const provider of Object.values(ANALYTICS_PROVIDERS)) {
127161
if (provider.type === 'function' && provider.functionName === functionName) {
128162
return provider.name;

src/analyze/javascript/extractors/event-extractor.js

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ function extractSnowplowEvent(node, constantMap) {
7272

7373
// tracker.track(buildStructEvent({ action: 'event_name', ... }))
7474
const firstArg = node.arguments[0];
75-
76-
if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&
75+
76+
if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&
7777
firstArg.arguments.length > 0) {
7878
const structEventArg = firstArg.arguments[0];
79-
79+
8080
if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
8181
const actionProperty = findPropertyByKey(structEventArg, 'action');
8282
const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;
83-
83+
8484
return { eventName, propertiesNode: structEventArg };
8585
}
8686
}
@@ -119,7 +119,7 @@ function extractGTMEvent(node, constantMap) {
119119

120120
// dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
121121
const firstArg = node.arguments[0];
122-
122+
123123
if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) {
124124
return { eventName: null, propertiesNode: null };
125125
}
@@ -131,11 +131,11 @@ function extractGTMEvent(node, constantMap) {
131131
}
132132

133133
const eventName = getStringValue(eventProperty.value, constantMap);
134-
134+
135135
// Create a modified properties node without the 'event' property
136136
const modifiedPropertiesNode = {
137137
...firstArg,
138-
properties: firstArg.properties.filter(prop =>
138+
properties: firstArg.properties.filter(prop =>
139139
prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event')
140140
)
141141
};
@@ -171,10 +171,27 @@ function extractDefaultEvent(node, constantMap) {
171171
function extractCustomEvent(node, constantMap, customConfig) {
172172
const args = node.arguments || [];
173173

174-
const eventArg = args[customConfig?.eventIndex ?? 0];
175-
const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
174+
let eventName;
175+
let propertiesArg;
176+
177+
if (customConfig?.isMethodAsEvent) {
178+
// Method-as-event pattern: event name comes from the method name
179+
if (node.callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
180+
node.callee.property.type === NODE_TYPES.IDENTIFIER) {
181+
eventName = node.callee.property.name;
182+
} else {
183+
// Fallback: could not extract method name
184+
eventName = null;
185+
}
176186

177-
const eventName = getStringValue(eventArg, constantMap);
187+
// Properties are at the configured index (default 0)
188+
propertiesArg = args[customConfig?.propertiesIndex ?? 0];
189+
} else {
190+
// Standard custom function pattern: event name comes from argument
191+
const eventArg = args[customConfig?.eventIndex ?? 0];
192+
propertiesArg = args[customConfig?.propertiesIndex ?? 1];
193+
eventName = getStringValue(eventArg, constantMap);
194+
}
178195

179196
const extraArgs = {};
180197
if (customConfig && customConfig.extraParams) {
@@ -274,8 +291,8 @@ function getStringValue(node, constantMap = {}) {
274291
*/
275292
function findPropertyByKey(objectNode, key) {
276293
if (!objectNode.properties) return null;
277-
278-
return objectNode.properties.find(prop =>
294+
295+
return objectNode.properties.find(prop =>
279296
prop.key && (prop.key.name === key || prop.key.value === key)
280297
);
281298
}

src/analyze/javascript/parser.js

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class ParseError extends Error {
5353
*/
5454
function parseFile(filePath) {
5555
let code;
56-
56+
5757
try {
5858
code = fs.readFileSync(filePath, 'utf8');
5959
} catch (error) {
@@ -72,16 +72,33 @@ function parseFile(filePath) {
7272
// ---------------------------------------------
7373

7474
/**
75-
* Determines whether a CallExpression node matches the provided custom function name.
76-
* Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
75+
* Determines whether a CallExpression node matches the provided custom function configuration.
76+
* Supports both simple identifiers (e.g. myTrack), dot-separated members (e.g. Custom.track),
77+
* and method-as-event patterns (e.g. eventCalls.EVENT_NAME).
7778
* The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
7879
* circular dependencies.
7980
* @param {Object} node – CallExpression AST node
80-
* @param {string} fnName – Custom function name (could include dots)
81+
* @param {Object} customConfig – Custom function configuration object
8182
* @returns {boolean}
8283
*/
83-
function nodeMatchesCustomFunction(node, fnName) {
84-
if (!fnName || !node.callee) return false;
84+
function nodeMatchesCustomFunction(node, customConfig) {
85+
if (!customConfig || !node.callee) return false;
86+
87+
// Handle method-as-event pattern
88+
if (customConfig.isMethodAsEvent && customConfig.objectName) {
89+
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
90+
return false;
91+
}
92+
const objectNode = node.callee.object;
93+
if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
94+
return false;
95+
}
96+
return objectNode.name === customConfig.objectName;
97+
}
98+
99+
// Handle standard custom function patterns
100+
const fnName = customConfig.functionName;
101+
if (!fnName) return false;
85102

86103
// Support chained calls in function name by stripping trailing parens from each segment
87104
const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
@@ -204,7 +221,7 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
204221
// Attempt to match any custom function first to avoid mis-classifying built-in providers
205222
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
206223
for (const cfg of customConfigs) {
207-
if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
224+
if (cfg && nodeMatchesCustomFunction(node, cfg)) {
208225
matchedCustomConfig = cfg;
209226
break;
210227
}
@@ -237,7 +254,8 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
237254
* @returns {Object|null} Extracted event or null
238255
*/
239256
function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
240-
const source = detectAnalyticsSource(node, customConfig?.functionName);
257+
// Pass the full customConfig object (not just functionName) to support method-as-event patterns
258+
const source = detectAnalyticsSource(node, customConfig || null);
241259
if (source === 'unknown') {
242260
return null;
243261
}

src/analyze/typescript/detectors/analytics-source.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@ const { ANALYTICS_PROVIDERS } = require('../constants');
99
/**
1010
* Detects the analytics provider from a CallExpression node
1111
* @param {Object} node - TypeScript CallExpression node
12-
* @param {string} [customFunction] - Custom function name to detect
12+
* @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
1313
* @returns {string} The detected analytics source or 'unknown'
1414
*/
15-
function detectAnalyticsSource(node, customFunction) {
15+
function detectAnalyticsSource(node, customFunctionOrConfig) {
1616
if (!node.expression) {
1717
return 'unknown';
1818
}
1919

2020
// Check for custom function first
21-
if (customFunction && isCustomFunction(node, customFunction)) {
21+
// Support both old string format and new config object format
22+
const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
23+
const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);
24+
25+
if (customConfig?.isMethodAsEvent) {
26+
// Method-as-event pattern: match any method on the specified object
27+
if (isMethodAsEventFunction(node, customConfig)) {
28+
return 'custom';
29+
}
30+
} else if (customFunction && isCustomFunction(node, customFunction)) {
2231
return 'custom';
2332
}
2433

@@ -37,6 +46,31 @@ function detectAnalyticsSource(node, customFunction) {
3746
return 'unknown';
3847
}
3948

49+
/**
50+
* Checks if the node matches a method-as-event custom function pattern
51+
* @param {Object} node - TypeScript CallExpression node
52+
* @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
53+
* @returns {boolean}
54+
*/
55+
function isMethodAsEventFunction(node, customConfig) {
56+
if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
57+
return false;
58+
}
59+
60+
// Must be a PropertyAccessExpression: objectName.methodName(...)
61+
if (!ts.isPropertyAccessExpression(node.expression)) {
62+
return false;
63+
}
64+
65+
// The object part must match the configured objectName
66+
const objectExpr = node.expression.expression;
67+
if (!ts.isIdentifier(objectExpr)) {
68+
return false;
69+
}
70+
71+
return objectExpr.escapedText === customConfig.objectName;
72+
}
73+
4074
/**
4175
* Checks if the node is a custom function call
4276
* @param {Object} node - TypeScript CallExpression node

0 commit comments

Comments
 (0)