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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ npx @flisk/analyze-tracking /path/to/project [options]

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.

#### Standard Custom Function Format

Your function signature should be in the following format:
```js
yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES, customFieldOne, customFieldTwo)
Expand All @@ -57,11 +59,45 @@ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES)

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

#### Method-Name-as-Event Format

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:

```js
yourClass.EVENT_NAME(PROPERTIES)
```

This pattern tells the analyzer that:
- `yourClass` is the object name to match
- The method name after the dot (e.g., `viewItemList`, `addToCart`) is the event name
- `PROPERTIES` is the properties object (defaults to the first argument if not specified)

**Example:**
```typescript
// Code in your project:
yourClass.viewItemList({ items: [...] });
yourClass.addToCart({ item: {...}, value: 100 });
yourClass.purchase({ userId: '123', value: 100 });

// Command:
npx @flisk/analyze-tracking /path/to/project --customFunction "yourClass.EVENT_NAME(PROPERTIES)"
```

This will detect:
- Event: `viewItemList` with properties from the first argument
- Event: `addToCart` with properties from the first argument
- Event: `purchase` with properties from the first argument

_**Note:** This pattern is currently only supported for JavaScript and TypeScript code._

#### Multiple Custom Functions

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.

```sh
npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
npx @flisk/analyze-tracking /path/to/project -c "yourClass.EVENT_NAME(PROPERTIES)" "customTrack(EVENT_NAME, PROPERTIES)"
```


Expand Down
10 changes: 10 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@
"items": {
"$ref": "#/definitions/property",
"description": "Schema for array items when type is 'array'"
},
"values": {
"type": "array",
"items": {
"oneOf": [
{ "type": "string" },
{ "type": "number" }
]
},
"description": "Possible values when type is 'enum'"
}
},
"required": [
Expand Down
42 changes: 38 additions & 4 deletions src/analyze/javascript/detectors/analytics-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@ const { ANALYTICS_PROVIDERS, NODE_TYPES } = require('../constants');
/**
* Detects the analytics provider from a CallExpression node
* @param {Object} node - AST CallExpression node
* @param {string} [customFunction] - Custom function name to detect
* @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
* @returns {string} The detected analytics source or 'unknown'
*/
function detectAnalyticsSource(node, customFunction) {
function detectAnalyticsSource(node, customFunctionOrConfig) {
if (!node.callee) {
return 'unknown';
}

// Check for custom function first
if (customFunction && isCustomFunction(node, customFunction)) {
// Support both old string format and new config object format
const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);

if (customConfig?.isMethodAsEvent) {
// Method-as-event pattern: match any method on the specified object
if (isMethodAsEventFunction(node, customConfig)) {
return 'custom';
}
} else if (customFunction && isCustomFunction(node, customFunction)) {
return 'custom';
}

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

/**
* Checks if the node matches a method-as-event custom function pattern
* @param {Object} node - AST CallExpression node
* @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
* @returns {boolean}
*/
function isMethodAsEventFunction(node, customConfig) {
if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
return false;
}

// Must be a MemberExpression: objectName.methodName(...)
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
return false;
}

// The object part must match the configured objectName
const objectNode = node.callee.object;
if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
return false;
}

return objectNode.name === customConfig.objectName;
}

/**
* Checks if the node is a custom function call
* @param {Object} node - AST CallExpression node
Expand Down Expand Up @@ -122,7 +156,7 @@ function detectFunctionBasedProvider(node) {
}

const functionName = node.callee.name;

for (const provider of Object.values(ANALYTICS_PROVIDERS)) {
if (provider.type === 'function' && provider.functionName === functionName) {
return provider.name;
Expand Down
41 changes: 29 additions & 12 deletions src/analyze/javascript/extractors/event-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ function extractSnowplowEvent(node, constantMap) {

// tracker.track(buildStructEvent({ action: 'event_name', ... }))
const firstArg = node.arguments[0];
if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&

if (firstArg.type === NODE_TYPES.CALL_EXPRESSION &&
firstArg.arguments.length > 0) {
const structEventArg = firstArg.arguments[0];

if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
const actionProperty = findPropertyByKey(structEventArg, 'action');
const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;

return { eventName, propertiesNode: structEventArg };
}
}
Expand Down Expand Up @@ -119,7 +119,7 @@ function extractGTMEvent(node, constantMap) {

// dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
const firstArg = node.arguments[0];

if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) {
return { eventName: null, propertiesNode: null };
}
Expand All @@ -131,11 +131,11 @@ function extractGTMEvent(node, constantMap) {
}

const eventName = getStringValue(eventProperty.value, constantMap);

// Create a modified properties node without the 'event' property
const modifiedPropertiesNode = {
...firstArg,
properties: firstArg.properties.filter(prop =>
properties: firstArg.properties.filter(prop =>
prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event')
)
};
Expand Down Expand Up @@ -171,10 +171,27 @@ function extractDefaultEvent(node, constantMap) {
function extractCustomEvent(node, constantMap, customConfig) {
const args = node.arguments || [];

const eventArg = args[customConfig?.eventIndex ?? 0];
const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
let eventName;
let propertiesArg;

if (customConfig?.isMethodAsEvent) {
// Method-as-event pattern: event name comes from the method name
if (node.callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
node.callee.property.type === NODE_TYPES.IDENTIFIER) {
eventName = node.callee.property.name;
} else {
// Fallback: could not extract method name
eventName = null;
}

const eventName = getStringValue(eventArg, constantMap);
// Properties are at the configured index (default 0)
propertiesArg = args[customConfig?.propertiesIndex ?? 0];
} else {
// Standard custom function pattern: event name comes from argument
const eventArg = args[customConfig?.eventIndex ?? 0];
propertiesArg = args[customConfig?.propertiesIndex ?? 1];
eventName = getStringValue(eventArg, constantMap);
}

const extraArgs = {};
if (customConfig && customConfig.extraParams) {
Expand Down Expand Up @@ -274,8 +291,8 @@ function getStringValue(node, constantMap = {}) {
*/
function findPropertyByKey(objectNode, key) {
if (!objectNode.properties) return null;
return objectNode.properties.find(prop =>

return objectNode.properties.find(prop =>
prop.key && (prop.key.name === key || prop.key.value === key)
);
}
Expand Down
34 changes: 26 additions & 8 deletions src/analyze/javascript/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class ParseError extends Error {
*/
function parseFile(filePath) {
let code;

try {
code = fs.readFileSync(filePath, 'utf8');
} catch (error) {
Expand All @@ -72,16 +72,33 @@ function parseFile(filePath) {
// ---------------------------------------------

/**
* Determines whether a CallExpression node matches the provided custom function name.
* Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
* Determines whether a CallExpression node matches the provided custom function configuration.
* Supports both simple identifiers (e.g. myTrack), dot-separated members (e.g. Custom.track),
* and method-as-event patterns (e.g. eventCalls.EVENT_NAME).
* The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
* circular dependencies.
* @param {Object} node – CallExpression AST node
* @param {string} fnName – Custom function name (could include dots)
* @param {Object} customConfig – Custom function configuration object
* @returns {boolean}
*/
function nodeMatchesCustomFunction(node, fnName) {
if (!fnName || !node.callee) return false;
function nodeMatchesCustomFunction(node, customConfig) {
if (!customConfig || !node.callee) return false;

// Handle method-as-event pattern
if (customConfig.isMethodAsEvent && customConfig.objectName) {
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
return false;
}
const objectNode = node.callee.object;
if (objectNode.type !== NODE_TYPES.IDENTIFIER) {
return false;
}
return objectNode.name === customConfig.objectName;
}

// Handle standard custom function patterns
const fnName = customConfig.functionName;
if (!fnName) return false;

// Support chained calls in function name by stripping trailing parens from each segment
const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
Expand Down Expand Up @@ -204,7 +221,7 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
// Attempt to match any custom function first to avoid mis-classifying built-in providers
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
for (const cfg of customConfigs) {
if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
if (cfg && nodeMatchesCustomFunction(node, cfg)) {
matchedCustomConfig = cfg;
break;
}
Expand Down Expand Up @@ -237,7 +254,8 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
* @returns {Object|null} Extracted event or null
*/
function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
const source = detectAnalyticsSource(node, customConfig?.functionName);
// Pass the full customConfig object (not just functionName) to support method-as-event patterns
const source = detectAnalyticsSource(node, customConfig || null);
if (source === 'unknown') {
return null;
}
Expand Down
40 changes: 37 additions & 3 deletions src/analyze/typescript/detectors/analytics-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@ const { ANALYTICS_PROVIDERS } = require('../constants');
/**
* Detects the analytics provider from a CallExpression node
* @param {Object} node - TypeScript CallExpression node
* @param {string} [customFunction] - Custom function name to detect
* @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object
* @returns {string} The detected analytics source or 'unknown'
*/
function detectAnalyticsSource(node, customFunction) {
function detectAnalyticsSource(node, customFunctionOrConfig) {
if (!node.expression) {
return 'unknown';
}

// Check for custom function first
if (customFunction && isCustomFunction(node, customFunction)) {
// Support both old string format and new config object format
const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null;
const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName);

if (customConfig?.isMethodAsEvent) {
// Method-as-event pattern: match any method on the specified object
if (isMethodAsEventFunction(node, customConfig)) {
return 'custom';
}
} else if (customFunction && isCustomFunction(node, customFunction)) {
return 'custom';
}

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

/**
* Checks if the node matches a method-as-event custom function pattern
* @param {Object} node - TypeScript CallExpression node
* @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true
* @returns {boolean}
*/
function isMethodAsEventFunction(node, customConfig) {
if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) {
return false;
}

// Must be a PropertyAccessExpression: objectName.methodName(...)
if (!ts.isPropertyAccessExpression(node.expression)) {
return false;
}

// The object part must match the configured objectName
const objectExpr = node.expression.expression;
if (!ts.isIdentifier(objectExpr)) {
return false;
}

return objectExpr.escapedText === customConfig.objectName;
}

/**
* Checks if the node is a custom function call
* @param {Object} node - TypeScript CallExpression node
Expand Down
Loading