diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 129d1b297..10a099f1d 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.9.0" + ".": "1.10.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e747681..ac085b3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.10.0](https://github.com/trycompai/crm/compare/v1.9.0...v1.10.0) (2026-08-11) + + +### Features + +* **tracking:** support installing the tracking tag via Google Tag Manager ([#124](https://github.com/trycompai/crm/issues/124)) ([2d8129c](https://github.com/trycompai/crm/commit/2d8129ccdd75ca2630289f4bf0cacd04505150b3)) + ## [1.9.0](https://github.com/trycompai/crm/compare/v1.8.2...v1.9.0) (2026-08-11) diff --git a/apps/api/src/tracking/tracking.service.ts b/apps/api/src/tracking/tracking.service.ts index 9babc087d..982df1cdf 100644 --- a/apps/api/src/tracking/tracking.service.ts +++ b/apps/api/src/tracking/tracking.service.ts @@ -11,8 +11,13 @@ import { safeFetch } from "@crm/db/safe-fetch"; import { SETTINGS_ID } from "@crm/db/settings"; import { COOKIE_LIFETIMES, + gtmContainers, + gtmContainerUrl, + gtmSnippet, + gtmTag, hostAllowed, loaderUrl, + MAX_VERIFY_BYTES, normalizeHost, trackingReady, trackingSnippet, @@ -40,6 +45,7 @@ export interface TrackingSettings { ready: boolean; scriptUrl: string; snippet: string | null; + tagManagerSnippet: string | null; crossDomain: boolean; limitToDomains: boolean; cookieSubdomains: boolean; @@ -89,6 +95,11 @@ export interface SourceRow { contacts: number; } +export interface FoundInContainer { + id: string; + carriesSiteId: boolean; +} + export type VerifyResult = | { status: "found"; @@ -96,8 +107,14 @@ export type VerifyResult = responseMs: number; allowed: boolean; pageView: boolean; + container: FoundInContainer | null; + } + | { + status: "missing"; + host: string; + responseMs: number; + containers: string[]; } - | { status: "missing"; host: string; responseMs: number } | { status: "unreachable"; host: string; detail: string }; @Injectable() @@ -145,6 +162,7 @@ export class TrackingService { ready, scriptUrl: scriptUrl(), snippet: siteId ? snippet(siteId) : null, + tagManagerSnippet: siteId ? gtmSnippet(appUrl, siteId) : null, crossDomain: row?.trackingCrossDomain ?? true, limitToDomains: row?.trackingLimitToDomains ?? true, cookieSubdomains: row?.trackingCookieSubdomains ?? false, @@ -316,11 +334,21 @@ export class TrackingService { }; } - const body = (await fetched.response.text()).slice(0, 512_000); + const body = (await fetched.response.text()).slice(0, MAX_VERIFY_BYTES); const siteId = compiled?.config.siteId; - if (!siteId || !mentions(body, siteId)) { - return { status: "missing", host, responseMs }; + if (!siteId) { + return { status: "missing", host, responseMs, containers: [] }; + } + + const inHtml = mentions(body, siteId); + const containers = inHtml ? [] : gtmContainers(body); + const container = inHtml + ? null + : await this.inContainers(containers, siteId); + + if (!inHtml && !container) { + return { status: "missing", host, responseMs, containers }; } const since = new Date(Date.now() - VERIFY_WINDOW_MS); @@ -335,9 +363,34 @@ export class TrackingService { responseMs, allowed: compiled ? hostAllowed(host, compiled.config) : false, pageView: seen !== null, + container, }; } + private async inContainers( + containers: string[], + siteId: string, + ): Promise { + let attribute: FoundInContainer | null = null; + + for (const id of containers) { + const fetched = await safeFetch(gtmContainerUrl(id), { + timeoutMs: 8_000, + }); + if (!fetched?.response.ok) continue; + + const source = (await fetched.response.text()).slice(0, MAX_VERIFY_BYTES); + const state = gtmTag(source, siteId); + + if (state === "url") return { id, carriesSiteId: true }; + if (state === "attribute" && !attribute) { + attribute = { id, carriesSiteId: false }; + } + } + + return attribute; + } + async activityForCompany(companyId: string): Promise { const visitors = await this.db.trackedVisitor.findMany({ where: { contact: { companyId } }, diff --git a/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx b/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx index 054a36a48..b349b44dc 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx @@ -68,19 +68,26 @@ export function TrackingScript() { if (!tracking.data) return null; - const { siteId, snippet, scriptUrl, receivingSince, paused, canManage } = - tracking.data; - - const copy = () => { + const { + siteId, + snippet, + tagManagerSnippet, + scriptUrl, + receivingSince, + paused, + canManage, + } = tracking.data; + + const copy = (value: string | null) => { const clipboard = navigator.clipboard; - if (!snippet || !clipboard) { + if (!value || !clipboard) { toast.error("Could not copy the script. Select it instead."); return; } clipboard - .writeText(snippet) + .writeText(value) .then(() => toast.success("Script copied.")) .catch(() => toast.error("Could not copy the script.")); }; @@ -109,7 +116,7 @@ export function TrackingScript() { - @@ -143,15 +150,41 @@ export function TrackingScript() { Add it through Google Tag Manager +
+								{"
+								{"\n  src="}
+								{`"${scriptUrl}?site=${siteId}"`}
+								{"\n  async\n  defer\n"}
+								{">"}
+							
  1. In Tag Manager, add a new Custom HTML tag.
  2. -
  3. Paste the snippet above as the tag's HTML.
  4. +
  5. + Paste this snippet — not the one above — as the tag's HTML. +
  6. Trigger it on All Pages, then publish the container. Keep{" "} {scriptUrl}{" "} off any consent-blocked category you do not need.
+
+

+ Tag Manager drops a{" "} + data-site{" "} + attribute when it injects a script, so this form carries the + site ID in the URL instead. +

+ +
diff --git a/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx b/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx index bcc19cdd6..5b10f21af 100644 --- a/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx +++ b/apps/app/app/(app)/[slug]/settings/tracking/verify-installation.tsx @@ -60,7 +60,8 @@ export function VerifyInstallation() { - We load one page and look for the script. + We load one page and look for the script, then read your Tag Manager + container if it is not in the HTML. @@ -127,6 +128,16 @@ function Indicator({ result }: { result: Result }) { ); } + if (result.status === "found" && result.container?.carriesSiteId === false) { + return ( + + ); + } + return ( 0 + ? ` We also read Tag Manager container ${result.containers.join(" and ")}, and the tag is not in there either.` + : ""} + + + ); + } + + if (result.container && !result.container.carriesSiteId) { + return ( + + + Tag Manager will drop the site ID + + Container {result.container.id} carries the tag, but the site ID is + not in the script URL. Tag Manager keeps only the URL when it injects + a script, so a data-site attribute never reaches the page and the + tracker never starts. Copy the Tag Manager snippet above and replace + the tag's HTML. + {result.pageView + ? " A page view did arrive in the last five minutes, so something on this site is still recording." + : ""} ); @@ -167,10 +200,17 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) { return ( - Script found on {result.host} + + {result.container + ? `Script found in container ${result.container.id}` + : `Script found on ${result.host}`} + It answered in {result.responseMs} ms. Site ID {siteId} matched, and this domain is {result.allowed ? "on" : "not on"} the allow list. + {result.container + ? " The tag is not in the HTML, so it only runs once Tag Manager fires it — a page view is the proof." + : ""} {result.pageView ? " A page view arrived in the last five minutes." : " No page view has arrived yet — open the page in a browser to send one."} diff --git a/apps/app/lib/tracking/loader.ts b/apps/app/lib/tracking/loader.ts index ef2ea13af..1f726cf95 100644 --- a/apps/app/lib/tracking/loader.ts +++ b/apps/app/lib/tracking/loader.ts @@ -1,17 +1,17 @@ export const LOADER_SOURCE = `(function(){ -var s=document.currentScript||document.querySelector("script[data-site][src*='/t/crm.js']"); +var s=document.currentScript||document.querySelector("script[src*='/t/crm.js']"); if(!s||!s.src)return; -var site=s.getAttribute("data-site"); +var u; +try{u=new URL(s.src)}catch(e){return} +var site=s.getAttribute("data-site")||u.searchParams.get("site"); if(!site||!/^cmp_[0-9a-f]{8}$/.test(site))return; var id="crm-tracker-"+site; if(document.getElementById(id))return; -var origin; -try{origin=new URL(s.src).origin}catch(e){return} var t=document.createElement("script"); t.id=id; t.async=!0; t.defer=!0; -t.src=origin+"/t/"+site+".js"; +t.src=u.origin+"/t/"+site+".js"; (document.head||document.documentElement).appendChild(t); })(); `; diff --git a/apps/app/test/tracking-bundle.spec.ts b/apps/app/test/tracking-bundle.spec.ts index eeb679c45..f8d7eabd6 100644 --- a/apps/app/test/tracking-bundle.spec.ts +++ b/apps/app/test/tracking-bundle.spec.ts @@ -43,6 +43,82 @@ describe("the tracking bundle stays inside its budget", () => { expect(LOADER_SOURCE).toContain("cmp_[0-9a-f]{8}"); expect(LOADER_SOURCE).toContain("document.currentScript"); }); +}); + +function inject( + tag: { src: string; "data-site"?: string }, + existing: boolean = false, +): string[] { + const injected: string[] = []; + + const script = { + src: tag.src, + getAttribute: (name: string) => + name === "data-site" ? (tag["data-site"] ?? null) : null, + }; + + const document = { + currentScript: script, + querySelector: () => script, + getElementById: () => (existing ? script : null), + createElement: () => ({}) as Record, + head: { + appendChild: (node: { src: string }) => injected.push(node.src), + }, + }; + + new Function("document", LOADER_SOURCE)(document); + + return injected; +} + +describe("the loader finds the site id however the page was built", () => { + test("reads the attribute a rep pasted into their own HTML", () => { + expect( + inject({ + src: "https://crm.example.com/t/crm.js", + "data-site": "cmp_8f3ad91c", + }), + ).toEqual(["https://crm.example.com/t/cmp_8f3ad91c.js"]); + }); + + test("reads the URL when a tag manager stripped the attribute", () => { + expect( + inject({ src: "https://crm.example.com/t/crm.js?site=cmp_8f3ad91c" }), + ).toEqual(["https://crm.example.com/t/cmp_8f3ad91c.js"]); + }); + + test("prefers the attribute, so a stale URL cannot outvote the pasted tag", () => { + expect( + inject({ + src: "https://crm.example.com/t/crm.js?site=cmp_11112222", + "data-site": "cmp_8f3ad91c", + }), + ).toEqual(["https://crm.example.com/t/cmp_8f3ad91c.js"]); + }); + + test("injects nothing when neither carries a site", () => { + expect(inject({ src: "https://crm.example.com/t/crm.js" })).toEqual([]); + }); + + test("refuses a site id from the URL that is not one", () => { + for (const site of ["../config", "cmp_ZZZZZZZZ", "cmp_", ""]) { + expect( + inject({ + src: `https://crm.example.com/t/crm.js?site=${encodeURIComponent(site)}`, + }), + ).toEqual([]); + } + }); + + test("injects once, however many copies of the tag a container fires", () => { + expect( + inject( + { src: "https://crm.example.com/t/crm.js?site=cmp_8f3ad91c" }, + true, + ), + ).toEqual([]); + }); test("the tracker bakes the config in rather than fetching it", () => { const source = trackerSource(CONFIG, "https://crm.example.com/api/t/e"); diff --git a/docs/tracking.md b/docs/tracking.md index ab5e2faab..bfa2a514d 100644 --- a/docs/tracking.md +++ b/docs/tracking.md @@ -13,7 +13,7 @@ cookie is first-party, and the only thing that ever leaves the browser is a POST | | | | --- | --- | -| `apps/app/lib/tracking/loader.ts` → `/t/crm.js` | The tag a rep pastes. Reads `data-site`, checks the shape, injects the second script. Immutable and cached for a year at the edge | +| `apps/app/lib/tracking/loader.ts` → `/t/crm.js` | The tag a rep pastes. Reads the site id from `data-site` or from `?site=`, checks the shape, injects the second script. Immutable and cached for a year at the edge | | `apps/app/lib/tracking/tracker.ts` → `/t/.js` | The tracker itself, with the config **baked into the source** rather than fetched. Cached for five minutes | The split is the whole cache design. The tag never changes, so it is `immutable` @@ -21,6 +21,16 @@ and free forever; the config does change, so the file that carries it is the one with the short life. Baking the config in also means a page view costs one request, not a request and then a config fetch before anything can be recorded. +- **The site id has two carriers, and a tag manager is the reason.** Google Tag + Manager's Custom HTML injector rebuilds the script element and keeps only the + URL — `data-site`, `async` and `defer` are all dropped on the way in. An + attribute-only tag therefore loads `/t/crm.js`, finds no site, and returns: + the loader is in the network tab, the tracker never is, and the install looks + installed while recording nothing. `?site=` rides in the `src`, which no + injector can strip. **`data-site` still wins when both are present**, so a + pasted tag beats a stale URL. Either way **the loader stays config-free** — + baking the install's own id into it would put a year-immutable edge cache in + front of the rotate kill switch. - **Five minutes is a promise.** Pause tracking and every browser stops within `CONFIG_MAX_AGE_SECONDS`. That is why `/t/[site]` carries **no `stale-while-revalidate`** — a revalidation window is exactly a licence to keep @@ -186,6 +196,18 @@ everybody: a member's render would fire a request that can only be refused. redirects, so the host in the result comes from `fetched.url`, not from what the rep typed — otherwise `acme.com` reports the allow-list status of a page that lives on `www.acme.com`. +- **Verify reads Tag Manager containers, not only the HTML.** A tag a container + injects is not in the response, so an HTML-only check calls a working install + broken — and it did. When the HTML carries no tag, `verify` takes the `GTM-…` + ids out of it and reads `gtmContainerUrl(id)`, at most `MAX_CONTAINERS` of + them. **The URL is built from the id we matched, never from anything a rep + typed**, so the one host this can ever reach is Google's. A container holding + the attribute form is reported as *found and broken*, with the fix, because + that is precisely what it is — and `missing` names the containers it read, so + a rep can tell *not installed* from *not looked at*. +- **`MAX_VERIFY_BYTES` is measured against real marketing pages.** The old + 512 KB cut was smaller than one homepage this repo's own company ships, and a + tag past the cut reads exactly like a tag that is not there. - **Rotating the site id is the kill switch for a stolen snippet.** The old id stops resolving at `forSite` within the cache TTL. - **The compiled config is cached for five minutes and invalidated on every write.** diff --git a/package.json b/package.json index a7e7d42c5..4fd6e02aa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.9.0", + "version": "1.10.0", "scripts": { "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build": "turbo run build", diff --git a/packages/db/src/tracking.ts b/packages/db/src/tracking.ts index 8b9cd2bcd..5757de709 100644 --- a/packages/db/src/tracking.ts +++ b/packages/db/src/tracking.ts @@ -32,6 +32,12 @@ export const EVENT_RETENTION_DAYS = 90; export const VERIFY_WINDOW_MS = 5 * 60_000; +export const MAX_VERIFY_BYTES = 2_000_000; + +export const MAX_CONTAINERS = 2; + +export type GtmTagState = "absent" | "url" | "attribute"; + export type DomainScopeValue = "SITE_AND_SUBDOMAINS" | "EXACT_HOST"; export interface TrackedHost { @@ -92,6 +98,33 @@ export function trackingSnippet(appUrl: string, siteId: string): string { return ``; } +export function gtmLoaderUrl(appUrl: string, siteId: string): string { + return `${loaderUrl(appUrl)}?site=${siteId}`; +} + +export function gtmSnippet(appUrl: string, siteId: string): string { + return ``; +} + +export function gtmContainerUrl(container: string): string { + return `https://www.googletagmanager.com/gtm.js?id=${container}`; +} + +export function gtmContainers(html: string): string[] { + const found = html.match(/GTM-[A-Z0-9]{4,10}/g) ?? []; + + return [...new Set(found)].slice(0, MAX_CONTAINERS); +} + +export function gtmTag(source: string, siteId: string): GtmTagState { + const text = source.replace(/\\\//g, "/"); + + if (!text.includes("/t/crm.js")) return "absent"; + if (text.includes(`/t/crm.js?site=${siteId}`)) return "url"; + + return text.includes(siteId) ? "attribute" : "absent"; +} + export function configHash(config: TrackingConfig): string { const canonical = JSON.stringify({ siteId: config.siteId, diff --git a/packages/db/test/tracking.spec.ts b/packages/db/test/tracking.spec.ts index e68065519..a88d3221f 100644 --- a/packages/db/test/tracking.spec.ts +++ b/packages/db/test/tracking.spec.ts @@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"; import { configHash, dedupeKey, + gtmContainers, + gtmContainerUrl, + gtmSnippet, + gtmTag, hostAllowed, isSiteId, loaderUrl, @@ -73,6 +77,81 @@ describe("the snippet a rep copies", () => { }); }); +describe("the snippet a rep pastes into Tag Manager", () => { + const value = gtmSnippet("https://crm.example.com", "cmp_6e9356c9"); + + test("carries the site id in the URL, where the injector cannot drop it", () => { + expect(value).toBe( + '', + ); + }); + + test("carries no data-site, so there is nothing to lose", () => { + expect(value).not.toInclude("data-site"); + }); +}); + +describe("finding the tag inside a Tag Manager container", () => { + const custom = (attributes: string) => + `{"function":"__html","vtp_html":"\\u003Cscript ${attributes} type=\\"text\\/gtmscript\\"\\u003E\\u003C\\/script\\u003E"}`; + + test("reads the container ids out of a page", () => { + const html = + '' + + ""; + + expect(gtmContainers(html)).toEqual(["GTM-N47SXGJB"]); + }); + + test("takes at most two containers, because each one is half a megabyte", () => { + const html = ["GTM-AAAA1111", "GTM-BBBB2222", "GTM-CCCC3333"].join(" "); + + expect(gtmContainers(html)).toEqual(["GTM-AAAA1111", "GTM-BBBB2222"]); + }); + + test("finds nothing in a page that has no container", () => { + expect(gtmContainers("nothing here")).toEqual([]); + }); + + test("builds the container URL from the id alone, so nothing user-typed is fetched", () => { + expect(gtmContainerUrl("GTM-N47SXGJB")).toBe( + "https://www.googletagmanager.com/gtm.js?id=GTM-N47SXGJB", + ); + }); + + test("reports the URL form as the one that survives injection", () => { + const source = custom( + 'data-gtmsrc=\\"https:\\/\\/crm.example.com\\/t\\/crm.js?site=cmp_8f3ad91c\\" async defer', + ); + + expect(gtmTag(source, "cmp_8f3ad91c")).toBe("url"); + }); + + test("reports the attribute form, which Tag Manager strips on the way in", () => { + const source = custom( + 'data-gtmsrc=\\"https:\\/\\/crm.example.com\\/t\\/crm.js\\" data-site=\\"cmp_8f3ad91c\\" async defer', + ); + + expect(gtmTag(source, "cmp_8f3ad91c")).toBe("attribute"); + }); + + test("is absent when the container carries a tag for a site id we rotated away from", () => { + const source = custom( + 'data-gtmsrc=\\"https:\\/\\/crm.example.com\\/t\\/crm.js\\" data-site=\\"cmp_11112222\\"', + ); + + expect(gtmTag(source, "cmp_8f3ad91c")).toBe("absent"); + }); + + test("is absent from a container full of somebody else's tags", () => { + const source = custom( + 'data-gtmsrc=\\"https:\\/\\/js-na2.hs-scripts.com\\/243178497.js\\"', + ); + + expect(gtmTag(source, "cmp_8f3ad91c")).toBe("absent"); + }); +}); + describe("whether there is anything to install yet", () => { test("is not ready with the limit on and no domains", () => { expect(trackingReady(true, 0)).toBe(false);