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