@flisk/analyze-tracking 0.8.6 → 0.8.8

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
@@ -101,18 +101,19 @@ See [schema.json](schema.json) for a JSON Schema of the output.
101
101
 
102
102
  | Library | JavaScript/TypeScript | Python | Ruby | Go |
103
103
  |---------|:---------------------:|:------:|:----:|:--:|
104
- | Google Analytics | ✅ | ❌ | ❌ | ❌ |
105
- | Segment | ✅ | | | |
106
- | Mixpanel | ✅ | ✅ | ✅ | ✅ |
107
- | Amplitude | ✅ | ✅ | | ✅ |
108
- | Rudderstack | ✅ | ✅ | ✳️ | ✳️ |
109
- | mParticle | ✅ | | | |
110
- | PostHog | ✅ | | | |
111
- | Pendo | ✅ | | | |
112
- | Heap | ✅ | ❌ | ❌ | ❌ |
113
- | Snowplow | ✅ | | | |
114
- | Datadog RUM | ✅ | | | |
115
- | Custom Function | ✅ | | | |
104
+ | Google Analytics | ✅ | ❌ | ❌ | ❌ |
105
+ | Google Tag Manager | ✅ | | | |
106
+ | Segment | ✅ | ✅ | ✅ | ✅ |
107
+ | Mixpanel | ✅ | ✅ | | ✅ |
108
+ | Amplitude | ✅ | ✅ | | |
109
+ | Rudderstack | ✅ | | ✳️ | ✳️ |
110
+ | mParticle | ✅ | | | |
111
+ | PostHog | ✅ | | | |
112
+ | Pendo | ✅ | ❌ | ❌ | ❌ |
113
+ | Heap | ✅ | | | |
114
+ | Snowplow | ✅ | | | |
115
+ | Datadog RUM | ✅ | | | |
116
+ | Custom Function | ✅ | ✅ | ✅ | ✅ |
116
117
 
117
118
  ✳️ Rudderstack's SDKs often use the same format as Segment, so Rudderstack events may be detected as Segment events.
118
119
 
