diff --git a/README.md b/README.md index 7aee5b1..4c9b8b4 100644 --- a/README.md +++ b/README.md @@ -101,18 +101,19 @@ See [schema.json](schema.json) for a JSON Schema of the output. | Library | JavaScript/TypeScript | Python | Ruby | Go | |---------|:---------------------:|:------:|:----:|:--:| -| Google Analytics | ✅ | ❌ | ❌ | ❌ | -| Segment | ✅ | ✅ | ✅ | ✅ | -| Mixpanel | ✅ | ✅ | ✅ | ✅ | -| Amplitude | ✅ | ✅ | ❌ | ✅ | -| Rudderstack | ✅ | ✅ | ✳️ | ✳️ | -| mParticle | ✅ | ❌ | ❌ | ❌ | -| PostHog | ✅ | ✅ | ✅ | ✅ | -| Pendo | ✅ | ❌ | ❌ | ❌ | -| Heap | ✅ | ❌ | ❌ | ❌ | -| Snowplow | ✅ | ✅ | ✅ | ✅ | -| Datadog RUM | ✅ | ❌ | ❌ | ❌ | -| Custom Function | ✅ | ✅ | ✅ | ✅ | +| Google Analytics | ✅ | ❌ | ❌ | ❌ | +| Google Tag Manager | ✅ | ❌ | ❌ | ❌ | +| Segment | ✅ | ✅ | ✅ | ✅ | +| Mixpanel | ✅ | ✅ | ✅ | ✅ | +| Amplitude | ✅ | ✅ | ❌ | ✅ | +| Rudderstack | ✅ | ✅ | ✳️ | ✳️ | +| mParticle | ✅ | ❌ | ❌ | ❌ | +| PostHog | ✅ | ✅ | ✅ | ✅ | +| Pendo | ✅ | ❌ | ❌ | ❌ | +| Heap | ✅ | ❌ | ❌ | ❌ | +| Snowplow | ✅ | ✅ | ✅ | ✅ | +| Datadog RUM | ✅ | ❌ | ❌ | ❌ | +| Custom Function | ✅ | ✅ | ✅ | ✅ | ✳️ Rudderstack's SDKs often use the same format as Segment, so Rudderstack events may be detected as Segment events. @@ -130,6 +131,24 @@ See [schema.json](schema.json) for a JSON Schema of the output. ``` +
+ Google Tag Manager + + **JavaScript/TypeScript** + ```js + dataLayer.push({ + event: '', + '': '' + }); + + // Or via window + window.dataLayer.push({ + event: '', + '': '' + }); + ``` +
+
Segment diff --git a/schema.json b/schema.json index 11067a4..052e12d 100644 --- a/schema.json +++ b/schema.json @@ -68,6 +68,7 @@ "heap", "snowplow", "datadog", + "gtm", "custom", "unknown" ], diff --git a/src/analyze/javascript/constants.js b/src/analyze/javascript/constants.js index 102673b..06a6002 100644 --- a/src/analyze/javascript/constants.js +++ b/src/analyze/javascript/constants.js @@ -81,6 +81,12 @@ const ANALYTICS_PROVIDERS = { objectNames: ['datadogRum', 'DD_RUM'], methodName: 'addAction', type: 'member' + }, + GOOGLE_TAG_MANAGER: { + name: 'gtm', + objectNames: ['dataLayer'], + methodName: 'push', + type: 'member' } }; diff --git a/src/analyze/javascript/extractors/event-extractor.js b/src/analyze/javascript/extractors/event-extractor.js index 8143f4f..e8fcb1b 100644 --- a/src/analyze/javascript/extractors/event-extractor.js +++ b/src/analyze/javascript/extractors/event-extractor.js @@ -20,6 +20,7 @@ const EXTRACTION_STRATEGIES = { googleanalytics: extractGoogleAnalyticsEvent, snowplow: extractSnowplowEvent, mparticle: extractMparticleEvent, + gtm: extractGTMEvent, custom: extractCustomEvent, default: extractDefaultEvent }; @@ -105,6 +106,43 @@ function extractMparticleEvent(node, constantMap) { return { eventName, propertiesNode }; } +/** + * Extracts Google Tag Manager event data + * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map + * @returns {EventData} + */ +function extractGTMEvent(node, constantMap) { + if (!node.arguments || node.arguments.length === 0) { + return { eventName: null, propertiesNode: null }; + } + + // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' }) + const firstArg = node.arguments[0]; + + if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) { + return { eventName: null, propertiesNode: null }; + } + + // Find the 'event' property + const eventProperty = findPropertyByKey(firstArg, 'event'); + if (!eventProperty) { + return { eventName: null, propertiesNode: null }; + } + + const eventName = getStringValue(eventProperty.value, constantMap); + + // Create a modified properties node without the 'event' property + const modifiedPropertiesNode = { + ...firstArg, + properties: firstArg.properties.filter(prop => + prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event') + ) + }; + + return { eventName, propertiesNode: modifiedPropertiesNode }; +} + /** * Default event extraction for standard providers * @param {Object} node - CallExpression node diff --git a/src/analyze/typescript/constants.js b/src/analyze/typescript/constants.js index 52d7fa9..a13a6b5 100644 --- a/src/analyze/typescript/constants.js +++ b/src/analyze/typescript/constants.js @@ -81,6 +81,12 @@ const ANALYTICS_PROVIDERS = { objectNames: ['datadogRum', 'DD_RUM'], methodName: 'addAction', type: 'member' + }, + GOOGLE_TAG_MANAGER: { + name: 'gtm', + objectNames: ['dataLayer'], + methodName: 'push', + type: 'member' } }; diff --git a/src/analyze/typescript/extractors/event-extractor.js b/src/analyze/typescript/extractors/event-extractor.js index 40f7184..9b6789f 100644 --- a/src/analyze/typescript/extractors/event-extractor.js +++ b/src/analyze/typescript/extractors/event-extractor.js @@ -21,6 +21,7 @@ const EXTRACTION_STRATEGIES = { googleanalytics: extractGoogleAnalyticsEvent, snowplow: extractSnowplowEvent, mparticle: extractMparticleEvent, + gtm: extractGTMEvent, custom: extractCustomEvent, default: extractDefaultEvent }; @@ -126,6 +127,60 @@ function extractMparticleEvent(node, checker, sourceFile) { return { eventName, propertiesNode }; } +/** + * Extracts Google Tag Manager event data + * @param {Object} node - CallExpression node + * @param {Object} checker - TypeScript type checker + * @param {Object} sourceFile - TypeScript source file + * @returns {EventData} + */ +function extractGTMEvent(node, checker, sourceFile) { + if (!node.arguments || node.arguments.length === 0) { + return { eventName: null, propertiesNode: null }; + } + + // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' }) + const firstArg = node.arguments[0]; + + if (!ts.isObjectLiteralExpression(firstArg)) { + return { eventName: null, propertiesNode: null }; + } + + // Find the 'event' property + const eventProperty = findPropertyByKey(firstArg, 'event'); + if (!eventProperty) { + return { eventName: null, propertiesNode: null }; + } + + const eventName = getStringValue(eventProperty.initializer, checker, sourceFile); + + // Create a modified properties node without the 'event' property + const modifiedProperties = firstArg.properties.filter(prop => { + if (ts.isPropertyAssignment(prop) && prop.name) { + if (ts.isIdentifier(prop.name)) { + return prop.name.escapedText !== 'event'; + } + if (ts.isStringLiteral(prop.name)) { + return prop.name.text !== 'event'; + } + } + return true; + }); + + // Create a synthetic object literal with the filtered properties + const modifiedPropertiesNode = ts.factory.createObjectLiteralExpression(modifiedProperties); + + // Copy source positions for proper analysis + if (firstArg.pos !== undefined) { + modifiedPropertiesNode.pos = firstArg.pos; + } + if (firstArg.end !== undefined) { + modifiedPropertiesNode.end = firstArg.end; + } + + return { eventName, propertiesNode: modifiedPropertiesNode }; +} + /** * Custom extraction * @param {Object} node - CallExpression node diff --git a/tests/analyzeJavaScript.test.js b/tests/analyzeJavaScript.test.js index 2b266b1..d9288bd 100644 --- a/tests/analyzeJavaScript.test.js +++ b/tests/analyzeJavaScript.test.js @@ -16,7 +16,7 @@ test.describe('analyzeJsFile', () => { // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 15); + assert.strictEqual(events.length, 19); // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'purchase' && e.source === 'googleanalytics'); @@ -232,7 +232,7 @@ test.describe('analyzeJsFile', () => { const events = analyzeJsFile(testFilePath, null); // Should find all events except the custom one - assert.strictEqual(events.length, 14); + assert.strictEqual(events.length, 18); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); diff --git a/tests/analyzeTypeScript.test.js b/tests/analyzeTypeScript.test.js index 693a4a2..fcf7ce1 100644 --- a/tests/analyzeTypeScript.test.js +++ b/tests/analyzeTypeScript.test.js @@ -37,7 +37,7 @@ test.describe('analyzeTsFile', () => { // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 23); + assert.strictEqual(events.length, 25); // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'order_completed' && e.source === 'googleanalytics'); @@ -405,7 +405,7 @@ test.describe('analyzeTsFile', () => { const events = analyzeTsFile(testFilePath, program, null); // Should find all events except the custom ones - assert.strictEqual(events.length, 16); + assert.strictEqual(events.length, 18); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); diff --git a/tests/fixtures/javascript/main.js b/tests/fixtures/javascript/main.js index 6a4b06a..605e313 100644 --- a/tests/fixtures/javascript/main.js +++ b/tests/fixtures/javascript/main.js @@ -211,3 +211,51 @@ mixpanel.track(TRACKING_EVENTS_FROZEN.ECOMMERCE_PURCHASE, { total: 99.99, items: ['sku_1', 'sku_2'] }); + +// ----------------------------------------------------------------------------- +// Google Tag Manager (GTM) tracking examples +// ----------------------------------------------------------------------------- + +// GTM example 1: window.dataLayer.push +window.dataLayer.push({ + 'event': 'formSubmission', + 'formId': 'contactForm', + 'formLocation': 'footer' +}); + +// GTM example 2: dataLayer.push (without window) +dataLayer.push({ + 'event': 'userRegistration', + 'userId': 'user123', + 'source': 'organic', + 'plan': 'premium' +}); + +// GTM example 3: complex properties +window.dataLayer.push({ + 'event': 'ecommerce_purchase', + 'transactionId': 'txn_123', + 'value': 99.99, + 'currency': 'USD', + 'items': [ + { + 'item_id': 'sku_001', + 'item_name': 'Product A', + 'price': 49.99 + }, + { + 'item_id': 'sku_002', + 'item_name': 'Product B', + 'price': 50.00 + } + ] +}); + +function gtmTestFunction() { + // GTM example 4: inside a function + dataLayer.push({ + 'event': 'buttonClick', + 'buttonText': 'Subscribe Now', + 'location': 'header' + }); +} diff --git a/tests/fixtures/javascript/tracking-schema-javascript.yaml b/tests/fixtures/javascript/tracking-schema-javascript.yaml index c050f0f..ba93161 100644 --- a/tests/fixtures/javascript/tracking-schema-javascript.yaml +++ b/tests/fixtures/javascript/tracking-schema-javascript.yaml @@ -316,3 +316,55 @@ events: type: string success: type: boolean + formSubmission: + implementations: + - path: main.js + line: 220 + function: global + destination: gtm + properties: + formId: + type: string + formLocation: + type: string + userRegistration: + implementations: + - path: main.js + line: 227 + function: global + destination: gtm + properties: + userId: + type: string + source: + type: string + plan: + type: string + ecommerce_purchase: + implementations: + - path: main.js + line: 235 + function: global + destination: gtm + properties: + transactionId: + type: string + value: + type: number + currency: + type: string + items: + type: array + items: + type: object + buttonClick: + implementations: + - path: main.js + line: 256 + function: gtmTestFunction + destination: gtm + properties: + buttonText: + type: string + location: + type: string diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index b156ec1..8cb331f 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -982,6 +982,10 @@ events: line: 326 function: global destination: custom + - path: javascript/main.js + line: 235 + function: global + destination: gtm properties: orderId: type: string @@ -993,6 +997,12 @@ events: type: string userId: type: string + transactionId: + type: string + value: + type: number + currency: + type: string ecommerce_purchase_v2: implementations: - path: typescript/main.ts @@ -1432,3 +1442,55 @@ events: type: any nonInteraction: type: number + formSubmission: + implementations: + - path: javascript/main.js + line: 220 + function: global + destination: gtm + properties: + formId: + type: string + formLocation: + type: string + userRegistration: + implementations: + - path: javascript/main.js + line: 227 + function: global + destination: gtm + properties: + userId: + type: string + source: + type: string + plan: + type: string + buttonClick: + implementations: + - path: javascript/main.js + line: 256 + function: gtmTestFunction + destination: gtm + - path: typescript/main.ts + line: 543 + function: gtmTestFunction + destination: gtm + properties: + buttonText: + type: string + location: + type: string + timestamp: + type: string + video_play: + implementations: + - path: typescript/main.ts + line: 556 + function: global + destination: gtm + properties: + videoTitle: + type: string + videoDuration: + type: number diff --git a/tests/fixtures/typescript/main.ts b/tests/fixtures/typescript/main.ts index 506e599..6cadc6a 100644 --- a/tests/fixtures/typescript/main.ts +++ b/tests/fixtures/typescript/main.ts @@ -462,3 +462,99 @@ function mapDispatchToActions(dispatch: Function, ownProps: ExplicitPropsRedux & ), }; } + +// ----------------------------------------------------------------------------- +// Google Tag Manager (GTM) tracking examples +// ----------------------------------------------------------------------------- + +// Extend existing window declaration to include dataLayer +interface GTMWindow extends Window { + dataLayer: any[]; +} + +declare const dataLayer: any[]; + +// GTM example 1: window.dataLayer.push with explicit types +interface GTMEvent { + event: string; + [key: string]: any; +} + +(window as any).dataLayer.push({ + 'event': 'formSubmission', + 'formId': 'contactForm', + 'formLocation': 'footer', + 'timestamp': Date.now() +} as GTMEvent); + +// GTM example 2: dataLayer.push (without window) with typed interface +interface UserRegistrationEvent { + event: 'userRegistration'; + userId: string; + source: string; + plan: string; +} + +const gtmRegistrationEvent: UserRegistrationEvent = { + event: 'userRegistration', + userId: 'user123', + source: 'organic', + plan: 'premium' +}; +dataLayer.push(gtmRegistrationEvent); + +// GTM example 3: complex ecommerce tracking +interface GTMEcommerceItem { + item_id: string; + item_name: string; + price: number; +} + +interface GTMEcommercePurchaseEvent { + event: 'ecommerce_purchase'; + transactionId: string; + value: number; + currency: string; + items: GTMEcommerceItem[]; +} + +const gtmPurchaseEvent: GTMEcommercePurchaseEvent = { + event: 'ecommerce_purchase', + transactionId: 'txn_123', + value: 99.99, + currency: 'USD', + items: [ + { + item_id: 'sku_001', + item_name: 'Product A', + price: 49.99 + }, + { + item_id: 'sku_002', + item_name: 'Product B', + price: 50.00 + } + ] +}; +(window as any).dataLayer.push(gtmPurchaseEvent); + +// GTM example 4: inside a function +function gtmTestFunction(): void { + dataLayer.push({ + 'event': 'buttonClick', + 'buttonText': 'Subscribe Now', + 'location': 'header', + 'timestamp': new Date().toISOString() + }); +} + +// GTM example 5: with variable reference +const GTM_EVENTS = { + VIDEO_PLAY: 'video_play' +} as const; + +(window as any).dataLayer.push({ + 'event': GTM_EVENTS.VIDEO_PLAY, + 'videoTitle': 'Product Demo', + 'videoDuration': 120 +}); diff --git a/tests/fixtures/typescript/tracking-schema-typescript.yaml b/tests/fixtures/typescript/tracking-schema-typescript.yaml index f18f826..55b6214 100644 --- a/tests/fixtures/typescript/tracking-schema-typescript.yaml +++ b/tests/fixtures/typescript/tracking-schema-typescript.yaml @@ -515,3 +515,27 @@ events: type: string page: type: string + buttonClick: + implementations: + - path: main.ts + line: 543 + function: gtmTestFunction + destination: gtm + properties: + buttonText: + type: string + location: + type: string + timestamp: + type: string + video_play: + implementations: + - path: main.ts + line: 556 + function: global + destination: gtm + properties: + videoTitle: + type: string + videoDuration: + type: number diff --git a/tests/schema.test.js b/tests/schema.test.js index 5d988df..9f08782 100644 --- a/tests/schema.test.js +++ b/tests/schema.test.js @@ -178,7 +178,7 @@ test.describe('Schema Validation Tests', () => { const validDestinations = [ 'googleanalytics', 'segment', 'mixpanel', 'amplitude', 'rudderstack', 'mparticle', 'posthog', 'pendo', - 'heap', 'snowplow', 'datadog', 'custom', 'unknown', + 'heap', 'snowplow', 'datadog', 'gtm', 'custom', 'unknown', ]; // Check that all destinations are valid