Skip to content

Commit 8e00447

Browse files
committed
improve performance by checking all custom functions in one pass per file
1 parent cd9ab36 commit 8e00447

File tree

8 files changed

+201
-123
lines changed

8 files changed

+201
-123
lines changed

src/analyze/javascript/index.js

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,35 +12,21 @@ const { parseFile, findTrackingEvents, FileReadError, ParseError } = require('./
1212
* @returns {Array<Object>} Array of tracking events found in the file
1313
*/
1414
function analyzeJsFile(filePath, customFunctionSignatures = null) {
15-
const events = [];
16-
1715
try {
1816
// Parse the file into an AST once
1917
const ast = parseFile(filePath);
2018

21-
// -------- Built-in providers pass --------
22-
const builtInEvents = findTrackingEvents(ast, filePath, null);
23-
events.push(...builtInEvents);
24-
25-
// -------- Custom function passes --------
26-
if (Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0) {
27-
for (const customConfig of customFunctionSignatures) {
28-
if (!customConfig) continue;
29-
const customEvents = findTrackingEvents(ast, filePath, customConfig);
30-
events.push(...customEvents);
31-
}
32-
}
19+
// Single pass extraction covering built-in + all custom configs
20+
const events = findTrackingEvents(ast, filePath, customFunctionSignatures || []);
3321

3422
// Deduplicate events (by source | eventName | line | functionName)
35-
const uniqueEvents = new Map();
23+
const unique = new Map();
3624
for (const evt of events) {
3725
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
38-
if (!uniqueEvents.has(key)) {
39-
uniqueEvents.set(key, evt);
40-
}
26+
if (!unique.has(key)) unique.set(key, evt);
4127
}
4228

43-
return Array.from(uniqueEvents.values());
29+
return Array.from(unique.values());
4430

4531
} catch (error) {
4632
if (error instanceof FileReadError) {

src/analyze/javascript/parser.js

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,95 @@ function parseFile(filePath) {
6666
}
6767
}
6868

69+
// ---------------------------------------------
70+
// Helper – custom function matcher
71+
// ---------------------------------------------
72+
6973
/**
70-
* Walks the AST and finds analytics tracking calls
71-
* @param {Object} ast - Parsed AST
72-
* @param {string} filePath - Path to the file being analyzed
73-
* @param {Object} [customConfig] - Custom function configuration object
74-
* @returns {Array<Object>} Array of found events
74+
* Determines whether a CallExpression node matches the provided custom function name.
75+
* Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
76+
* The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
77+
* circular dependencies.
78+
* @param {Object} node – CallExpression AST node
79+
* @param {string} fnName – Custom function name (could include dots)
80+
* @returns {boolean}
7581
*/
76-
function findTrackingEvents(ast, filePath, customConfig) {
82+
function nodeMatchesCustomFunction(node, fnName) {
83+
if (!fnName || !node.callee) return false;
84+
85+
const parts = fnName.split('.');
86+
87+
// Simple identifier case
88+
if (parts.length === 1) {
89+
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
90+
}
91+
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
98+
let currentNode = node.callee;
99+
let idx = parts.length - 1;
100+
101+
while (currentNode && idx >= 0) {
102+
const expected = parts[idx];
103+
104+
if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
105+
if (
106+
currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
107+
currentNode.property.name !== expected
108+
) {
109+
return false;
110+
}
111+
currentNode = currentNode.object;
112+
idx -= 1;
113+
} else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
114+
return idx === 0 && currentNode.name === expected;
115+
} else {
116+
return false;
117+
}
118+
}
119+
120+
return false;
121+
}
122+
123+
/**
124+
* Walk the AST once and find tracking events for built-in providers plus any number of custom
125+
* function configurations. This avoids the previous O(n * customConfigs) behaviour.
126+
*
127+
* @param {Object} ast – Parsed AST of the source file
128+
* @param {string} filePath – Absolute/relative path to the source file
129+
* @param {Object[]} [customConfigs=[]] – Array of parsed custom function configurations
130+
* @returns {Array<Object>} – List of extracted tracking events
131+
*/
132+
function findTrackingEvents(ast, filePath, customConfigs = []) {
77133
const events = [];
78134

79135
walk.ancestor(ast, {
80136
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
81137
try {
82-
const event = extractTrackingEvent(node, ancestors, filePath, customConfig);
83-
if (event) {
84-
events.push(event);
138+
let matchedCustomConfig = null;
139+
140+
// Attempt to match any custom function first to avoid mis-classifying built-in providers
141+
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
142+
for (const cfg of customConfigs) {
143+
if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
144+
matchedCustomConfig = cfg;
145+
break;
146+
}
147+
}
148+
}
149+
150+
if (matchedCustomConfig) {
151+
// Force source to 'custom' and use matched config
152+
const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig);
153+
if (event) events.push(event);
154+
} else {
155+
// Let built-in detector figure out source (pass undefined customFunction)
156+
const event = extractTrackingEvent(node, ancestors, filePath, null);
157+
if (event) events.push(event);
85158
}
86159
} catch (error) {
87160
console.error(`Error processing node in ${filePath}:`, error.message);

src/analyze/python/index.js

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,29 +93,14 @@ async function analyzePythonFile(filePath, customFunctionSignatures = null) {
9393
return JSON.parse(result);
9494
};
9595

96-
const events = [];
96+
// Prepare config argument (array or null)
97+
const configArg = Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0
98+
? customFunctionSignatures
99+
: null;
97100

98-
// Built-in providers pass (no custom config)
99-
events.push(...runAnalysis(null));
101+
const events = runAnalysis(configArg);
100102

101-
// Custom configs passes
102-
if (Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0) {
103-
for (const customConfig of customFunctionSignatures) {
104-
if (!customConfig) continue;
105-
events.push(...runAnalysis(customConfig));
106-
}
107-
}
108-
109-
// Deduplicate events
110-
const uniqueEvents = new Map();
111-
for (const evt of events) {
112-
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
113-
if (!uniqueEvents.has(key)) {
114-
uniqueEvents.set(key, evt);
115-
}
116-
}
117-
118-
return Array.from(uniqueEvents.values());
103+
return events;
119104
} catch (error) {
120105
// Log detailed error information for debugging
121106
console.error(`Error analyzing Python file ${filePath}:`, error);

src/analyze/python/pythonTrackingAnalyzer.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -806,33 +806,56 @@ def get_value_type(self, value: Any) -> str:
806806
return "null"
807807
return "any"
808808

809-
def analyze_python_code(code: str, filepath: str, custom_config: Optional[dict[str, any]] = None) -> str:
809+
def analyze_python_code(code: str, filepath: str, custom_config: Optional[any] = None) -> str:
810810
"""
811811
Analyze Python code for analytics tracking calls.
812-
813-
This function parses Python code and identifies analytics tracking calls,
814-
extracting event names, properties, and metadata.
815-
812+
813+
The function supports either a single custom configuration object or a list
814+
of such objects, allowing detection of multiple custom tracking functions
815+
without parsing the source code multiple times.
816+
816817
Args:
817818
code: The Python source code to analyze
818819
filepath: Path to the file being analyzed
819-
custom_config: Optional custom configuration for custom tracking functions
820-
820+
custom_config: None, a single custom config dict, or a list of configs
821+
821822
Returns:
822823
JSON string containing array of tracking events
823824
"""
824825
try:
825-
# Parse the Python code
826+
# Parse the Python code only once
826827
tree = ast.parse(code)
827-
828-
# Create visitor and analyze
829-
visitor = TrackingVisitor(filepath, custom_config)
830-
visitor.visit(tree)
831-
832-
# Return events as JSON
833-
return json.dumps(visitor.events)
834-
except Exception as e:
835-
# Return empty array on parse errors
828+
829+
events: List[AnalyticsEvent] = []
830+
831+
def run_visitor(cfg: Optional[dict]) -> None:
832+
vis = TrackingVisitor(filepath, cfg)
833+
vis.visit(tree)
834+
events.extend(vis.events)
835+
836+
# Built-in providers pass (no custom config)
837+
run_visitor(None)
838+
839+
# Handle list or single custom configuration
840+
if custom_config:
841+
if isinstance(custom_config, list):
842+
for cfg in custom_config:
843+
if cfg:
844+
run_visitor(cfg)
845+
else:
846+
run_visitor(custom_config) # single config for backward compat
847+
848+
# Deduplicate events (source|eventName|line|functionName)
849+
unique: Dict[str, AnalyticsEvent] = {}
850+
for evt in events:
851+
key = f"{evt['source']}|{evt['eventName']}|{evt['line']}|{evt['functionName']}"
852+
if key not in unique:
853+
unique[key] = evt
854+
855+
return json.dumps(list(unique.values()))
856+
857+
except Exception:
858+
# Return empty array on failure
836859
return json.dumps([])
837860

838861
# Command-line interface

src/analyze/ruby/index.js

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,18 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
3636
return [];
3737
}
3838

39-
const events = [];
40-
41-
// -------- Built-in providers pass --------
42-
let visitor = new TrackingVisitor(code, filePath, null);
43-
const builtInEvents = await visitor.analyze(ast);
44-
events.push(...builtInEvents);
45-
46-
// -------- Custom config passes --------
47-
if (Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0) {
48-
for (const customConfig of customFunctionSignatures) {
49-
if (!customConfig) continue;
50-
const customVisitor = new TrackingVisitor(code, filePath, customConfig);
51-
const customEvents = await customVisitor.analyze(ast);
52-
events.push(...customEvents);
53-
}
54-
}
39+
// Single visitor pass covering all custom configs
40+
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
41+
const events = await visitor.analyze(ast);
5542

5643
// Deduplicate events
57-
const uniqueEvents = new Map();
44+
const unique = new Map();
5845
for (const evt of events) {
5946
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
60-
if (!uniqueEvents.has(key)) {
61-
uniqueEvents.set(key, evt);
62-
}
47+
if (!unique.has(key)) unique.set(key, evt);
6348
}
6449

65-
return Array.from(uniqueEvents.values());
50+
return Array.from(unique.values());
6651

6752
} catch (fileError) {
6853
console.error(`Error reading or processing file ${filePath}:`, fileError.message);

src/analyze/ruby/visitor.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ const { extractEventName, extractProperties } = require('./extractors');
88
const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
99

1010
class TrackingVisitor {
11-
constructor(code, filePath, customConfig = null) {
11+
constructor(code, filePath, customConfigs = []) {
1212
this.code = code;
1313
this.filePath = filePath;
14-
this.customConfig = customConfig;
14+
this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
1515
this.events = [];
1616
}
1717

@@ -22,24 +22,41 @@ class TrackingVisitor {
2222
*/
2323
async processCallNode(node, ancestors) {
2424
try {
25-
const source = detectSource(node, this.customConfig?.functionName);
25+
let matchedConfig = null;
26+
let source = null;
27+
28+
// Try to match any custom config first
29+
for (const cfg of this.customConfigs) {
30+
if (!cfg) continue;
31+
if (detectSource(node, cfg.functionName) === 'custom') {
32+
matchedConfig = cfg;
33+
source = 'custom';
34+
break;
35+
}
36+
}
37+
38+
// If no custom match, attempt built-in providers
39+
if (!source) {
40+
source = detectSource(node, null);
41+
}
42+
2643
if (!source) return;
2744

28-
const eventName = extractEventName(node, source, this.customConfig);
45+
const eventName = extractEventName(node, source, matchedConfig);
2946
if (!eventName) return;
3047

3148
const line = getLineNumber(this.code, node.location);
3249

3350
// For module-scoped custom functions, use the custom function name as the functionName
3451
// For simple custom functions, use the wrapping function name
3552
let functionName;
36-
if (source === 'custom' && this.customConfig && this.customConfig.functionName.includes('.')) {
37-
functionName = this.customConfig.functionName;
53+
if (source === 'custom' && matchedConfig && matchedConfig.functionName.includes('.')) {
54+
functionName = matchedConfig.functionName;
3855
} else {
3956
functionName = await findWrappingFunction(node, ancestors);
4057
}
4158

42-
const properties = await extractProperties(node, source, this.customConfig);
59+
const properties = await extractProperties(node, source, matchedConfig);
4360

4461
this.events.push({
4562
eventName,

0 commit comments

Comments
 (0)