@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.
@@ -130,26 +130,36 @@ class TrackingVisitor(ast.NodeVisitor):
130
130
  if obj_id == 'analytics' and method_name == 'track':
131
131
  return 'segment'
132
132
  # Mixpanel
133
- if obj_id == 'mixpanel' and method_name == 'track':
133
+ if obj_id == 'mp' and method_name == 'track':
134
134
  return 'mixpanel'
135
- # Amplitude
136
- if obj_id == 'amplitude' and method_name == 'track':
137
- return 'amplitude'
138
135
  # Rudderstack
139
136
  if obj_id == 'rudder_analytics' and method_name == 'track':
140
137
  return 'rudderstack'
141
- # mParticle
142
- if obj_id == 'mParticle' and method_name == 'logEvent':
143
- return 'mparticle'
144
138
  # PostHog
145
139
  if obj_id == 'posthog' and method_name == 'capture':
146
140
  return 'posthog'
147
- # Pendo
148
- if obj_id == 'pendo' and method_name == 'track':
149
- return 'pendo'
150
- # Heap
151
- if obj_id == 'heap' and method_name == 'track':
152
- return 'heap'
141
+
142
+ # Amplitude - tracker with BaseEvent
143
+ if method_name == 'track' and len(node.args) >= 1:
144
+ first_arg = node.args[0]
145
+ # Check if the first argument is a BaseEvent call
146
+ if isinstance(first_arg, ast.Call) and isinstance(first_arg.func, ast.Name):
147
+ if first_arg.func.id == 'BaseEvent':
148
+ return 'amplitude'
149
+
150
+ # Snowplow - tracker with StructuredEvent
151
+ if method_name == 'track' and len(node.args) >= 1:
152
+ first_arg = node.args[0]
153
+ # Check if the first argument is a StructuredEvent call
154
+ if isinstance(first_arg, ast.Call) and isinstance(first_arg.func, ast.Name):
155
+ if first_arg.func.id == 'StructuredEvent':
156
+ return 'snowplow'
157
+ # Also check if it's a variable that might be a StructuredEvent
158
+ elif isinstance(first_arg, ast.Name):
159
+ # Check if we can find the assignment of this variable
160
+ # For now, we'll assume any tracker.track() with a single argument is Snowplow
161
+ if obj_id == 'tracker':
162
+ return 'snowplow'
153
163
 
154
164
  # Check for Snowplow struct event patterns
155
165
  if isinstance(node.func, ast.Name) and node.func.id in ['trackStructEvent', 'buildStructEvent']:
@@ -169,14 +179,20 @@ class TrackingVisitor(ast.NodeVisitor):
169
179
 
170
180
  def extract_event_name(self, node, source):
171
181
  try:
172
- if source in ['segment', 'mixpanel', 'amplitude', 'rudderstack', 'pendo', 'heap', 'custom']:
173
- # Standard format: library.track('event_name', {...})
174
- # Custom function follows same format: customFunction('event_name', {...})
175
- if len(node.args) >= 1 and isinstance(node.args[0], ast.Constant):
176
- return node.args[0].value
177
-
178
- elif source == 'mparticle':
179
- # mParticle: mParticle.logEvent('event_name', {...})
182
+ if source in ['segment', 'rudderstack', 'mixpanel']:
183
+ # Segment/Rudderstack/Mixpanel format: library.track(user_id/distinct_id, 'event_name', {...})
184
+ if len(node.args) >= 2 and isinstance(node.args[1], ast.Constant):
185
+ return node.args[1].value
186
+ elif source == 'amplitude':
187
+ # Amplitude format: client.track(BaseEvent(event_type='event_name', ...))
188
+ if len(node.args) >= 1 and isinstance(node.args[0], ast.Call):
189
+ base_event_call = node.args[0]
190
+ # Look for event_type in keyword arguments
191
+ for keyword in base_event_call.keywords:
192
+ if keyword.arg == 'event_type' and isinstance(keyword.value, ast.Constant):
193
+ return keyword.value.value
194
+ elif source in ['custom']:
195
+ # Standard format: customFunction('event_name', {...})
180
196
  if len(node.args) >= 1 and isinstance(node.args[0], ast.Constant):
