Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,6 @@ __pycache__/

# Tracking schema output
tracking-schema.yaml

# Test output
tests/temp*
13 changes: 12 additions & 1 deletion src/analyze/ruby/detectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,18 @@ function detectSource(node, customFunction = null) {
if (node.name === 'track_struct_event') return 'snowplow';

// Custom tracking function
if (customFunction && node.name === customFunction) return 'custom';
if (customFunction) {
// Handle simple function names (e.g., 'customTrackFunction')
if (node.name === customFunction) return 'custom';

// Handle module-scoped function names (e.g., 'CustomModule.track')
if (customFunction.includes('.')) {
const [moduleName, methodName] = customFunction.split('.');
if (node.receiver && node.receiver.name === moduleName && node.name === methodName) {
return 'custom';
}
}
}

return null;
}
Expand Down
36 changes: 24 additions & 12 deletions src/analyze/ruby/extractors.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,44 @@ const { getValueType } = require('./types');
function extractEventName(node, source) {
if (source === 'segment' || source === 'rudderstack') {
// Both Segment and Rudderstack use the same format
const params = node.arguments_.arguments_[0].elements;
const params = node.arguments_?.arguments_?.[0]?.elements;
if (!params || !Array.isArray(params)) {
return null;
}
const eventProperty = params.find(param => param?.key?.unescaped?.value === 'event');
return eventProperty?.value?.unescaped?.value || null;
}

if (source === 'mixpanel') {
// Mixpanel Ruby SDK format: tracker.track('distinct_id', 'event_name', {...})
const args = node.arguments_.arguments_;
const args = node.arguments_?.arguments_;
if (args && args.length > 1 && args[1]?.unescaped?.value) {
return args[1].unescaped.value;
}
}

if (source === 'posthog') {
// PostHog Ruby SDK format: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
const hashArg = node.arguments_.arguments_[0];
if (hashArg && hashArg.elements) {
const hashArg = node.arguments_?.arguments_?.[0];
if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
const eventProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'event');
return eventProperty?.value?.unescaped?.value || null;
}
}

if (source === 'snowplow') {
// Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
const params = node.arguments_.arguments_[0].elements;
const params = node.arguments_?.arguments_?.[0]?.elements;
if (!params || !Array.isArray(params)) {
return null;
}
const actionProperty = params.find(param => param?.key?.unescaped?.value === 'action');
return actionProperty?.value?.unescaped?.value || null;
}