@@ -130,6 +131,24 @@ See [schema.json](schema.json) for a JSON Schema of the output.
130
131
  ```
131
132
  </details>
132
133
 
134
+ <details>
135
+ <summary>Google Tag Manager</summary>
136
+
137
+ **JavaScript/TypeScript**
138
+ ```js
139
+ dataLayer.push({
140
+ event: '<event_name>',
141
+ '<property_name>': '<property_value>'
142
+ });
143
+
144
+ // Or via window
145
+ window.dataLayer.push({
146
+ event: '<event_name>',
147
+ '<property_name>': '<property_value>'
148
+ });
149
+ ```
150
+ </details>
151
+
133
152
  <details>
134
153
  <summary>Segment</summary>
135
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/schema.json CHANGED
@@ -68,6 +68,7 @@
68
68
  "heap",
69
69
  "snowplow",
70
70
  "datadog",
71
+ "gtm",
71
72
  "custom",
72
73
  "unknown"
73
74
  ],
@@ -81,6 +81,12 @@ const ANALYTICS_PROVIDERS = {
81
81
  objectNames: ['datadogRum', 'DD_RUM'],
82
82
  methodName: 'addAction',
83
83
  type: 'member'
84
+ },
85
+ GOOGLE_TAG_MANAGER: {
86
+ name: 'gtm',
87
+ objectNames: ['dataLayer'],
88
+ methodName: 'push',
89
+ type: 'member'
84
90
  }
85
91
  };
86
92
 
@@ -45,20 +45,23 @@ function detectAnalyticsSource(node, customFunction) {
45
45
  function isCustomFunction(node, customFunction) {
46
46
  if (!customFunction) return false;
47
47
 
48
- // Support dot-separated names like "CustomModule.track"
49
- const parts = customFunction.split('.');
48
+ // Support dot-separated names like "CustomModule.track" and chained calls like "getTrackingService().track"
49
+ // Normalize each segment by stripping trailing parentheses
50
+ const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
50
51
 
51
52
  // Simple identifier (no dot)
52
53
  if (parts.length === 1) {
53
- return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction;
54
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
54
55
  }
55
56
 
56
- // For dot-separated names, the callee should be a MemberExpression chain.
57
- if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
57
+ // For dot-separated names, the callee should be a MemberExpression chain,
58
+ // but we also allow CallExpression in the chain (e.g., getService().track)
59
+ const callee = node.callee;
60
+ if (callee.type !== NODE_TYPES.MEMBER_EXPRESSION && callee.type !== NODE_TYPES.CALL_EXPRESSION) {
58
61
  return false;
59
62
  }
60
63
 
61
- return matchesMemberChain(node.callee, parts);
64
+ return matchesMemberChain(callee, parts);
62
65
  }
63
66
 
64
67
  /**
@@ -75,9 +78,8 @@ function matchesMemberChain(memberExpr, parts) {
75
78
  while (currentNode && idx >= 0) {
76
79
  const expectedPart = parts[idx];
77
80
 
78
- // property should match current expectedPart
79
81
  if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
80
- // Ensure property is Identifier and matches
82
+ // Ensure property is Identifier and matches the expected part
81
83
  if (
82
84
  currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
83
85
  currentNode.property.name !== expectedPart
@@ -85,16 +87,25 @@ function matchesMemberChain(memberExpr, parts) {
85
87
  return false;
86
88
  }
87
89
 
88
- // Move to the object of the MemberExpression
90
+ // Move to the object (which could itself be a MemberExpression, Identifier, or CallExpression)
89
91
  currentNode = currentNode.object;
90
92
  idx -= 1;
91
- } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
92
- // We reached the leftmost Identifier; it should match the first part
93
+ continue;
94
+ }
95
+
96
+ // If we encounter a CallExpression in the chain (e.g., getService().track),
97
+ // step into its callee without consuming an expected part.
98
+ if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
99
+ currentNode = currentNode.callee;
100
+ continue;
101
+ }
102
+
103
+ if (currentNode.type === NODE_TYPES.IDENTIFIER) {
93
104
  return idx === 0 && currentNode.name === expectedPart;
94
- } else {
95
- // Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
96
- return false;
97
105
  }
106
+
107
+ // Unexpected node type (e.g., ThisExpression, Literal, etc.)
108
+ return false;
98
109
  }
99
110
 
100
111
  return false;
@@ -20,6 +20,7 @@ const EXTRACTION_STRATEGIES = {
20
20
  googleanalytics: extractGoogleAnalyticsEvent,
21
21
  snowplow: extractSnowplowEvent,
22
22
  mparticle: extractMparticleEvent,
23
+ gtm: extractGTMEvent,
23
24
  custom: extractCustomEvent,
24
25
  default: extractDefaultEvent
25
26
  };
@@ -105,6 +106,43 @@ function extractMparticleEvent(node, constantMap) {
105
106
  return { eventName, propertiesNode };
106
107
  }
107
108
 
109
+ /**
110
+ * Extracts Google Tag Manager event data
111
+ * @param {Object} node - CallExpression node
112
+ * @param {Object} constantMap - Collected constant map
113
+ * @returns {EventData}
114
+ */
115
+ function extractGTMEvent(node, constantMap) {
116
+ if (!node.arguments || node.arguments.length === 0) {
117
+ return { eventName: null, propertiesNode: null };
118
+ }
119
+
120
+ // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
121
+ const firstArg = node.arguments[0];
122
+
123
+ if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) {
124
+ return { eventName: null, propertiesNode: null };
125
+ }
126
+
127
+ // Find the 'event' property
128
+ const eventProperty = findPropertyByKey(firstArg, 'event');
129
+ if (!eventProperty) {
130
+ return { eventName: null, propertiesNode: null };
131
+ }
132
+
133
+ const eventName = getStringValue(eventProperty.value, constantMap);
134
+
135
+ // Create a modified properties node without the 'event' property
136
+ const modifiedPropertiesNode = {
137
+ ...firstArg,
138
+ properties: firstArg.properties.filter(prop =>
139
+ prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event')
140
+ )
141
+ };
142
+
143
+ return { eventName, propertiesNode: modifiedPropertiesNode };
144
+ }
145
+
108
146
  /**
109
147
  * Default event extraction for standard providers
110
148
  * @param {Object} node - CallExpression node
@@ -219,7 +257,11 @@ function getStringValue(node, constantMap = {}) {
219
257
  return node.value;
220
258
  }
221
259
  if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
222
- return resolveMemberExpressionToString(node, constantMap);
260
+ const resolved = resolveMemberExpressionToString(node, constantMap);
261
+ if (resolved) return resolved;
262
+ // Fallback: return a dotted path for member expressions when we cannot
263
+ // resolve to a literal (e.g., imported constants like TELEMETRY_EVENTS.X)
264
+ return memberExpressionToPath(node);
223
265
  }
224
266
  return null;
225
267
  }
@@ -277,6 +319,25 @@ function resolveMemberExpressionToString(node, constantMap) {
277
319
  return null;
278
320
  }
279
321
 
322
+ // Build a dotted path string for a MemberExpression (e.g., OBJ.KEY.SUBKEY)
323
+ function memberExpressionToPath(node) {
324
+ if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
325
+ const parts = [];
326
+ let current = node;
327
+ while (current && current.type === NODE_TYPES.MEMBER_EXPRESSION && !current.computed) {
328
+ if (current.property && current.property.type === NODE_TYPES.IDENTIFIER) {
329
+ parts.unshift(current.property.name);
330
+ } else if (current.property && current.property.type === NODE_TYPES.LITERAL) {
331
+ parts.unshift(String(current.property.value));
332
+ }
333
+ current = current.object;
334
+ }
335
+ if (current && current.type === NODE_TYPES.IDENTIFIER) {
336
+ parts.unshift(current.name);
337
+ }
338
+ return parts.length ? parts.join('.') : null;
339
+ }
340
+
280
341
  module.exports = {
281
342
  extractEventData,
282
343
  processEventData
@@ -12,6 +12,7 @@ const { PARSER_OPTIONS, NODE_TYPES } = require('./constants');
12
12
  const { detectAnalyticsSource } = require('./detectors');
13
13
  const { extractEventData, processEventData } = require('./extractors');
14
14
  const { findWrappingFunction } = require('./utils/function-finder');
15
+ const { collectImportedConstantStringMap } = require('./utils/import-resolver');
15
16
 
16
17
  // Extend walker to support JSX
17
18
  extend(walk.base);
@@ -82,19 +83,15 @@ function parseFile(filePath) {
82
83
  function nodeMatchesCustomFunction(node, fnName) {
83
84
  if (!fnName || !node.callee) return false;
84
85
 
85
- const parts = fnName.split('.');
86
+ // Support chained calls in function name by stripping trailing parens from each segment
87
+ const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
86
88
 
87
89
  // Simple identifier case
88
90
  if (parts.length === 1) {
89
- return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
91
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
90
92
  }
91
93
 
92
- // Member expression chain case
93
- if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
94
- return false;
95
- }
96
-
97
- // Walk the chain from the right-most property to the leftmost object
94
+ // Allow MemberExpression and CallExpression within the chain (e.g., getService().track)
98
95
  let currentNode = node.callee;
99
96
  let idx = parts.length - 1;
100
97
 
@@ -108,13 +105,23 @@ function nodeMatchesCustomFunction(node, fnName) {
108
105
  ) {
109
106
  return false;
110
107
  }
108
+ // step to the object; do not decrement idx for call expressions yet
111
109
  currentNode = currentNode.object;
112
110
  idx -= 1;
113
- } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
111
+ continue;
112
+ }
113
+
114
+ if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
115
+ // descend into the callee of the call without consuming a part
116
+ currentNode = currentNode.callee;
117
+ continue;
118
+ }
119
+
120
+ if (currentNode.type === NODE_TYPES.IDENTIFIER) {
114
121
  return idx === 0 && currentNode.name === expected;
115
- } else {
116
- return false;
117
122
  }
123
+
124
+ return false;
118
125
  }
119
126
 
120
127
  return false;
@@ -183,8 +190,11 @@ function collectConstantStringMap(ast) {
183
190
  function findTrackingEvents(ast, filePath, customConfigs = []) {
184
191
  const events = [];
185
192
 
186
- // Collect constant mappings once per file
187
- const constantMap = collectConstantStringMap(ast);
193
+ // Collect constant mappings once per file (locals + imported)
194
+ const constantMap = {
195
+ ...collectConstantStringMap(ast),
196
+ ...collectImportedConstantStringMap(filePath, ast)
197
+ };
188
198
 
189
199
  walk.ancestor(ast, {
190
200
  [NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
@@ -0,0 +1,233 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const ts = require('typescript');
4
+ const acorn = require('acorn');
5
+ const jsx = require('acorn-jsx');
6
+ const { PARSER_OPTIONS, NODE_TYPES } = require('../constants');
7
+
8
+ const JS_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx'];
9
+ const TS_EXTENSIONS = ['.ts', '.tsx'];
10
+ const ALL_EXTENSIONS = [...JS_EXTENSIONS, ...TS_EXTENSIONS, '.json'];
11
+
12
+ function tryFileWithExtensions(basePath) {
13
+ for (const ext of ALL_EXTENSIONS) {
14
+ const full = basePath + ext;
15
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) return full;
16
+ }
17
+ // index.* inside directory
18
+ if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) {
19
+ for (const ext of ALL_EXTENSIONS) {
20
+ const idx = path.join(basePath, 'index' + ext);
21
+ if (fs.existsSync(idx) && fs.statSync(idx).isFile()) return idx;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function resolveModulePath(specifier, fromDir) {
28
+ // Relative or absolute
29
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
30
+ const abs = path.resolve(fromDir, specifier);
31
+ const direct = tryFileWithExtensions(abs);
32
+ if (direct) return direct;
33
+ } else {
34
+ // Bare or aliased import – search upwards for a matching folder for first segment
35
+ const parts = specifier.split('/');
36
+ let current = fromDir;
37
+ while (true) {
38
+ const candidateRoot = path.join(current, parts[0]);
39
+ if (fs.existsSync(candidateRoot) && fs.statSync(candidateRoot).isDirectory()) {
40
+ const fullBase = path.join(current, specifier);
41
+ const resolved = tryFileWithExtensions(fullBase);
42
+ if (resolved) return resolved;
43
+ }
44
+ const parent = path.dirname(current);
45
+ if (parent === current) break;
46
+ current = parent;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ // Extracts exported constant object maps from a JS/TS file.
53
+ // Returns: { ExportName: { KEY: 'value', ... }, ... }
54
+ function extractExportedConstStringMap(filePath) {
55
+ const code = fs.readFileSync(filePath, 'utf8');
56
+ const map = {};
57
+ const ext = path.extname(filePath).toLowerCase();
58
+
59
+ if (TS_EXTENSIONS.includes(ext)) {
60
+ const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
61
+ for (const stmt of sourceFile.statements) {
62
+ if (ts.isVariableStatement(stmt) && stmt.modifiers && stmt.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) {
63
+ for (const decl of stmt.declarationList.declarations) {
64
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
65
+ const exportName = decl.name.escapedText;
66
+ const obj = unwrapFreezeToObjectLiteral(decl.initializer);
67
+ if (obj) {
68
+ const entries = objectLiteralToStringMapTS(obj);
69
+ if (Object.keys(entries).length > 0) {
70
+ map[exportName] = entries;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ return map;
78
+ }
79
+
80
+ // JS/JSX – parse with acorn + JSX
81
+ const parser = acorn.Parser.extend(jsx());
82
+ let ast;
83
+ try {
84
+ ast = parser.parse(code, { ...PARSER_OPTIONS, sourceType: 'module' });
85
+ } catch (_) {
86
+ return map;
87
+ }
88
+ // Look for export named declarations with const object or Object.freeze
89
+ ast.body.forEach(node => {
90
+ if (node.type === 'ExportNamedDeclaration' && node.declaration && node.declaration.type === 'VariableDeclaration') {
91
+ node.declaration.declarations.forEach(decl => {
92
+ if (decl.id && decl.id.type === NODE_TYPES.IDENTIFIER && decl.init) {
93
+ const exportName = decl.id.name;
94
+ const obj = unwrapFreezeToObjectLiteralJS(decl.init);
95
+ if (obj && obj.type === NODE_TYPES.OBJECT_EXPRESSION) {
96
+ const entries = objectExpressionToStringMapJS(obj);
97
+ if (Object.keys(entries).length > 0) {
98
+ map[exportName] = entries;
99
+ }
100
+ }
101
+ }
102
+ });
103
+ }
104
+ });
105
+
106
+ return map;
107
+ }
108
+
109
+ function unwrapFreezeToObjectLiteral(initializer) {
110
+ if (ts.isObjectLiteralExpression(initializer)) return initializer;
111
+ if (ts.isCallExpression(initializer)) {
112
+ const callee = initializer.expression;
113
+ if (
114
+ ts.isPropertyAccessExpression(callee) &&
115
+ ts.isIdentifier(callee.expression) && callee.expression.escapedText === 'Object' &&
116
+ callee.name.escapedText === 'freeze' &&
117
+ initializer.arguments.length > 0 && ts.isObjectLiteralExpression(initializer.arguments[0])
118
+ ) {
119
+ return initializer.arguments[0];
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function objectLiteralToStringMapTS(obj) {
126
+ const out = {};
127
+ for (const prop of obj.properties) {
128
+ if (!ts.isPropertyAssignment(prop)) continue;
129
+ const key = ts.isIdentifier(prop.name) ? prop.name.escapedText : ts.isStringLiteral(prop.name) ? prop.name.text : null;
130
+ if (!key) continue;
131
+ if (prop.initializer && ts.isStringLiteral(prop.initializer)) {
132
+ out[key] = prop.initializer.text;
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function unwrapFreezeToObjectLiteralJS(init) {
139
+ if (init.type === NODE_TYPES.OBJECT_EXPRESSION) return init;
140
+ if (init.type === 'CallExpression') {
141
+ const callee = init.callee;
142
+ if (
143
+ callee && callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
144
+ callee.object && callee.object.type === NODE_TYPES.IDENTIFIER && callee.object.name === 'Object' &&
145
+ callee.property && callee.property.type === NODE_TYPES.IDENTIFIER && callee.property.name === 'freeze' &&
146
+ init.arguments && init.arguments.length > 0 && init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
147
+ ) {
148
+ return init.arguments[0];
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function objectExpressionToStringMapJS(obj) {
155
+ const out = {};
156
+ obj.properties.forEach(prop => {
157
+ if (!prop.key || !prop.value) return;
158
+ const key = prop.key.name || prop.key.value;
159
+ if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
160
+ out[key] = prop.value.value;
161
+ }
162
+ });
163
+ return out;
164
+ }
165
+
166
+ // Collect imported constant string maps used by this file
167
+ function collectImportedConstantStringMap(filePath, ast) {
168
+ const fromDir = path.dirname(filePath);
169
+ const imports = [];
170
+
171
+ // ES imports
172
+ ast.body.forEach(node => {
173
+ if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
174
+ const spec = node.source.value;
175
+ node.specifiers.forEach(s => {
176
+ if (s.type === 'ImportSpecifier' && s.imported && s.local) {
177
+ imports.push({ local: s.local.name, exported: s.imported.name, spec });
178
+ }
179
+ });
180
+ }
181
+ });
182
+
183
+ // CommonJS requires: const { X } = require('mod')
184
+ ast.body.forEach(node => {
185
+ if (node.type === 'VariableDeclaration') {
186
+ node.declarations.forEach(decl => {
187
+ if (
188
+ decl.init && decl.init.type === 'CallExpression' &&
189
+ decl.init.callee && decl.init.callee.type === NODE_TYPES.IDENTIFIER && decl.init.callee.name === 'require' &&
190
+ decl.init.arguments && decl.init.arguments[0] && decl.init.arguments[0].type === NODE_TYPES.LITERAL
191
+ ) {
192
+ const spec = String(decl.init.arguments[0].value);
193
+ if (decl.id && decl.id.type === 'ObjectPattern') {
194
+ decl.id.properties.forEach(p => {
195
+ if (p.key && p.value && p.key.type === NODE_TYPES.IDENTIFIER && p.value.type === NODE_TYPES.IDENTIFIER) {
196
+ imports.push({ local: p.value.name, exported: p.key.name, spec });
197
+ }
198
+ });
199
+ }
200
+ }
201
+ });
202
+ }
203
+ });
204
+
205
+ const constantMap = {};
206
+ const bySpec = new Map();
207
+ for (const imp of imports) {
208
+ let resolved = bySpec.get(imp.spec);
209
+ if (!resolved) {
210
+ const file = resolveModulePath(imp.spec, fromDir);
211
+ bySpec.set(imp.spec, file || null);
212
+ resolved = file || null;
213
+ }
214
+ if (!resolved) continue;
215
+ try {
216
+ const exported = extractExportedConstStringMap(resolved);
217
+ const entries = exported[imp.exported];
218
+ if (entries && typeof entries === 'object') {
219
+ constantMap[imp.local] = entries;
220
+ }
221
+ } catch (_) { /* ignore resolution errors */ }
222
+ }
223
+
224
+ return constantMap;
225
+ }
226
+
227
+ module.exports = {
228
+ resolveModulePath,
229
+ extractExportedConstStringMap,
230
+ collectImportedConstantStringMap
231
+ };
232
+
233
+
@@ -81,6 +81,12 @@ const ANALYTICS_PROVIDERS = {
81
81
  objectNames: ['datadogRum', 'DD_RUM'],
82
82
  methodName: 'addAction',
83
83
  type: 'member'
84
+ },
85
+ GOOGLE_TAG_MANAGER: {
86
+ name: 'gtm',
87
+ objectNames: ['dataLayer'],
88
+ methodName: 'push',
89
+ type: 'member'
84
90
  }
85
91
  };
86
92
 
@@ -44,16 +44,53 @@ function detectAnalyticsSource(node, customFunction) {
44
44
  * @returns {boolean}
45
45
  */
46
46
  function isCustomFunction(node, customFunction) {
47
- const canBeCustomFunction = ts.isIdentifier(node.expression) ||
48
- ts.isPropertyAccessExpression(node.expression) ||
49
- ts.isCallExpression(node.expression) || // For chained calls like getTracker().track()
50
- ts.isElementAccessExpression(node.expression) || // For array/object access like trackers['analytics'].track()
51
- (node.expression?.expression &&
52
- ts.isPropertyAccessExpression(node.expression.expression) &&
53
- node.expression.expression.expression &&
54
- ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track()
55
-
56
- return canBeCustomFunction && node.expression.getText() === customFunction;
47
+ if (!customFunction || !node || !node.expression) return false;
48
+
49
+ // Normalize signature parts by stripping trailing parentheses from each part
50
+ const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
51
+
52
+ return matchesExpressionChain(node.expression, parts);
53
+ }
54
+
55
+ /**
56
+ * Recursively verify that a CallExpression/PropertyAccessExpression chain matches the expected parts.
57
+ * Supports patterns like getTracker().track, this.props.customTrackFunction6, tracker.track
58
+ */
59
+ function matchesExpressionChain(expr, parts) {
60
+ let current = expr;
61
+ let idx = parts.length - 1;
62
+
63
+ while (current && idx >= 0) {
64
+ const expected = parts[idx];
65
+
66
+ if (ts.isPropertyAccessExpression(current)) {
67
+ const name = current.name?.escapedText;
68
+ if (name !== expected) return false;
69
+ current = current.expression;
70
+ idx -= 1;
71
+ continue;
72
+ }
73
+
74
+ if (ts.isCallExpression(current)) {
75
+ // Step into the callee (e.g., getTracker() -> getTracker)
76
+ current = current.expression;
77
+ continue;
78
+ }
79
+
80
+ if (ts.isIdentifier(current)) {
81
+ return idx === 0 && current.escapedText === expected;
82
+ }
83
+
84
+ // Handle `this` without relying on ts.isThisExpression for compatibility across TS versions
85
+ if (current.kind === ts.SyntaxKind.ThisKeyword || current.kind === ts.SyntaxKind.ThisExpression) {
86
+ return idx === 0 && expected === 'this';
87
+ }
88
+
89
+ // Unsupported expression kind for our matcher
90
+ return false;
91
+ }
92
+
93
+ return false;
57
94
  }
58
95
 
59
96
  /**
@@ -21,6 +21,7 @@ const EXTRACTION_STRATEGIES = {
21
21
  googleanalytics: extractGoogleAnalyticsEvent,
22
22
  snowplow: extractSnowplowEvent,
23
23
  mparticle: extractMparticleEvent,
24
+ gtm: extractGTMEvent,
24
25
  custom: extractCustomEvent,
25
26
  default: extractDefaultEvent
26
27
  };
@@ -126,6 +127,60 @@ function extractMparticleEvent(node, checker, sourceFile) {
126
127
  return { eventName, propertiesNode };
127
128
  }
128
129
 
130
+ /**
131
+ * Extracts Google Tag Manager event data
132
+ * @param {Object} node - CallExpression node
133
+ * @param {Object} checker - TypeScript type checker
134
+ * @param {Object} sourceFile - TypeScript source file
135
+ * @returns {EventData}
136
+ */
137
+ function extractGTMEvent(node, checker, sourceFile) {
138
+ if (!node.arguments || node.arguments.length === 0) {
139
+ return { eventName: null, propertiesNode: null };
140
+ }
141
+
142
+ // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' })
143
+ const firstArg = node.arguments[0];
144
+
145
+ if (!ts.isObjectLiteralExpression(firstArg)) {
146
+ return { eventName: null, propertiesNode: null };
147
+ }
148
+
149
+ // Find the 'event' property
150
+ const eventProperty = findPropertyByKey(firstArg, 'event');
151
+ if (!eventProperty) {
152
+ return { eventName: null, propertiesNode: null };
153
+ }
154
+
155
+ const eventName = getStringValue(eventProperty.initializer, checker, sourceFile);
156
+
157
+ // Create a modified properties node without the 'event' property
158
+ const modifiedProperties = firstArg.properties.filter(prop => {
159
+ if (ts.isPropertyAssignment(prop) && prop.name) {
160
+ if (ts.isIdentifier(prop.name)) {
161
+ return prop.name.escapedText !== 'event';
162
+ }
163
+ if (ts.isStringLiteral(prop.name)) {
164
+ return prop.name.text !== 'event';
165
+ }
166
+ }
167
+ return true;
168
+ });
169
+
170
+ // Create a synthetic object literal with the filtered properties
171
+ const modifiedPropertiesNode = ts.factory.createObjectLiteralExpression(modifiedProperties);
172
+
173
+ // Copy source positions for proper analysis
174
+ if (firstArg.pos !== undefined) {
175
+ modifiedPropertiesNode.pos = firstArg.pos;
176
+ }
177
+ if (firstArg.end !== undefined) {
178
+ modifiedPropertiesNode.end = firstArg.end;
179
+ }
180
+
181
+ return { eventName, propertiesNode: modifiedPropertiesNode };
182
+ }
183
+
129
184
  /**
130
185
  * Custom extraction
131
186
  * @param {Object} node - CallExpression node
@@ -4,15 +4,27 @@ function parseCustomFunctionSignature(signature) {
4
4
  return null;
5
5
  }
6
6
 
7
- // Match function name and optional parameter list
8
- // Supports names with module prefix like Module.func
9
- const match = signature.match(/^\s*([A-Za-z0-9_.]+)\s*(?:\(([^)]*)\))?\s*$/);
10
- if (!match) {
11
- return null;
12
- }
7
+ const trimmed = signature.trim();
13
8
 
14
- const functionName = match[1].trim();
15
- const paramsPart = match[2];
9
+ // Two cases:
10
+ // 1) Full signature with params at the end (e.g., Module.track(EVENT_NAME, PROPERTIES)) → parse params
11
+ // 2) Name-only (including chains with internal calls, e.g., getService().track) → no params
12
+ let functionName;
13
+ let paramsPart = null;
14
+
15
+ if (/\)\s*$/.test(trimmed)) {
16
+ // Looks like it ends with a parameter list – extract the final (...) only
17
+ const lastOpenIdx = trimmed.lastIndexOf('(');
18
+ const lastCloseIdx = trimmed.lastIndexOf(')');
19
+ if (lastOpenIdx === -1 || lastCloseIdx < lastOpenIdx) {
20
+ return null;
21
+ }
22
+ functionName = trimmed.slice(0, lastOpenIdx).trim();
23
+ paramsPart = trimmed.slice(lastOpenIdx + 1, lastCloseIdx);
24
+ } else {
25
+ // No trailing params – treat the whole string as the function name
26
+ functionName = trimmed;
27
+ }
16
28
 
17
29
  // Default legacy behaviour: EVENT_NAME, PROPERTIES
18
30
  if (!paramsPart) {