181
197
  return node.args[0].value
182
198
 
@@ -195,28 +211,28 @@ class TrackingVisitor(ast.NodeVisitor):
195
211
  return node.args[1].value
196
212
 
197
213
  elif source == 'snowplow':
198
- # Snowplow struct events use 'action' as the event name
214
+ # Snowplow has multiple patterns
199
215
  if len(node.args) >= 1:
200
- # Handle different snowplow call patterns
201
- props_node = None
202
-
203
- # Direct trackStructEvent/buildStructEvent call
204
- if isinstance(node.func, ast.Name) and node.func.id in ['trackStructEvent', 'buildStructEvent']:
205
- if len(node.args) >= 1:
206
- props_node = node.args[0]
216
+ first_arg = node.args[0]
207
217
 
208
- # snowplow('trackStructEvent', {...}) pattern
209
- elif isinstance(node.func, ast.Name) and node.func.id == 'snowplow':
210
- if len(node.args) >= 2:
211
- props_node = node.args[1]
212
-
213
- # Extract 'action' from properties
214
- if props_node and isinstance(props_node, ast.Dict):
215
- for i, key_node in enumerate(props_node.keys):
216
- if isinstance(key_node, ast.Constant) and key_node.value == 'action':
217
- value_node = props_node.values[i]
218
- if isinstance(value_node, ast.Constant):
219
- return value_node.value
218
+ # Pattern 1: tracker.track(StructuredEvent(...))
219
+ if isinstance(first_arg, ast.Call) and isinstance(first_arg.func, ast.Name):
220
+ if first_arg.func.id == 'StructuredEvent':
221
+ # Look for action in keyword arguments
222
+ for keyword in first_arg.keywords:
223
+ if keyword.arg == 'action' and isinstance(keyword.value, ast.Constant):
224
+ return keyword.value.value
225
+
226
+ # Pattern 2 & 3: For other Snowplow patterns
227
+ # For Snowplow struct events
228
+ if isinstance(node.func, ast.Name) and node.func.id in ['trackStructEvent', 'buildStructEvent']:
229
+ if len(node.args) >= 1:
230
+ props_node = node.args[0]
231
+
232
+ # snowplow('trackStructEvent', {...}) pattern
233
+ elif isinstance(node.func, ast.Name) and node.func.id == 'snowplow':
234
+ if len(node.args) >= 2:
235
+ props_node = node.args[1]
220
236
  except:
221
237
  pass
222
238
  return None
@@ -227,9 +243,53 @@ class TrackingVisitor(ast.NodeVisitor):
227
243
  props_node = None
228
244
 
229
245
  # Get the properties object based on source