if (source === 'custom') {
// Custom function format: customFunction('event_name', {...})
const args = node.arguments_.arguments_;
const args = node.arguments_?.arguments_;
if (args && args.length > 0 && args[0]?.unescaped?.value) {
return args[0].unescaped.value;
}
Expand All @@ -65,7 +71,10 @@ async function extractProperties(node, source) {

if (source === 'segment' || source === 'rudderstack') {
// Both Segment and Rudderstack use the same format
const params = node.arguments_.arguments_[0].elements;
const params = node.arguments_?.arguments_?.[0]?.elements;
if (!params || !Array.isArray(params)) {
return null;
}
const properties = {};

// Process all top-level fields except 'event'
Expand Down Expand Up @@ -108,7 +117,7 @@ async function extractProperties(node, source) {

if (source === 'mixpanel') {
// Mixpanel Ruby SDK: tracker.track('distinct_id', 'event_name', {properties})
const args = node.arguments_.arguments_;
const args = node.arguments_?.arguments_;
const properties = {};

// Add distinct_id as property (even if it's a variable)
Expand All @@ -129,10 +138,10 @@ async function extractProperties(node, source) {

if (source === 'posthog') {
// PostHog Ruby SDK: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
const hashArg = node.arguments_.arguments_[0];
const hashArg = node.arguments_?.arguments_?.[0];
const properties = {};

if (hashArg && hashArg.elements) {
if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
// Extract distinct_id if present
const distinctIdProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'distinct_id');
if (distinctIdProperty?.value) {
Expand All @@ -154,7 +163,10 @@ async function extractProperties(node, source) {

if (source === 'snowplow') {
// Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
const params = node.arguments_.arguments_[0].elements;
const params = node.arguments_?.arguments_?.[0]?.elements;
if (!params || !Array.isArray(params)) {
return null;
}
const properties = {};

// Extract all struct event parameters except 'action' (which is used as the event name)
Expand All @@ -172,7 +184,7 @@ async function extractProperties(node, source) {

if (source === 'custom') {
// Custom function format: customFunction('event_name', {properties})
const args = node.arguments_.arguments_;
const args = node.arguments_?.arguments_;
if (args && args.length > 1 && args[1] instanceof HashNode) {
return await extractHashProperties(args[1]);
}
Expand Down
11 changes: 10 additions & 1 deletion src/analyze/ruby/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ class TrackingVisitor {
if (!eventName) return;

const line = getLineNumber(this.code, node.location);
const functionName = await findWrappingFunction(node, ancestors);

// For module-scoped custom functions, use the custom function name as the functionName
// For simple custom functions, use the wrapping function name
let functionName;
if (source === 'custom' && this.customFunction && this.customFunction.includes('.')) {
functionName = this.customFunction;
} else {
functionName = await findWrappingFunction(node, ancestors);
}

const properties = await extractProperties(node, source);

this.events.push({
Expand Down
4 changes: 2 additions & 2 deletions src/analyze/typescript/detectors/analytics-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ function detectMemberBasedProvider(node) {
return 'unknown';
}

const objectName = node.expression.expression.escapedText;
const methodName = node.expression.name.escapedText;
const objectName = node.expression.expression?.escapedText;
const methodName = node.expression.name?.escapedText;

if (!objectName || !methodName) {
return 'unknown';
Expand Down
1 change: 1 addition & 0 deletions src/analyze/typescript/extractors/property-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ function resolveTypeSchema(checker, typeString) {
* @returns {string|null} Literal type or null
*/
function getLiteralType(node) {
if (!node) return null;
if (ts.isStringLiteral(node)) return 'string';
if (ts.isNumericLiteral(node)) return 'number';
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
Expand Down
2 changes: 1 addition & 1 deletion src/analyze/typescript/utils/function-finder.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function findParentFunctionName(node) {
parent.initializer &&
ts.isCallExpression(parent.initializer) &&
ts.isIdentifier(parent.initializer.expression) &&
REACT_HOOKS.has(parent.initializer.expression.escapedText)
isReactHookCall(parent.initializer)
) {
return `${parent.initializer.expression.escapedText}(${parent.name.escapedText})`;
}
Expand Down
27 changes: 25 additions & 2 deletions tests/analyzeRuby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ test.describe('analyzeRubyFile', () => {
assert.ok(snowplowEvent);
assert.strictEqual(snowplowEvent.source, 'snowplow');
assert.strictEqual(snowplowEvent.functionName, 'snowplow_track');
assert.strictEqual(snowplowEvent.line, 96);
assert.strictEqual(snowplowEvent.line, 109);
assert.deepStrictEqual(snowplowEvent.properties, {
category: { type: 'string' },
label: { type: 'string' },
Expand Down Expand Up @@ -103,7 +103,7 @@ test.describe('analyzeRubyFile', () => {
assert.ok(moduleEvent);
assert.strictEqual(moduleEvent.source, 'segment');
assert.strictEqual(moduleEvent.functionName, 'track_something');
assert.strictEqual(moduleEvent.line, 108);
assert.strictEqual(moduleEvent.line, 121);
assert.deepStrictEqual(moduleEvent.properties, {
anonymous_id: { type: 'string' },
from_module: { type: 'boolean' }
Expand Down Expand Up @@ -195,6 +195,29 @@ test.describe('analyzeRubyFile', () => {
]);
});

test('should detect custom functions that are methods of a module', async () => {
const customFunction = 'CustomModule.track';
const events = await analyzeRubyFile(testFilePath, customFunction);

// Should find the CustomModule.track call
const customModuleEvent = events.find(e => e.source === 'custom' && e.functionName === 'CustomModule.track');
assert.ok(customModuleEvent);
assert.strictEqual(customModuleEvent.eventName, 'custom_event');
assert.strictEqual(customModuleEvent.line, 98);
assert.deepStrictEqual(customModuleEvent.properties, {
key: { type: 'string' },
nested: {
type: 'object',
properties: {
a: {
type: 'array',
items: { type: 'number' }
}
}
}
});
});

test('should correctly differentiate between Segment and Rudderstack', async () => {
const events = await analyzeRubyFile(testFilePath, null);

Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/ruby/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ def custom_track_event
def customTrackFunction(event_name, params = {})
puts "Custom track: #{event_name} - #{params}"
end

module CustomModule
def track(event_name, params = {})
# Mock implementation
end
end

def custom_track_module
CustomModule.track('custom_event', {
key: 'value',
nested: { a: [1, 2, 3] }
})
end
end

# Snowplow tracking example
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/ruby/tracking-schema-ruby.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ events:
add-to-basket:
implementations:
- path: main.rb
line: 96
line: 109
function: snowplow_track
destination: snowplow
properties:
Expand All @@ -95,7 +95,7 @@ events:
Module Event:
implementations:
- path: main.rb
line: 108
line: 121
function: track_something
destination: segment
properties:
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/tracking-schema-all.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ events:
function: snowplow_track_events
destination: snowplow
- path: ruby/main.rb
line: 96
line: 109
function: snowplow_track
destination: snowplow
properties:
Expand Down Expand Up @@ -430,7 +430,7 @@ events:
Module Event:
implementations:
- path: ruby/main.rb
line: 108
line: 121
function: track_something
destination: segment
properties:
Expand Down