@flisk/analyze-tracking 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,38 +23,46 @@ Run without installation! Just use:
23
23
  npx @flisk/analyze-tracking /path/to/project [options]
24
24
  ```
25
25
 
26
- ### Key Options:
26
+ ### Key Options
27
27
  - `-g, --generateDescription`: Generate descriptions of fields (default: `false`)
28
28
  - `-p, --provider <provider>`: Specify a provider (options: `openai`, `gemini`)
29
29
  - `-m, --model <model>`: Specify a model (ex: `gpt-4.1-nano`, `gpt-4o-mini`, `gemini-2.0-flash-lite-001`)
30
30
  - `-o, --output <output_file>`: Name of the output file (default: `tracking-schema.yaml`)
31
- - `-c, --customFunction <function_name>`: Specify a custom tracking function
31
+ - `-c, --customFunction <function_signature>`: Specify the signature of your custom tracking function (see [instructions here](#custom-functions))
32
32
  - `--format <format>`: Output format, either `yaml` (default) or `json`. If an invalid value is provided, the CLI will exit with an error.
33
33
  - `--stdout`: Print the output to the terminal instead of writing to a file (works with both YAML and JSON)
34
34
 
35
35
  🔑&nbsp; **Important:** If you are using `generateDescription`, you must set the appropriate credentials for the LLM provider you are using as an environment variable. OpenAI uses `OPENAI_API_KEY` and Google Vertex AI uses `GOOGLE_APPLICATION_CREDENTIALS`.
36
36
 
37
- <details>
38
- <summary>Note on Custom Functions 💡</summary>
39
37
 
40
- Use this if you have your own in-house tracker or a wrapper function that calls other tracking libraries.
38
+ ### Custom Functions
41
39
 
42
- We currently only support functions that follow the following format:
43
-
44
- **JavaScript/TypeScript/Python/Ruby:**
45
- ```js
46
- yourCustomTrackFunctionName('<event_name>', {
47
- <event_parameters>
48
- });
49
- ```
50
-
51
- **Go:**
52
- ```go
53
- yourCustomTrackFunctionName("<event_name>", map[string]any{}{
54
- "<property_name>": "<property_value>",
55
- })
56
- ```
57
- </details>
40
+ 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.
41
+
42
+ Your function signature should be in the following format:
43
+ ```js
44
+ yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES, customFieldOne, customFieldTwo)
45
+ ```
46
+
47
+ - `EVENT_NAME` is the name of the event you are tracking. It should be a string or a pointer to a string. This is required.
48
+ - `PROPERTIES` is an object of properties for that event. It should be an object / dictionary. This is optional.
49
+ - Any additional parameters are other fields you are tracking. They can be of any type. The names you provide for these parameters will be used as the property names in the output.
50
+
51
+
52
+ For example, if your function has a userId parameter at the beginning, followed by the event name and properties, you would pass in the following:
53
+
54
+ ```js
55
+ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES)
56
+ ```
57
+
58
+ If your function follows the standard format `yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES)`, you can simply pass in `yourCustomTrackFunctionName` to `--customFunction` as a shorthand.
59
+
60
+ 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.
61
+
62
+ ```sh
63
+ npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
64
+ npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)"
65
+ ```
58
66
 
59
67
 
60
68
  ## What's Generated?
package/bin/cli.js CHANGED
@@ -43,6 +43,7 @@ const optionDefinitions = [
43
43
  name: 'customFunction',
44
44
  alias: 'c',
45
45
  type: String,
46
+ multiple: true,
46
47
  },
47
48
  {
48
49
  name: 'repositoryUrl',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,34 +12,34 @@ const { extractTrackingEvent } = require('./trackingExtractor');
12
12
  * @param {Array<Object>} events - Array to collect found tracking events (modified in place)
13
13
  * @param {string} filePath - Path to the file being analyzed
14
14
  * @param {string} functionName - Name of the current function being processed
15
- * @param {string|null} customFunction - Name of custom tracking function to detect
15
+ * @param {Object|null} customConfig - Parsed custom function configuration (or null)
16
16
  * @param {Object} typeContext - Type information context for variable resolution
17
17
  * @param {string} currentFunction - Current function context for type lookups
18
18
  */
19
- function extractEventsFromBody(body, events, filePath, functionName, customFunction, typeContext, currentFunction) {
19
+ function extractEventsFromBody(body, events, filePath, functionName, customConfig, typeContext, currentFunction) {
20
20
  for (const stmt of body) {
21
21
  if (stmt.tag === 'exec' && stmt.expr) {
22
- processExpression(stmt.expr, events, filePath, functionName, customFunction, typeContext, currentFunction);
22
+ processExpression(stmt.expr, events, filePath, functionName, customConfig, typeContext, currentFunction);
23
23
  } else if (stmt.tag === 'declare' && stmt.value) {
24
24
  // Handle variable declarations with tracking calls
25
- processExpression(stmt.value, events, filePath, functionName, customFunction, typeContext, currentFunction);
25
+ processExpression(stmt.value, events, filePath, functionName, customConfig, typeContext, currentFunction);
26
26
  } else if (stmt.tag === 'assign' && stmt.rhs) {
27
27
  // Handle assignments with tracking calls
28
- processExpression(stmt.rhs, events, filePath, functionName, customFunction, typeContext, currentFunction);
28
+ processExpression(stmt.rhs, events, filePath, functionName, customConfig, typeContext, currentFunction);
29
29
  } else if (stmt.tag === 'if' && stmt.body) {
30
- extractEventsFromBody(stmt.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
30
+ extractEventsFromBody(stmt.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
31
31
  } else if (stmt.tag === 'elseif' && stmt.body) {
32
- extractEventsFromBody(stmt.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
32
+ extractEventsFromBody(stmt.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
33
33
  } else if (stmt.tag === 'else' && stmt.body) {
34
- extractEventsFromBody(stmt.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
34
+ extractEventsFromBody(stmt.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
35
35
  } else if (stmt.tag === 'for' && stmt.body) {
36
- extractEventsFromBody(stmt.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
36
+ extractEventsFromBody(stmt.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
37
37
  } else if (stmt.tag === 'foreach' && stmt.body) {
38
- extractEventsFromBody(stmt.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
38
+ extractEventsFromBody(stmt.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
39
39
  } else if (stmt.tag === 'switch' && stmt.cases) {
40
40
  for (const caseNode of stmt.cases) {
41
41
  if (caseNode.body) {
42
- extractEventsFromBody(caseNode.body, events, filePath, functionName, customFunction, typeContext, currentFunction);
42
+ extractEventsFromBody(caseNode.body, events, filePath, functionName, customConfig, typeContext, currentFunction);
43
43
  }
44
44
  }
45
45
  }
@@ -52,18 +52,18 @@ function extractEventsFromBody(body, events, filePath, functionName, customFunct
52
52
  * @param {Array<Object>} events - Array to collect found tracking events (modified in place)
53
53
  * @param {string} filePath - Path to the file being analyzed
54
54
  * @param {string} functionName - Name of the current function being processed
55
- * @param {string|null} customFunction - Name of custom tracking function to detect
55
+ * @param {Object|null} customConfig - Parsed custom function configuration (or null)
56
56
  * @param {Object} typeContext - Type information context for variable resolution
57
57
  * @param {string} currentFunction - Current function context for type lookups
58
58
  * @param {number} [depth=0] - Current recursion depth (used to prevent infinite recursion)
59
59
  */
60
- function processExpression(expr, events, filePath, functionName, customFunction, typeContext, currentFunction, depth = 0) {
60
+ function processExpression(expr, events, filePath, functionName, customConfig, typeContext, currentFunction, depth = 0) {
61
61
  if (!expr || depth > MAX_RECURSION_DEPTH) return; // Prevent infinite recursion with depth limit
62
62
 
63
63
  // Handle array of expressions
64
64
  if (Array.isArray(expr)) {
65
65
  for (const item of expr) {
66
- processExpression(item, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
66
+ processExpression(item, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
67
67
  }
68
68
  return;
69
69
  }
@@ -71,25 +71,25 @@ function processExpression(expr, events, filePath, functionName, customFunction,
71
71
  // Handle single expression with body
72
72
  if (expr.body) {
73
73
  for (const item of expr.body) {
74
- processExpression(item, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
74
+ processExpression(item, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
75
75
  }
76
76
  return;
77
77
  }
78
78
 
79
79
  // Handle specific node types
80
80
  if (expr.tag === 'call') {
81
- const trackingCall = extractTrackingEvent(expr, filePath, functionName, customFunction, typeContext, currentFunction);
81
+ const trackingCall = extractTrackingEvent(expr, filePath, functionName, customConfig, typeContext, currentFunction);
82
82
  if (trackingCall) {
83
83
  events.push(trackingCall);
84
84
  }
85
85
 
86
86
  // Also process call arguments
87
87
  if (expr.args) {
88
- processExpression(expr.args, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
88
+ processExpression(expr.args, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
89
89
  }
90
90
  } else if (expr.tag === 'structlit') {
91
91
  // Check if this struct literal is a tracking event
92
- const trackingCall = extractTrackingEvent(expr, filePath, functionName, customFunction, typeContext, currentFunction);
92
+ const trackingCall = extractTrackingEvent(expr, filePath, functionName, customConfig, typeContext, currentFunction);
93
93
  if (trackingCall) {
94
94
  events.push(trackingCall);
95
95
  }
@@ -98,7 +98,7 @@ function processExpression(expr, events, filePath, functionName, customFunction,
98
98
  if (!trackingCall && expr.fields) {
99
99
  for (const field of expr.fields) {
100
100
  if (field.value) {
101
- processExpression(field.value, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
101
+ processExpression(field.value, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
102
102
  }
103
103
  }
104
104
  }
@@ -106,13 +106,13 @@ function processExpression(expr, events, filePath, functionName, customFunction,
106
106
 
107
107
  // Process other common properties that might contain expressions
108
108
  if (expr.value && expr.tag !== 'structlit') {
109
- processExpression(expr.value, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
109
+ processExpression(expr.value, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
110
110
  }
111
111
  if (expr.lhs) {
112
- processExpression(expr.lhs, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
112
+ processExpression(expr.lhs, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
113
113
  }
114
114
  if (expr.rhs) {
115
- processExpression(expr.rhs, events, filePath, functionName, customFunction, typeContext, currentFunction, depth + 1);
115
+ processExpression(expr.rhs, events, filePath, functionName, customConfig, typeContext, currentFunction, depth + 1);
116
116
  }
117
117
  }
118
118
 
@@ -10,9 +10,10 @@ const { extractStringValue, findStructLiteral, findStructField, extractSnowplowV
10
10
  * Extract event name from a tracking call based on the source
11
11
  * @param {Object} callNode - AST node representing a function call or struct literal
12
12
  * @param {string} source - Analytics source (e.g., 'segment', 'amplitude')
13
+ * @param {Object|null} customConfig - Parsed custom function configuration
13
14
  * @returns {string|null} Event name or null if not found
14
15
  */
15
- function extractEventName(callNode, source) {
16
+ function extractEventName(callNode, source, customConfig = null) {
16
17
  if (!callNode.args || callNode.args.length === 0) {
17
18
  // For struct literals, we need to check fields instead of args
18
19
  if (!callNode.fields || callNode.fields.length === 0) {
@@ -35,7 +36,7 @@ function extractEventName(callNode, source) {
35
36
  return extractSnowplowEventName(callNode);
36
37
 
37
38
  case ANALYTICS_SOURCES.CUSTOM:
38
- return extractCustomEventName(callNode);
39
+ return extractCustomEventName(callNode, customConfig);
39
40
  }
40
41
 
41
42
  return null;
@@ -142,13 +143,15 @@ function extractSnowplowEventName(callNode) {
142
143
  * Extract custom event name
143
144
  * Pattern: customFunction("event_name", props)
144
145
  * @param {Object} callNode - AST node for custom tracking function call
146
+ * @param {Object|null} customConfig - Custom configuration object
145
147
  * @returns {string|null} Event name or null if not found
146
148
  */
147
- function extractCustomEventName(callNode) {
148
- if (callNode.args && callNode.args.length > 0) {
149
- return extractStringValue(callNode.args[0]);
150
- }
151
- return null;
149
+ function extractCustomEventName(callNode, customConfig) {
150
+ if (!callNode.args || callNode.args.length === 0) return null;
151
+ const args = callNode.args;
152
+ const eventIdx = customConfig?.eventIndex ?? 0;
153
+ const argNode = args[eventIdx];
154
+ return extractStringValue(argNode);
152
155
  }
153
156
 
154
157
  module.exports = {
@@ -12,39 +12,59 @@ const { extractEventsFromBody } = require('./astTraversal');
12
12
  /**
13
13
  * Analyze a Go file and extract tracking events
14
14
  * @param {string} filePath - Path to the Go file to analyze
15
- * @param {string|null} customFunction - Name of custom tracking function to detect (optional)
15
+ * @param {string|null} customFunctionSignatures - Signature of custom tracking function to detect (optional)
16
16
  * @returns {Promise<Array>} Array of tracking events found in the file
17
17
  * @throws {Error} If the file cannot be read or parsed
18
18
  */
19
- async function analyzeGoFile(filePath, customFunction) {
19
+ async function analyzeGoFile(filePath, customFunctionSignatures = null) {
20
20
  try {
21
21
  // Read the Go file
22
22
  const source = fs.readFileSync(filePath, 'utf8');
23
-
24
- // Parse the Go file using goAstParser
23
+
24
+ // Parse the Go file using goAstParser (once)
25
25
  const ast = extractGoAST(source);
26
-
26
+
27
27
  // First pass: build type information for functions and variables
28
28
  const typeContext = buildTypeContext(ast);
29
-
30
- // Extract tracking events from the AST
31
- const events = [];
32
- let currentFunction = 'global';
33
-
34
- // Walk through the AST
35
- for (const node of ast) {
36
- if (node.tag === 'func') {
37
- currentFunction = node.name;
38
- // Process the function body
39
- if (node.body) {
40
- extractEventsFromBody(node.body, events, filePath, currentFunction, customFunction, typeContext, currentFunction);
29
+
30
+ const collectEventsForConfig = (customConfig) => {
31
+ const events = [];
32
+ let currentFunction = 'global';
33
+ for (const node of ast) {
34
+ if (node.tag === 'func') {
35
+ currentFunction = node.name;
36
+ if (node.body) {
37
+ extractEventsFromBody(
38
+ node.body,
39
+ events,
40
+ filePath,
41
+ currentFunction,
42
+ customConfig,
43
+ typeContext,
44
+ currentFunction
45
+ );
46
+ }
41
47
  }
42
48
  }
49
+ return events;
50
+ };
51
+
52
+ let events = [];
53
+
54
+ // Built-in providers pass (null custom config)
55
+ events.push(...collectEventsForConfig(null));
56
+
57
+ // Custom configs passes
58
+ if (Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0) {
59
+ for (const customConfig of customFunctionSignatures) {
60
+ if (!customConfig) continue;
61
+ events.push(...collectEventsForConfig(customConfig));
62
+ }
43
63
  }
44
-
64
+
45
65
  // Deduplicate events based on eventName, source, and function
46
66
  const uniqueEvents = deduplicateEvents(events);
47
-
67
+
48
68
  return uniqueEvents;
49
69
  } catch (error) {
50
70
  console.error(`Error analyzing Go file ${filePath}:`, error.message);
@@ -12,9 +12,10 @@ const { extractStringValue, findStructLiteral, findStructField, extractFieldName
12
12
  * @param {string} source - Analytics source (e.g., 'segment', 'amplitude')
13
13
  * @param {Object} typeContext - Type information context for variable resolution
14
14
  * @param {string} currentFunction - Current function context for type lookups
15
+ * @param {Object} customConfig - Custom configuration for property extraction
15
16
  * @returns {Object} Object containing extracted properties with their type information
16
17
  */
17
- function extractProperties(callNode, source, typeContext, currentFunction) {
18
+ function extractProperties(callNode, source, typeContext, currentFunction, customConfig) {
18
19
  const properties = {};
19
20
 
20
21
  switch (source) {
@@ -36,7 +37,7 @@ function extractProperties(callNode, source, typeContext, currentFunction) {
36
37
  break;
37
38
 
38
39
  case ANALYTICS_SOURCES.CUSTOM:
39
- extractCustomProperties(callNode, properties, typeContext, currentFunction);
40
+ extractCustomProperties(callNode, properties, typeContext, currentFunction, customConfig);
40
41
  break;
41
42
  }
42
43
 
@@ -270,10 +271,29 @@ function extractSnowplowProperties(callNode, properties, typeContext, currentFun
270
271
  * @param {Object} properties - Object to store extracted properties (modified in place)
271
272
  * @param {Object} typeContext - Type information context for variable resolution
272
273
  * @param {string} currentFunction - Current function context for type lookups
274
+ * @param {Object} customConfig - Custom configuration for property extraction
273
275
  */
274
- function extractCustomProperties(callNode, properties, typeContext, currentFunction) {
275
- if (callNode.args && callNode.args.length > 1) {
276
- extractPropertiesFromExpr(callNode.args[1], properties, typeContext, currentFunction);
276
+ function extractCustomProperties(callNode, properties, typeContext, currentFunction, customConfig) {
277
+ if (!callNode.args || callNode.args.length === 0) return;
278
+
279
+ const args = callNode.args;
280
+
281
+ const propsIdx = customConfig?.propertiesIndex ?? 1;
282
+
283
+ // Extract extra params first (those not event or properties)
284
+ if (customConfig && Array.isArray(customConfig.extraParams)) {
285
+ customConfig.extraParams.forEach(param => {
286
+ const argNode = args[param.idx];
287
+ if (argNode) {
288
+ properties[param.name] = getPropertyInfo(argNode, typeContext, currentFunction);
289
+ }
290
+ });
291
+ }
292
+
293
+ // Extract properties map/object (if provided)
294
+ const propsArg = args[propsIdx];
295
+ if (propsArg) {
296
+ extractPropertiesFromExpr(propsArg, properties, typeContext, currentFunction);
277
297
  }
278
298
  }
279
299
 
@@ -13,19 +13,19 @@ const { extractProperties } = require('./propertyExtractor');
13
13
  * @param {Object} callNode - AST node representing a function call or struct literal
14
14
  * @param {string} filePath - Path to the file being analyzed
15
15
  * @param {string} functionName - Name of the function containing this tracking call
16
- * @param {string|null} customFunction - Name of custom tracking function to detect
16
+ * @param {Object|null} customConfig - Parsed custom function configuration (or null)
17
17
  * @param {Object} typeContext - Type information context for variable resolution
18
18
  * @param {string} currentFunction - Current function context for type lookups
19
19
  * @returns {Object|null} Tracking event object with eventName, source, properties, etc., or null if not a tracking call
20
20
  */
21
- function extractTrackingEvent(callNode, filePath, functionName, customFunction, typeContext, currentFunction) {
22
- const source = detectSource(callNode, customFunction);
21
+ function extractTrackingEvent(callNode, filePath, functionName, customConfig, typeContext, currentFunction) {
22
+ const source = detectSource(callNode, customConfig ? customConfig.functionName : null);
23
23
  if (!source) return null;
24
24
 
25
- const eventName = extractEventName(callNode, source);
25
+ const eventName = extractEventName(callNode, source, customConfig);
26
26
  if (!eventName) return null;
27
27
 
28
- const properties = extractProperties(callNode, source, typeContext, currentFunction);
28
+ const properties = extractProperties(callNode, source, typeContext, currentFunction, customConfig);
29
29
 
30
30
  // Get line number based on source type
31
31
  let line = 0;
@@ -5,6 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const ts = require('typescript');
8
+ const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
8
9
  const { getAllFiles } = require('../utils/fileProcessor');
9
10
  const { analyzeJsFile } = require('./javascript');
10
11
  const { analyzeTsFile } = require('./typescript');
@@ -12,9 +13,11 @@ const { analyzePythonFile } = require('./python');
12
13
  const { analyzeRubyFile } = require('./ruby');
13
14
  const { analyzeGoFile } = require('./go');
14
15
 
15
- async function analyzeDirectory(dirPath, customFunction) {
16
+ async function analyzeDirectory(dirPath, customFunctions) {
16
17
  const allEvents = {};
17
18
 
19
+ const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;
20
+
18
21
  const files = getAllFiles(dirPath);
19
22
  const tsFiles = files.filter(file => /\.(tsx?)$/.test(file));
20
23
  const tsProgram = ts.createProgram(tsFiles, {
@@ -32,15 +35,15 @@ async function analyzeDirectory(dirPath, customFunction) {
32
35
  const isGoFile = /\.(go)$/.test(file);
33
36
 
34
37
  if (isJsFile) {
35
- events = analyzeJsFile(file, customFunction);
38
+ events = analyzeJsFile(file, customFunctionSignatures);
36
39
  } else if (isTsFile) {
37
- events = analyzeTsFile(file, tsProgram, customFunction);
40
+ events = analyzeTsFile(file, tsProgram, customFunctionSignatures);
38
41
  } else if (isPythonFile) {
39
- events = await analyzePythonFile(file, customFunction);
42
+ events = await analyzePythonFile(file, customFunctionSignatures);
40
43
  } else if (isRubyFile) {
41
- events = await analyzeRubyFile(file, customFunction);
44
+ events = await analyzeRubyFile(file, customFunctionSignatures);
42
45
  } else if (isGoFile) {
43
- events = await analyzeGoFile(file, customFunction);
46
+ events = await analyzeGoFile(file, customFunctionSignatures);
44
47
  } else {
45
48
  continue;
46
49
  }
@@ -43,8 +43,61 @@ function detectAnalyticsSource(node, customFunction) {
43
43
  * @returns {boolean}
44
44
  */
45
45
  function isCustomFunction(node, customFunction) {
46
- return node.callee.type === NODE_TYPES.IDENTIFIER &&
47
- node.callee.name === customFunction;
46
+ if (!customFunction) return false;
47
+
48
+ // Support dot-separated names like "CustomModule.track"
49
+ const parts = customFunction.split('.');
50
+
51
+ // Simple identifier (no dot)
52
+ if (parts.length === 1) {
53
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction;
54
+ }
55
+
56
+ // For dot-separated names, the callee should be a MemberExpression chain.
57
+ if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
58
+ return false;
59
+ }
60
+
61
+ return matchesMemberChain(node.callee, parts);
62
+ }
63
+
64
+ /**
65
+ * Recursively verifies that a MemberExpression chain matches the expected parts.
66
+ * Example: parts ["CustomModule", "track"] should match `CustomModule.track()`.
67
+ * @param {Object} memberExpr - AST MemberExpression node
68
+ * @param {string[]} parts - Expected name segments (left -> right)
69
+ * @returns {boolean}
70
+ */
71
+ function matchesMemberChain(memberExpr, parts) {
72
+ let currentNode = memberExpr;
73
+ let idx = parts.length - 1; // start from the rightmost property
74
+
75
+ while (currentNode && idx >= 0) {
76
+ const expectedPart = parts[idx];
77
+
78
+ // property should match current expectedPart
79
+ if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
80
+ // Ensure property is Identifier and matches
81
+ if (
82
+ currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
83
+ currentNode.property.name !== expectedPart
84
+ ) {
85
+ return false;
86
+ }
87
+
88
+ // Move to the object of the MemberExpression
89
+ currentNode = currentNode.object;
90
+ idx -= 1;
91
+ } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
92
+ // We reached the leftmost Identifier; it should match the first part
93
+ return idx === 0 && currentNode.name === expectedPart;
94
+ } else {
95
+ // Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
96
+ return false;
97
+ }
98
+ }
99
+
100
+ return false;
48
101
  }
49
102
 
50
103
  /**
@@ -20,6 +20,7 @@ const EXTRACTION_STRATEGIES = {
20
20
  googleanalytics: extractGoogleAnalyticsEvent,
21
21
  snowplow: extractSnowplowEvent,
22
22
  mparticle: extractMparticleEvent,
23
+ custom: extractCustomEvent,
23
24
  default: extractDefaultEvent
24
25
  };
25
26
 
@@ -27,10 +28,14 @@ const EXTRACTION_STRATEGIES = {
27
28
  * Extracts event information from a CallExpression node
28
29
  * @param {Object} node - AST CallExpression node
29
30
  * @param {string} source - Analytics provider source
31
+ * @param {Object} customConfig - Parsed custom function configuration
30
32
  * @returns {EventData} Extracted event data
31
33
  */
32
- function extractEventData(node, source) {
34
+ function extractEventData(node, source, customConfig) {
33
35
  const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
36
+ if (source === 'custom') {
37
+ return strategy(node, customConfig);
38
+ }
34
39
  return strategy(node);
35
40
  }
36
41
 
@@ -113,6 +118,30 @@ function extractDefaultEvent(node) {
113
118
  return { eventName, propertiesNode };
114
119
  }
115
120
 
121
+ /**
122
+ * Extracts Custom function event data according to signature
123
+ * @param {Object} node - CallExpression node
124
+ * @param {Object} customConfig - Parsed custom function configuration
125
+ * @returns {EventData & {extraArgs:Object}} event data plus extra args map
126
+ */
127
+ function extractCustomEvent(node, customConfig) {
128
+ const args = node.arguments || [];
129
+
130
+ const eventArg = args[customConfig?.eventIndex ?? 0];
131
+ const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
132
+
133
+ const eventName = getStringValue(eventArg);
134
+
135
+ const extraArgs = {};
136
+ if (customConfig && customConfig.extraParams) {
137
+ customConfig.extraParams.forEach(extra => {
138
+ extraArgs[extra.name] = args[extra.idx];
139
+ });
140
+ }
141
+
142
+ return { eventName, propertiesNode: propertiesArg, extraArgs };
143
+ }
144
+
116
145
  /**
117
146
  * Processes extracted event data into final event object
118
147
  * @param {EventData} eventData - Raw event data
@@ -120,9 +149,10 @@ function extractDefaultEvent(node) {
120
149
  * @param {string} filePath - File path
121
150
  * @param {number} line - Line number
122
151
  * @param {string} functionName - Containing function name
152
+ * @param {Object} customConfig - Parsed custom function configuration
123
153
  * @returns {Object|null} Processed event object or null
124
154
  */
125
- function processEventData(eventData, source, filePath, line, functionName) {
155
+ function processEventData(eventData, source, filePath, line, functionName, customConfig) {
126
156
  const { eventName, propertiesNode } = eventData;
127
157
 
128
158
  if (!eventName || !propertiesNode || propertiesNode.type !== NODE_TYPES.OBJECT_EXPRESSION) {
@@ -131,6 +161,24 @@ function processEventData(eventData, source, filePath, line, functionName) {
131
161
 
132
162
  let properties = extractProperties(propertiesNode);
133
163
 
164
+ // Handle custom extra params
165
+ if (source === 'custom' && customConfig && eventData.extraArgs) {
166
+ for (const [paramName, argNode] of Object.entries(eventData.extraArgs)) {
167
+ if (argNode && argNode.type === NODE_TYPES.OBJECT_EXPRESSION) {
168
+ // Extract detailed properties from object expression
169
+ properties[paramName] = {
170
+ type: 'object',
171
+ properties: extractProperties(argNode)
172
+ };
173
+ } else {
174
+ // For non-object arguments, use simple type inference
175
+ properties[paramName] = {
176
+ type: inferNodeValueType(argNode)
177
+ };
178
+ }
179
+ }
180
+ }
181
+
134
182
  // Special handling for Snowplow: remove 'action' from properties
135
183
  if (source === 'snowplow' && properties.action) {
136
184
  delete properties.action;
@@ -173,6 +221,25 @@ function findPropertyByKey(objectNode, key) {
173
221
  );
174
222
  }
175
223
 
224
+ /**
225
+ * Infers the type of a value from an AST node (simple heuristic)
226
+ * @param {Object} node - AST node
227
+ * @returns {string} inferred type
228
+ */
229
+ function inferNodeValueType(node) {
230
+ if (!node) return 'any';
231
+ switch (node.type) {
232
+ case NODE_TYPES.LITERAL:
233
+ return typeof node.value;
234
+ case NODE_TYPES.OBJECT_EXPRESSION:
235
+ return 'object';
236
+ case NODE_TYPES.ARRAY_EXPRESSION:
237
+ return 'array';
238
+ default:
239
+ return 'any';
240
+ }
241
+ }
242
+
176
243
  module.exports = {
177
244
  extractEventData,
178
245
  processEventData