230
- if source in ['segment', 'mixpanel', 'amplitude', 'rudderstack', 'mparticle', 'pendo', 'heap', 'custom']:
231
- # Standard format: library.track('event_name', {properties})
232
- # Custom function follows same format: customFunction('event_name', {...})
246
+ if source in ['segment', 'rudderstack']:
247
+ # Segment/Rudderstack format: analytics.track(user_id, 'event_name', {properties})
248
+ # Add user_id as a property if it's not null
249
+ if len(node.args) > 0:
250
+ user_id_node = node.args[0]
251
+ if isinstance(user_id_node, ast.Constant) and user_id_node.value is not None:
252
+ properties["user_id"] = {"type": "string"}
253
+ elif isinstance(user_id_node, ast.Name):
254
+ # It's a variable reference, include it as a property
255
+ properties["user_id"] = {"type": "string"}
256
+
257
+ if len(node.args) > 2:
258
+ props_node = node.args[2]
259
+ elif source == 'mixpanel':
260
+ # Mixpanel format: mp.track(distinct_id, 'event_name', {properties})
261
+ # Add distinct_id as a property if it's not null
262
+ if len(node.args) > 0:
263
+ distinct_id_node = node.args[0]
264
+ if isinstance(distinct_id_node, ast.Constant) and distinct_id_node.value is not None:
265
+ properties["distinct_id"] = {"type": "string"}
266
+ elif isinstance(distinct_id_node, ast.Name):
267
+ # It's a variable reference, include it as a property
268
+ properties["distinct_id"] = {"type": "string"}
269
+
270
+ if len(node.args) > 2:
271
+ props_node = node.args[2]
272
+ elif source == 'amplitude':
273
+ # Amplitude format: client.track(BaseEvent(event_type='...', user_id='...', event_properties={...}))
274
+ if len(node.args) >= 1 and isinstance(node.args[0], ast.Call):
275
+ base_event_call = node.args[0]
276
+
277
+ # First, check for user_id parameter
278
+ for keyword in base_event_call.keywords:
279
+ if keyword.arg == 'user_id':
280
+ if isinstance(keyword.value, ast.Constant) and keyword.value.value is not None:
281
+ properties["user_id"] = {"type": "string"}
282
+ elif isinstance(keyword.value, ast.Name):
283
+ # It's a variable reference, include it as a property
284
+ properties["user_id"] = {"type": "string"}
285
+
286
+ # Then look for event_properties
287
+ for keyword in base_event_call.keywords:
288
+ if keyword.arg == 'event_properties' and isinstance(keyword.value, ast.Dict):
289
+ props_node = keyword.value
290
+ break
291
+ elif source in ['custom']:
292
+ # Standard format: customFunction('event_name', {properties})
233
293
  if len(node.args) > 1:
234
294
  props_node = node.args[1]
235
295
 
@@ -272,6 +332,48 @@ class TrackingVisitor(ast.NodeVisitor):
272
332
  elif isinstance(node.func, ast.Name) and node.func.id == 'snowplow':
273
333
  if len(node.args) >= 2:
274
334
  props_node = node.args[1]
335
+
336
+ # Pattern: tracker.track(StructuredEvent(...))
337
+ elif len(node.args) >= 1:
338
+ first_arg = node.args[0]
339
+ if isinstance(first_arg, ast.Call) and isinstance(first_arg.func, ast.Name):
340
+ if first_arg.func.id == 'StructuredEvent':
341
+ # Extract all keyword arguments from StructuredEvent except 'action'
342
+ for keyword in first_arg.keywords:
343
+ if keyword.arg and keyword.arg != 'action':
344
+ # Map property_ to property for consistency
345
+ prop_name = 'property' if keyword.arg == 'property_' else keyword.arg
346
+
347
+ if isinstance(keyword.value, ast.Constant):
348
+ value_type = self.get_value_type(keyword.value.value)
349
+ properties[prop_name] = {"type": value_type}
350
+ elif isinstance(keyword.value, ast.Name):
351
+ # Check if we know the type of this variable
352
+ var_name = keyword.value.id
353
+ if var_name in self.var_types:
354
+ var_type = self.var_types[var_name]
355
+ if isinstance(var_type, dict):
356
+ properties[prop_name] = var_type
357
+ else:
358
+ properties[prop_name] = {"type": var_type}
359
+ else:
360
+ properties[prop_name] = {"type": "any"}
361
+ elif isinstance(keyword.value, ast.Dict):
362
+ # Nested dictionary
363
+ nested_props = self.extract_nested_dict(keyword.value)
364
+ properties[prop_name] = {
365
+ "type": "object",
366
+ "properties": nested_props
367
+ }
368
+ elif isinstance(keyword.value, ast.List) or isinstance(keyword.value, ast.Tuple):
369
+ # Array/list/tuple
370
+ item_type = self.infer_sequence_item_type(keyword.value)
371
+ properties[prop_name] = {
372
+ "type": "array",
373
+ "items": item_type
374
+ }
375
+ # Don't process props_node if we've already extracted properties
376
+ props_node = None
275
377
 
