@flisk/analyze-tracking 0.7.1 → 0.7.2

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
@@ -1,8 +1,8 @@
1
1
  # @flisk/analyze-tracking
2
2
 
3
- Automatically document your analytics setup by analyzing tracking code and generating data schemas from tools like Segment, Amplitude, Mixpanel, and more 🚀.
3
+ Automatically document your analytics setup by analyzing tracking code and generating data schemas from tools like Segment, Amplitude, Mixpanel, and more 🚀
4
4
 
5
- [![NPM version](https://img.shields.io/npm/v/@flisk/analyze-tracking.svg)](https://www.npmjs.com/package/@flisk/analyze-tracking)
5
+ [![NPM version](https://img.shields.io/npm/v/@flisk/analyze-tracking.svg)](https://www.npmjs.com/package/@flisk/analyze-tracking) [![Tests](https://github.com/fliskdata/analyze-tracking/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/fliskdata/analyze-tracking/actions/workflows/tests.yml)
6
6
 
7
7
 
8
8
  ## Why Use @flisk/analyze-tracking?
@@ -30,7 +30,7 @@ npx @flisk/analyze-tracking /path/to/project [options]
30
30
  - `-o, --output <output_file>`: Name of the output file (default: `tracking-schema.yaml`)
31
31
  - `-c, --customFunction <function_name>`: Specify a custom tracking function
32
32
 
33
- 🔑&nbsp; **Important:** If you are using `generateDescription`, you must set the appropriate credentials for the provider you are using as an environment variable. OpenAI uses `OPENAI_API_KEY` and Google Vertex AI uses `GOOGLE_APPLICATION_CREDENTIALS`.
33
+ 🔑&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`.
34
34
 
35
35
  <details>
36
36
  <summary>Note on Custom Functions 💡</summary>
@@ -96,10 +96,10 @@ See [schema.json](schema.json) for a JSON Schema of the output.
96
96
  | Mixpanel | ✅ | ✅ | ✅ | ✅ |
97
97
  | Amplitude | ✅ | ✅ | ❌ | ✅ |
98
98
  | Rudderstack | ✅ | ✅ | ✳️ | ✳️ |
99
- | mParticle | ✅ | | ❌ | ❌ |
99
+ | mParticle | ✅ | | ❌ | ❌ |
100
100
  | PostHog | ✅ | ✅ | ✅ | ✅ |
101
- | Pendo | ✅ | | ❌ | ❌ |
102
- | Heap | ✅ | | ❌ | ❌ |
101
+ | Pendo | ✅ | | ❌ | ❌ |
102
+ | Heap | ✅ | | ❌ | ❌ |
103
103
  | Snowplow | ✅ | ✅ | ✅ | ✅ |
104
104
  | Custom Function | ✅ | ✅ | ✅ | ✅ |
105
105
 
@@ -198,16 +198,22 @@ See [schema.json](schema.json) for a JSON Schema of the output.
198
198
 
199
199
  **JavaScript/TypeScript**
200
200
  ```js
201
- amplitude.logEvent('<event_name>', {
201
+ amplitude.track('<event_name>', {
202
202
  <event_parameters>
203
203
  });
204
204
  ```
205
205
 
206
206
  **Python**
207
207
  ```python
208
- amplitude.track('<event_name>', {
209
- '<property_name>': '<property_value>'
210
- })
208
+ client.track(
209
+ BaseEvent(
210
+ event_type="<event_name>",
211
+ user_id="<user_id>",
212
+ event_properties={
213
+ "<property_name>": "<property_value>",
214
+ },
215
+ )
216
+ )
211
217
  ```
212
218
 
213
219
  **Go**
@@ -266,19 +272,10 @@ See [schema.json](schema.json) for a JSON Schema of the output.
266
272
 
267
273
  **JavaScript/TypeScript**
268
274
  ```js
269
- mParticle.logEvent('<event_name>', {
275
+ mParticle.logEvent('<event_name>', mParticle.EventType.<event_type>, {
270
276
  '<property_name>': '<property_value>'
271
277
  });
272
278
  ```
273
-
274
- **Python**
275
- ```python
276
- mParticle.logEvent('<event_name>', {
277
- '<property_name>': '<property_value>'
278
- })
279
- ```
280
-
281
-
282
279
  </details>
283
280
 
284
281
  <details>
@@ -293,13 +290,9 @@ See [schema.json](schema.json) for a JSON Schema of the output.
293
290
 
294
291
  **Python**
295
292
  ```python
296
- posthog.capture(
297
- 'distinct_id',
298
- '<event_name>',
299
- {
300
- '<property_name>': '<property_value>'
301
- }
302
- )
293
+ posthog.capture('distinct_id', '<event_name>', {
294
+ '<property_name>': '<property_value>'
295
+ })
303
296
  # Or
304
297
  posthog.capture(
305
298
  'distinct_id',
@@ -377,61 +370,42 @@ See [schema.json](schema.json) for a JSON Schema of the output.
377
370
 
378
371
  **JavaScript/TypeScript**
379
372
  ```js
380
- snowplow('trackStructEvent', {
373
+ tracker.track(buildStructEvent({
374
+ action: '<event_name>',
381
375
  category: '<category>',
382
- action: '<action>',
383
376
  label: '<label>',
384
377
  property: '<property>',
385
- value: '<value>'
386
- });
378
+ value: <value>
379
+ }));
387
380
  ```
388
381
 
389
382
  **Python**
390
383
  ```python
391
- # Direct tracking
392
- trackStructEvent({
393
- 'category': '<category>',
394
- 'action': '<action>',
395
- 'label': '<label>',
396
- 'property': '<property>',
397
- 'value': '<value>'
398
- })
399
-
400
- # Builder pattern
401
- buildStructEvent({
402
- 'category': '<category>',
403
- 'action': '<action>',
404
- 'label': '<label>',
405
- 'property': '<property>',
406
- 'value': '<value>'
407
- })
408
-
409
- # Function call pattern
410
- snowplow('trackStructEvent', {
411
- 'category': '<category>',
412
- 'action': '<action>',
413
- 'label': '<label>',
414
- 'property': '<property>',
415
- 'value': '<value>'
416
- })
384
+ tracker.track(StructuredEvent(
385
+ action="<event_name>",
386
+ category="<category>",
387
+ label="<label>",
388
+ property_="<property>",
389
+ value=<value>,
390
+ ))
417
391
  ```
418
392
 
419
393
  **Ruby**
420
394
  ```ruby
421
395
  tracker.track_struct_event(
396
+ action: '<event_name>',
422
397
  category: '<category>',
423
- action: '<action>',
424
398
  label: '<label>',
425
399
  property: '<property>',
426
- value: '<value>'
400
+ value: <value>
427
401
  )
428
402
  ```
429
403
 
430
404
  **Go**
431
405
  ```go
432
406
  tracker.TrackStructEvent(sp.StructuredEvent{
407
+ Action: sp.NewString("<event_name>"),
433
408
  Category: sp.NewString("<category>"),
434
- Action: sp.NewString("<action>"),
435
409
  Label: sp.NewString("<label>"),
436
410
  Property: sp.NewString("<property>"),
437
411
  Value: sp.NewFloat64(<value>),
@@ -441,4 +415,4 @@ See [schema.json](schema.json) for a JSON Schema of the output.
441
415
 
442
416
 
443
417
  ## Contribute
444
- We're actively improving this package. Found a bug? Want to request a feature? Open an issue or contribute directly!
418
+ We're actively improving this package. Found a bug? Have a feature request? Open an issue or submit a pull request!
package/package.json CHANGED
@@ -1,14 +1,26 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "analyze-tracking": "bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
- "test": "jest"
10
+ "test": "node tests",
11
+ "test:js": "node --test tests/analyzeJsFile.test.js",
12
+ "test:ts": "node --test tests/analyzeTsFile.test.js",
13
+ "test:python": "node --experimental-vm-modules --test tests/analyzePythonFile.test.js",
14
+ "test:ruby": "node --experimental-vm-modules --test tests/analyzeRubyFile.test.js",
15
+ "test:go": "node --test tests/analyzeGoFile.test.js",
16
+ "test:cli": "node --test tests/cli.test.js",
17
+ "test:schema": "node --test tests/schema.test.js"
11
18
  },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "schema.json"
23
+ ],
12
24
  "repository": {
13
25
  "type": "git",
14
26
  "url": "git+https://github.com/fliskdata/analyze-tracking.git"
@@ -38,6 +50,7 @@
38
50
  "zod": "^3.24.4"
39
51
  },
40
52
  "devDependencies": {
41
- "jest": "^29.7.0"
53
+ "ajv": "^8.17.1",
54
+ "lodash": "^4.17.21"
42
55
  }
43
56
  }
@@ -33,10 +33,20 @@ function analyzeJsFile(filePath, customFunction) {
33
33
  if (source === 'googleanalytics' && node.arguments.length >= 3) {
34
34
  eventName = node.arguments[1]?.value || null;
35
35
  propertiesNode = node.arguments[2];
36
- } else if (source === 'snowplow' && node.arguments.length >= 2) {
37
- const actionProperty = node.arguments[1].properties.find(prop => prop.key.name === 'action');
38
- eventName = actionProperty ? actionProperty.value.value : null;
39
- propertiesNode = node.arguments[1];
36
+ } else if (source === 'snowplow' && node.arguments.length > 0) {
37
+ // Snowplow pattern: tracker.track(buildStructEvent({...}))
38
+ const firstArg = node.arguments[0];
39
+ if (firstArg.type === 'CallExpression' && firstArg.arguments.length > 0) {
40
+ const structEventArg = firstArg.arguments[0];
41
+ if (structEventArg.type === 'ObjectExpression') {
42
+ const actionProperty = structEventArg.properties.find(prop => prop.key.name === 'action');
43
+ eventName = actionProperty ? actionProperty.value.value : null;
44
+ propertiesNode = structEventArg;
45
+ }
46
+ }
47
+ } else if (source === 'mparticle' && node.arguments.length >= 3) {
48
+ eventName = node.arguments[0]?.value || null;
49
+ propertiesNode = node.arguments[2];
40
50
  } else if (node.arguments.length >= 2) {
41
51
  eventName = node.arguments[0]?.value || null;
42
52
  propertiesNode = node.arguments[1];
@@ -46,7 +56,12 @@ function analyzeJsFile(filePath, customFunction) {
46
56
  const functionName = findWrappingFunctionJs(node, ancestors);
47
57
 
48
58
  if (eventName && propertiesNode && propertiesNode.type === 'ObjectExpression') {
49
- const properties = extractJsProperties(propertiesNode);
59
+ let properties = extractJsProperties(propertiesNode);
60
+
61
+ // For Snowplow, remove 'action' from properties since it's used as the event name
62
+ if (source === 'snowplow' && properties.action) {
63
+ delete properties.action;
64
+ }
50
65
 
51
66
  events.push({
52
67
  eventName,
@@ -25,6 +25,7 @@ async function analyzePythonFile(filePath, customFunction) {
25
25
  py.globals.set('code', code);
26
26
  py.globals.set('filepath', filePath);
27
27
  py.globals.set('custom_function', customFunction || null);
28
+ py.globals.set('__name__', null);
28
29
 
29
30
  // Run the Python analyzer
30
31
  py.runPython(analyzerCode);
@@ -46,12 +46,16 @@ class TrackingVisitor {
46
46
  const objectName = node.receiver.name;
47
47
  const methodName = node.name;
48
48
 
49
- // Segment
50
- if (objectName === 'Analytics' && methodName === 'track') return 'segment';
49
+ // Segment and Rudderstack (both use similar format)
50
+ // Analytics.track (Segment) or analytics.track (Rudderstack)
51
+ if ((objectName === 'Analytics' || objectName === 'analytics') && methodName === 'track') {
52
+ // Try to determine if it's Rudderstack based on context
53
+ // For now, we'll treat lowercase 'analytics' as Rudderstack
54
+ return objectName === 'analytics' ? 'rudderstack' : 'segment';
55
+ }
51
56
 
52
57
  // Mixpanel (Ruby SDK uses Mixpanel::Tracker instance)
53
- if (methodName === 'track' && node.receiver.type === 'CallNode' &&
54
- node.receiver.name === 'tracker') return 'mixpanel';
58
+ if (methodName === 'track' && objectName === 'tracker') return 'mixpanel';
55
59
 
56
60
  // PostHog
57
61
  if (objectName === 'posthog' && methodName === 'capture') return 'posthog';
@@ -67,7 +71,8 @@ class TrackingVisitor {
67
71
  }
68
72
 
69
73
  extractEventName(node, source) {
70
- if (source === 'segment') {
74
+ if (source === 'segment' || source === 'rudderstack') {
75
+ // Both Segment and Rudderstack use the same format
71
76
  const params = node.arguments_.arguments_[0].elements;
72
77
  const eventProperty = params.find(param => param?.key?.unescaped?.value === 'event');
73
78
  return eventProperty?.value?.unescaped?.value || null;
@@ -111,7 +116,8 @@ class TrackingVisitor {
111
116
  async extractProperties(node, source) {
112
117
  const { HashNode, ArrayNode } = await import('@ruby/prism');
113
118
 
114
- if (source === 'segment') {
119
+ if (source === 'segment' || source === 'rudderstack') {
120
+ // Both Segment and Rudderstack use the same format
115
121
  const params = node.arguments_.arguments_[0].elements;
116
122
  const properties = {};
117
123
 
@@ -158,10 +164,10 @@ class TrackingVisitor {
158
164
  const args = node.arguments_.arguments_;
159
165
  const properties = {};
160
166
 
161
- // Add distinct_id as property
162
- if (args && args.length > 0 && args[0]?.unescaped?.value) {
167
+ // Add distinct_id as property (even if it's a variable)
168
+ if (args && args.length > 0) {
163
169
  properties.distinct_id = {
164
- type: 'string'
170
+ type: await this.getValueType(args[0])
165
171
  };
166
172
  }
167
173
 
@@ -300,7 +306,7 @@ class TrackingVisitor {
300
306
  }
301
307
 
302
308
  async visit(node) {
303
- const { CallNode, ProgramNode, StatementsNode, DefNode, IfNode, BlockNode, ArgumentsNode, HashNode, AssocNode, ClassNode } = await import('@ruby/prism');
309
+ const { CallNode, ProgramNode, StatementsNode, DefNode, IfNode, BlockNode, ArgumentsNode, HashNode, AssocNode, ClassNode, ModuleNode } = await import('@ruby/prism');
304
310
  if (!node) return;
305
311
 
306
312
  this.ancestors.push(node);
@@ -344,6 +350,10 @@ class TrackingVisitor {
344
350
  if (node.body) {
345
351
  await this.visit(node.body);
346
352
  }
353
+ } else if (node instanceof ModuleNode) {
354
+ if (node.body) {
355
+ await this.visit(node.body);
356
+ }
347
357
  } else if (node instanceof DefNode) {
348
358
  if (node.body) {
349
359
  await this.visit(node.body);
@@ -1,5 +1,65 @@
1
1
  const ts = require('typescript');
2
- const { detectSourceTs, findWrappingFunctionTs, extractTsProperties } = require('./helpers');
2
+ const { detectSourceTs, findWrappingFunctionTs, extractTsProperties, resolveIdentifierToInitializer, extractInterfaceProperties } = require('./helpers');
3
+
4
+ function resolveUnresolvedTypes(properties, checker, sourceFile) {
5
+ const resolved = {};
6
+
7
+ for (const [key, value] of Object.entries(properties)) {
8
+ if (value && typeof value === 'object') {
9
+ if (value.__unresolved) {
10
+ // Try to find and resolve the type
11
+ const typeName = value.__unresolved;
12
+ delete value.__unresolved;
13
+
14
+ // This is a simplified approach - in practice, you'd need to find the actual type declaration
15
+ // For now, we'll keep the object type but remove the unresolved marker
16
+ resolved[key] = value;
17
+ } else if (value.type === 'array' && value.items && value.items.__unresolved) {
18
+ // Handle unresolved array element types
19
+ const itemTypeName = value.items.__unresolved;
20
+ delete value.items.__unresolved;
21
+ resolved[key] = value;
22
+ } else if (value.type === 'array' && value.items && typeof value.items.type === 'string' && value.items.type.includes(' ')) {
23
+ // Handle types like "readonly Product" - extract the actual type name
24
+ const typeString = value.items.type;
25
+ const actualType = typeString.replace(/^readonly\s+/, '').trim();
26
+
27
+ // If it looks like a custom type, mark it as object
28
+ if (actualType[0] === actualType[0].toUpperCase() && !actualType.includes('<')) {
29
+ resolved[key] = {
30
+ ...value,
31
+ items: {
32
+ type: 'object'
33
+ }
34
+ };
35
+ } else {
36
+ resolved[key] = value;
37
+ }
38
+ } else if (value.type === 'object' && value.properties) {
39
+ // Recursively resolve nested properties
40
+ resolved[key] = {
41
+ ...value,
42
+ properties: resolveUnresolvedTypes(value.properties, checker, sourceFile)
43
+ };
44
+ } else if (value.type === 'array' && value.items && value.items.properties) {
45
+ // Recursively resolve array item properties
46
+ resolved[key] = {
47
+ ...value,
48
+ items: {
49
+ ...value.items,
50
+ properties: value.items.properties ? resolveUnresolvedTypes(value.items.properties, checker, sourceFile) : undefined
51
+ }
52
+ };
53
+ } else {
54
+ resolved[key] = value;
55
+ }
56
+ } else {
57
+ resolved[key] = value;
58
+ }
59
+ }
60
+
61
+ return resolved;
62
+ }
3
63
 
4
64
  function analyzeTsFile(filePath, program, customFunction) {
5
65
  let events = [];
@@ -24,10 +84,50 @@ function analyzeTsFile(filePath, program, customFunction) {
24
84
  if (source === 'googleanalytics' && node.arguments.length >= 3) {
25
85
  eventName = node.arguments[1]?.text || null;
26
86
  propertiesNode = node.arguments[2];
27
- } else if (source === 'snowplow' && node.arguments.length >= 2) {
28
- const actionProperty = node.arguments[1].properties.find(prop => prop.name.escapedText === 'action');
29
- eventName = actionProperty ? actionProperty.initializer.text : null;
30
- propertiesNode = node.arguments[1];
87
+ } else if (source === 'snowplow' && node.arguments.length > 0) {
88
+ // Snowplow pattern: tracker.track(buildStructEvent({...})) or tracker.track(payload)
89
+ const firstArg = node.arguments[0];
90
+
91
+ // Check if it's a direct buildStructEvent call
92
+ if (ts.isCallExpression(firstArg) &&
93
+ ts.isIdentifier(firstArg.expression) &&
94
+ firstArg.expression.escapedText === 'buildStructEvent' &&
95
+ firstArg.arguments.length > 0) {
96
+ const structEventArg = firstArg.arguments[0];
97
+ if (ts.isObjectLiteralExpression(structEventArg)) {
98
+ // Find the action property for event name
99
+ const actionProp = structEventArg.properties.find(
100
+ prop => prop.name && prop.name.escapedText === 'action'
101
+ );
102
+ if (actionProp && actionProp.initializer && ts.isStringLiteral(actionProp.initializer)) {
103
+ eventName = actionProp.initializer.text;
104
+ propertiesNode = structEventArg;
105
+ }
106
+ }
107
+ }
108
+ // Check if it's a variable reference (e.g., const payload = buildStructEvent({...}))
109
+ else if (ts.isIdentifier(firstArg)) {
110
+ const resolvedNode = resolveIdentifierToInitializer(checker, firstArg, sourceFile);
111
+ if (resolvedNode && ts.isCallExpression(resolvedNode) &&
112
+ ts.isIdentifier(resolvedNode.expression) &&
113
+ resolvedNode.expression.escapedText === 'buildStructEvent' &&
114
+ resolvedNode.arguments.length > 0) {
115
+ const structEventArg = resolvedNode.arguments[0];
116
+ if (ts.isObjectLiteralExpression(structEventArg)) {
117
+ const actionProp = structEventArg.properties.find(
118
+ prop => prop.name && prop.name.escapedText === 'action'
119
+ );
120
+ if (actionProp && actionProp.initializer && ts.isStringLiteral(actionProp.initializer)) {
121
+ eventName = actionProp.initializer.text;
122
+ propertiesNode = structEventArg;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ } else if (source === 'mparticle' && node.arguments.length >= 3) {
128
+ // mParticle: first param is event name, second is event type (ignored), third is properties
129
+ eventName = node.arguments[0]?.text || null;
130
+ propertiesNode = node.arguments[2];
31
131
  } else if (node.arguments.length >= 2) {
32
132
  eventName = node.arguments[0]?.text || null;
33
133
  propertiesNode = node.arguments[1];
@@ -36,17 +136,40 @@ function analyzeTsFile(filePath, program, customFunction) {
36
136
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
37
137
  const functionName = findWrappingFunctionTs(node);
38
138
 
39
- if (eventName && propertiesNode && ts.isObjectLiteralExpression(propertiesNode)) {
139
+ if (eventName && propertiesNode) {
40
140
  try {
41
- const properties = extractTsProperties(checker, propertiesNode);
42
- events.push({
43
- eventName,
44
- source,
45
- properties,
46
- filePath,
47
- line,
48
- functionName
49
- });
141
+ let properties = null;
142
+
143
+ // Check if properties is an object literal
144
+ if (ts.isObjectLiteralExpression(propertiesNode)) {
145
+ properties = extractTsProperties(checker, propertiesNode);
146
+ }
147
+ // Check if properties is an identifier (variable reference)
148
+ else if (ts.isIdentifier(propertiesNode)) {
149
+ const resolvedNode = resolveIdentifierToInitializer(checker, propertiesNode, sourceFile);
150
+ if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
151
+ properties = extractTsProperties(checker, resolvedNode);
152
+ }
153
+ }
154
+
155
+ if (properties) {
156
+ // For Snowplow, remove 'action' from properties since it's used as the event name
157
+ if (source === 'snowplow' && properties.action) {
158
+ delete properties.action;
159
+ }
160
+
161
+ // Clean up any unresolved type markers
162
+ const cleanedProperties = resolveUnresolvedTypes(properties, checker, sourceFile);
163
+
164
+ events.push({
165
+ eventName,
166
+ source,
167
+ properties: cleanedProperties,
168
+ filePath,
169
+ line,
170
+ functionName
171
+ });
172
+ }
50
173
  } catch (propertyError) {
51
174
  console.error(`Error extracting properties in ${filePath} at line ${line}`);
52
175
  }