Skip to content

Commit 882ff73

Browse files
Analytics improvements (tldraw#7041)
GTM Integration: - New GTMAnalyticsService loads GTM script/iframe, tracks events via dataLayer - Consent banner tracking: `display_consent_banner` and `select_consent_banner` events - Public consent API: `getConsentState()` and `onConsentUpdate()` on `window.tlanalytics` - Structured event tracking: `trackCopyCode()` and `trackFormSubmission()` methods - Wired up code copy tracking in docs site - Config: `TL_GTM_CONTAINER_ID` env var support Dotcom Analytics: - Added cross-domain tracking linker for tldraw.dev - Push watermark click tracking to GTM - Added watermark event to editor emit types Things to do: - [ ] Set NEXT_PUBLIC_GTM_CONTAINER_ID in Vercel - [ ] Remove NEXT_PUBLIC_GA4_MEASUREMENT_ID variable in Vercel - [ ] Make similar changes on the Framer side of things. Code change below. Add this to head: ```typescript <script> window.TL_GTM_CONTAINER_ID = 'GTM-PJDV58LL'; </script> <script id="tldraw-analytics" type="text/javascript" async defer src="https://analytics.tldraw.com/tl-analytics.js" ></script> <script> // Send form submission events to GTM document.addEventListener("framer:formsubmit", async (event) => { if (window.tlanalytics?.trackFormSubmission) { const formData = event.detail.data || {}; // Build payload with only fields that have values const payload = { enquiry_type: event.detail.trackingId || 'form_submission', }; if (formData.email) { payload.user_email = formData.email; payload.user_email_sha256 = await sha256(formData.email.toLowerCase()); } if (formData.first_name) { payload.user_first_name = formData.first_name; } if (formData.last_name) { payload.user_last_name = formData.last_name; } if (formData.linkedin_company_page) { payload.company_linkedin = formData.linkedin_company_page; } if (formData.hs_employee_range) { payload.company_size = formData.hs_employee_range; } if (formData.website) { payload.company_website = formData.website; } if (formData.country) { payload.user_country = formData.country; } // Only call if we have required fields if (payload.user_email) { window.tlanalytics.trackFormSubmission(payload); } } }); // Track code copy events document.addEventListener('copy', (copyEvent) => { const isWithinCodeBlock = copyEvent.target?.closest('pre, code'); if (isWithinCodeBlock) { const copiedText = window.getSelection()?.toString() || ''; // Only track if we have actual text if (copiedText && window.tlanalytics?.trackCopyCode) { window.tlanalytics.trackCopyCode({ page_category: 'framer', text_snippet: copiedText, }); } } }); // Simple SHA-256 hash function for email async function sha256(message) { const msgBuffer = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } </script> ``` Add this to body: ```typescript <noscript> <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PJDV58LL" height="0" width="0" style="display:none;visibility:hidden"></iframe> </noscript> ``` ### API changes - Add click `click-watermark` emit type, so that you can listen to it via the editor events. ### Change type - [x] `improvement` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates GTM with consent handling and new tracking APIs, wires code-copy/form events, adds watermark click tracking end-to-end, and updates allowed origins and docs embed. > > - **Analytics Core**: > - **GTM Integration**: New `GTMAnalyticsService` loads GTM, manages consent via `dataLayer`, and tracks `page_view`/custom events. > - **Consent**: Adds `getConsentState()`/`onConsentUpdate()` and tracks consent banner display/selection with opt-in type. > - **Structured Events**: Adds `trackCopyCode()` and `trackFormSubmission()` and forwards to services. > - **Service Wiring**: Includes `gtmService` alongside existing services. > - **Docs Site (`apps/docs`)**: > - Sets `window.TL_GTM_CONTAINER_ID`; loads analytics from local in dev; adds GTM `<noscript>` iframe. > - Wires code-copy to `tlanalytics.trackCopyCode`. > - **Dotcom Analytics**: > - GA4 linker for cross-domain tracking with `tldraw.dev`. > - Tracks `click-watermark` in GA4. > - **Editor SDK**: > - Adds `click-watermark` event to `TLEventMap`/UI events; watermark now emits it; `Tldraw` forwards to UI event system. > - **Worker**: > - Allows CORS for `http://localhost:3001`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 853c2d3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c344a5a commit 882ff73

16 files changed

Lines changed: 471 additions & 13 deletions

File tree

apps/analytics-worker/src/worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
function isAllowedOrigin(origin: string | null): string | undefined {
1414
if (!origin) return undefined
1515
if (origin === 'http://localhost:3000') return origin
16+
if (origin === 'http://localhost:3001') return origin
1617
if (origin === 'http://localhost:5420') return origin
1718
if (origin === 'https://meet.google.com') return origin
1819
if (origin === 'https://tldraw.dev') return origin

apps/analytics/src/analytics-services/analytics-service.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,33 @@ export class AnalyticsService {
1818
trackEvent(name: string, data?: { [key: string]: any }): void {}
1919
// Track a pageview.
2020
trackPageview(): void {}
21+
// Track when consent banner is displayed (called before consent is granted).
22+
trackConsentBannerDisplayed?(data: { consent_opt_in_type: 'manual' | 'auto' }): void
23+
// Track when user selects consent preferences (called before consent is granted).
24+
trackConsentBannerSelected?(data: {
25+
consent_analytics: 'granted' | 'denied'
26+
consent_marketing: 'granted' | 'denied'
27+
consent_opt_in_type: 'manual' | 'auto'
28+
}): void
29+
// Track when user copies a code snippet.
30+
trackCopyCode?(data: {
31+
page_category: string
32+
text_snippet: string
33+
user_email?: string
34+
user_email_sha256?: string
35+
user_first_name?: string
36+
user_last_name?: string
37+
user_phone_number?: string
38+
}): void
39+
// Track when user submits a form.
40+
trackFormSubmission?(data: {
41+
enquiry_type: string
42+
company_size?: string
43+
company_website?: string
44+
user_email: string
45+
user_email_sha256: string
46+
user_first_name: string
47+
user_last_name: string
48+
user_phone_number?: string
49+
}): void
2150
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { GTM_SCRIPT_ID } from '../constants'
2+
import { AnalyticsService } from './analytics-service'
3+
4+
// Extend Window with dataLayer (other Window properties declared in types.ts)
5+
declare global {
6+
interface Window {
7+
dataLayer?: any[]
8+
}
9+
}
10+
11+
// Helper to safely push to dataLayer (internal use only)
12+
function dataLayerPush(data: any) {
13+
if (typeof window !== 'undefined' && window.dataLayer) {
14+
window.dataLayer.push(data)
15+
}
16+
}
17+
18+
class GTMAnalyticsService extends AnalyticsService {
19+
private gtmContainerId: string | undefined
20+
21+
override initialize() {
22+
if (typeof window === 'undefined') return
23+
24+
this.gtmContainerId = window.TL_GTM_CONTAINER_ID ?? undefined
25+
26+
if (!this.gtmContainerId) return
27+
28+
// Set default consent state
29+
window.dataLayer = window.dataLayer || []
30+
dataLayerPush({
31+
'gtm.start': new Date().getTime(),
32+
event: 'gtm.js',
33+
})
34+
35+
dataLayerPush({
36+
event: 'consent_default',
37+
consent: {
38+
ad_storage: 'denied',
39+
ad_user_data: 'denied',
40+
ad_personalization: 'denied',
41+
analytics_storage: 'denied',
42+
wait_for_update: 500,
43+
},
44+
})
45+
46+
// Load the GTM script
47+
const script = document.createElement('script')
48+
script.async = true
49+
script.id = GTM_SCRIPT_ID
50+
script.src = `https://www.googletagmanager.com/gtm.js?id=${this.gtmContainerId}`
51+
document.head.appendChild(script)
52+
}
53+
54+
override dispose() {
55+
const script = document.getElementById(GTM_SCRIPT_ID)
56+
if (script) script.remove()
57+
}
58+
59+
override enable() {
60+
if (this.isEnabled) return
61+
dataLayerPush({
62+
event: 'consent_update',
63+
consent: {
64+
ad_user_data: 'granted',
65+
ad_personalization: 'granted',
66+
ad_storage: 'granted',
67+
analytics_storage: 'granted',
68+
},
69+
})
70+
this.isEnabled = true
71+
}
72+
73+
override disable() {
74+
if (!this.isEnabled) return
75+
dataLayerPush({
76+
event: 'consent_update',
77+
consent: {
78+
ad_user_data: 'denied',
79+
ad_personalization: 'denied',
80+
ad_storage: 'denied',
81+
analytics_storage: 'denied',
82+
},
83+
})
84+
this.isEnabled = false
85+
}
86+
87+
override identify(userId: string, properties?: { [key: string]: any }) {
88+
if (!this.isEnabled) return
89+
dataLayerPush({
90+
event: 'user_identify',
91+
user_id: userId,
92+
user_properties: properties,
93+
})
94+
}
95+
96+
override trackEvent(name: string, data?: { [key: string]: any }) {
97+
if (!this.isEnabled) return
98+
dataLayerPush({
99+
event: name,
100+
...data,
101+
})
102+
}
103+
104+
override trackPageview() {
105+
if (!this.isEnabled) return
106+
dataLayerPush({
107+
event: 'page_view',
108+
})
109+
}
110+
111+
override trackConsentBannerDisplayed(data: { consent_opt_in_type: 'manual' | 'auto' }) {
112+
dataLayerPush({
113+
event: 'display_consent_banner',
114+
id: crypto.randomUUID(),
115+
data,
116+
})
117+
}
118+
119+
override trackConsentBannerSelected(data: {
120+
consent_analytics: 'granted' | 'denied'
121+
consent_marketing: 'granted' | 'denied'
122+
consent_opt_in_type: 'manual' | 'auto'
123+
}) {
124+
dataLayerPush({
125+
event: 'select_consent_banner',
126+
id: crypto.randomUUID(),
127+
data,
128+
})
129+
}
130+
131+
override trackCopyCode(data: {
132+
page_category: string
133+
text_snippet: string
134+
user_email?: string
135+
user_email_sha256?: string
136+
user_first_name?: string
137+
user_last_name?: string
138+
user_phone_number?: string
139+
}) {
140+
if (!this.isEnabled) return
141+
const payload: any = {
142+
event: 'click_copy_code',
143+
id: crypto.randomUUID(),
144+
page: {
145+
category: data.page_category.toLowerCase(),
146+
},
147+
data: {
148+
text_snippet: data.text_snippet.substring(0, 100),
149+
},
150+
_clear: true,
151+
}
152+
153+
// Only include user object if we have user data
154+
if (
155+
data.user_email ||
156+
data.user_email_sha256 ||
157+
data.user_first_name ||
158+
data.user_last_name ||
159+
data.user_phone_number
160+
) {
161+
payload.user = {}
162+
if (data.user_email) payload.user.email = data.user_email.toLowerCase()
163+
if (data.user_email_sha256) payload.user.email_sha256 = data.user_email_sha256.toLowerCase()
164+
if (data.user_first_name) payload.user.first_name = data.user_first_name.toLowerCase()
165+
if (data.user_last_name) payload.user.last_name = data.user_last_name.toLowerCase()
166+
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
167+
}
168+
169+
dataLayerPush(payload)
170+
}
171+
172+
override trackFormSubmission(data: {
173+
enquiry_type: string
174+
company_size?: string
175+
company_website?: string
176+
user_email: string
177+
user_email_sha256: string
178+
user_first_name: string
179+
user_last_name: string
180+
user_phone_number?: string
181+
}) {
182+
if (!this.isEnabled) return
183+
const payload: any = {
184+
event: 'generate_lead',
185+
id: crypto.randomUUID(),
186+
page: {
187+
category: 'enquiry',
188+
},
189+
data: {
190+
enquiry_type: data.enquiry_type.toLowerCase(),
191+
},
192+
user: {
193+
email: data.user_email.toLowerCase(),
194+
email_sha256: data.user_email_sha256.toLowerCase(),
195+
first_name: data.user_first_name.toLowerCase(),
196+
last_name: data.user_last_name.toLowerCase(),
197+
},
198+
_clear: true,
199+
}
200+
201+
// Add optional company data
202+
if (data.company_size) payload.data.company_size = data.company_size.toLowerCase()
203+
if (data.company_website) payload.data.company_website = data.company_website.toLowerCase()
204+
if (data.user_phone_number) payload.user.phone_number = data.user_phone_number
205+
206+
dataLayerPush(payload)
207+
}
208+
}
209+
210+
export const gtmService = new GTMAnalyticsService()

apps/analytics/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ export const REO_SCRIPT_URL = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`
1818
export const POSTHOG_TOKEN = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
1919
export const POSTHOG_API_HOST = 'https://analytics.tldraw.com/i'
2020
export const POSTHOG_UI_HOST = 'https://eu.i.posthog.com'
21+
22+
// Google Tag Manager configuration.
23+
export const GTM_SCRIPT_ID = 'gtm-script-loader'

0 commit comments

Comments
 (0)