276
378
  # Extract properties from the dictionary
277
379
  if props_node and isinstance(props_node, ast.Dict):
@@ -398,12 +500,12 @@ class TrackingVisitor(ast.NodeVisitor):
398
500
  return nested_props
399
501
 
400
502
  def get_value_type(self, value):
401
- if isinstance(value, str):
503
+ if isinstance(value, bool):
504
+ return "boolean"
505
+ elif isinstance(value, str):
402
506
  return "string"
403
507
  elif isinstance(value, (int, float)):
404
508
  return "number"
405
- elif isinstance(value, bool):
406
- return "boolean"
407
509
  elif value is None:
408
510
  return "null"
409
511
  return "any"
@@ -1,33 +0,0 @@
1
- name: Publish Package to NPM
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- test:
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: actions/checkout@v4
12
- - uses: actions/setup-node@v4
13
- with:
14
- node-version: 20
15
- - run: npm ci
16
- - run: npm test
17
-
18
- publish:
19
- needs: test
20
- runs-on: ubuntu-latest
21
- permissions:
22
- contents: read
23
- id-token: write
24
- steps:
25
- - uses: actions/checkout@v4
26
- - uses: actions/setup-node@v4
27
- with:
28
- node-version: 20
29
- registry-url: https://registry.npmjs.org/
30
- - run: npm ci
31
- - run: npm publish --provenance --access public
32
- env:
33
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,17 +0,0 @@
1
- name: PR Tests
2
-
3
- on:
4
- pull_request_target:
5
- branches:
6
- - main
7
-
8
- jobs:
9
- test:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
- - uses: actions/setup-node@v4
14
- with:
15
- node-version: 20
16
- - run: npm ci
17
- - run: npm test
package/jest.config.js DELETED
@@ -1,7 +0,0 @@
1
- module.exports = {
2
- testEnvironment: 'node',
3
- verbose: true,
4
- collectCoverage: true,
5
- coverageDirectory: 'coverage',
6
- testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
7
- };
@@ -1,20 +0,0 @@
1
- const {
2
- detectSourceJs,
3
- } = require('../src/analyze/helpers');
4
-
5
- describe('detectSourceJs', () => {
6
- it('should detect Google Analytics', () => {
7
- const node = { callee: { type: 'Identifier', name: 'gtag' } };
8
- expect(detectSourceJs(node)).toBe('googleanalytics');
9
- });
10
-
11
- it('should detect Segment', () => {
12
- const node = { callee: { type: 'MemberExpression', object: { name: 'analytics' }, property: { name: 'track' } } };
13
- expect(detectSourceJs(node)).toBe('segment');
14
- });
15
-
16
- it('should return unknown for unrecognized source', () => {
17
- const node = { callee: { type: 'Identifier', name: 'unknownLib' } };
18
- expect(detectSourceJs(node)).toBe('unknown');
19
- });
20
- });
@@ -1,109 +0,0 @@
1
- const ts = require('typescript');
2
- const {
3
- extractJsProperties,
4
- extractTsProperties,
5
- } = require('../src/analyze/helpers');
6
-
7
- describe('extractJsProperties', () => {
8
- it('should extract simple properties', () => {
9
- const node = {
10
- properties: [
11
- { key: { name: 'userId' }, value: { value: '12345', type: 'Literal' } },
12
- { key: { name: 'plan' }, value: { value: 'Free', type: 'Literal' } },
13
- ],
14
- };
15
- const properties = extractJsProperties(node);
16
- expect(properties).toEqual({
17
- userId: { type: 'string' },
18
- plan: { type: 'string' },
19
- });
20
- });
21
-
22
- it('should handle nested object properties', () => {
23
- const node = {
24
- properties: [
25
- {
26
- key: { name: 'address' },
27
- value: {
28
- type: 'ObjectExpression',
29
- properties: [
30
- { key: { name: 'city' }, value: { value: 'San Francisco', type: 'Literal' } },
31
- { key: { name: 'state' }, value: { value: 'CA', type: 'Literal' } },
32
- ],
33
- },
34
- },
35
- ],
36
- };
37
- const properties = extractJsProperties(node);
38
- expect(properties).toEqual({
39
- address: {
40
- type: 'object',
41
- properties: {
42
- city: { type: 'string' },
43
- state: { type: 'string' },
44
- },
45
- },
46
- });
47
- });
48
-
49
- it('should handle properties with undefined type', () => {
50
- const node = {
51
- properties: [{ key: { name: 'undefinedProp' }, value: { value: undefined, type: 'Literal' } }],
52
- };
53
- const properties = extractJsProperties(node);
54
- expect(properties).toEqual({
55
- undefinedProp: { type: 'any' },
56
- });
57
- });
58
- });
59
-
60
- describe('extractTsProperties', () => {
61
- it('should extract properties from TypeScript object', () => {
62
- const node = {
63
- properties: [
64
- { name: { text: 'userId' }, initializer: { text: '12345', type: 'Literal' } },
65
- { name: { text: 'plan' }, initializer: { text: 'Free', type: 'Literal' } },
66
- ],
67
- };
68
- const checker = {
69
- getTypeAtLocation: jest.fn().mockReturnValue({}),
70
- typeToString: jest.fn().mockReturnValue('string'),
71
- };
72
- const properties = extractTsProperties(checker, node);
73
- expect(properties).toEqual({
74
- userId: { type: 'string' },
75
- plan: { type: 'string' },
76
- });
77
- });
78
-
79
- it('should handle nested object properties in TypeScript', () => {
80
- const node = {
81
- properties: [
82
- {
83
- name: { text: 'address' },
84
- initializer: {
85
- kind: ts.SyntaxKind.ObjectLiteralExpression,
86
- properties: [
87
- { name: { text: 'city' }, initializer: { text: 'San Francisco', type: 'Literal' } },
88
- { name: { text: 'state' }, initializer: { text: 'CA', type: 'Literal' } },
89
- ],
90
- },
91
- },
92
- ],
93
- };
94
- const checker = {
95
- getTypeAtLocation: jest.fn().mockReturnValue({}),
96
- typeToString: jest.fn().mockReturnValue('string'),
97
- };
98
- const properties = extractTsProperties(checker, node);
99
- expect(properties).toEqual({
100
- address: {
101
- type: 'object',
102
- properties: {
103
- city: { type: 'string' },
104
- state: { type: 'string' },
105
- },
106
- },
107
- });
108
- });
109
- });
@@ -1,30 +0,0 @@
1
- const ts = require('typescript');
2
- const {
3
- findWrappingFunctionJs,
4
- } = require('../src/analyze/helpers');
5
-
6
- describe('findWrappingFunctionJs', () => {
7
- it('should return function name for arrow function assigned to variable', () => {
8
- const node = { type: 'ArrowFunctionExpression' };
9
- const ancestors = [
10
- { type: 'Program' },
11
- { type: 'VariableDeclarator', init: node, id: { name: 'checkout' } },
12
- ];
13
- expect(findWrappingFunctionJs(node, ancestors)).toBe('checkout');
14
- });
15
-
16
- it('should return function name for function expression assigned to variable', () => {
17
- const node = { type: 'FunctionExpression' };
18
- const ancestors = [
19
- { type: 'Program' },
20
- { type: 'VariableDeclarator', init: node, id: { name: 'myFunc' } },
21
- ];
22
- expect(findWrappingFunctionJs(node, ancestors)).toBe('myFunc');
23
- });
24
-
25
- it('should return "global" if no wrapping function is found', () => {
26
- const node = {};
27
- const ancestors = [{ type: 'Program' }];
28
- expect(findWrappingFunctionJs(node, ancestors)).toBe('global');
29
- });
30
- });