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
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.9.0"
".": "1.10.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
61 changes: 57 additions & 4 deletions apps/api/src/tracking/tracking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,6 +45,7 @@ export interface TrackingSettings {
ready: boolean;
scriptUrl: string;
snippet: string | null;
tagManagerSnippet: string | null;
crossDomain: boolean;
limitToDomains: boolean;
cookieSubdomains: boolean;
Expand Down Expand Up @@ -89,15 +95,26 @@ export interface SourceRow {
contacts: number;
}

export interface FoundInContainer {
id: string;
carriesSiteId: boolean;
}

export type VerifyResult =
| {
status: "found";
host: string;
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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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<FoundInContainer | null> {
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<WebsiteActivity> {
const visitors = await this.db.trackedVisitor.findMany({
where: { contact: { companyId } },
Expand Down
49 changes: 41 additions & 8 deletions apps/app/app/(app)/[slug]/settings/tracking/tracking-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."));
};
Expand Down Expand Up @@ -109,7 +116,7 @@ export function TrackingScript() {
</CardDescription>

<CardAction>
<Button size="sm" onClick={copy} type="button">
<Button size="sm" onClick={() => copy(snippet)} type="button">
<Icon icon={Copy} data-icon="inline-start" />
Copy
</Button>
Expand Down Expand Up @@ -143,15 +150,41 @@ export function TrackingScript() {
Add it through Google Tag Manager
</AccordionTrigger>
<AccordionContent>
<pre className="overflow-x-auto rounded-md border bg-muted p-4 font-mono text-code-foreground text-xs/5">
<span className="text-code-accent">{"<script"}</span>
{"\n src="}
<span className="text-code-string">{`"${scriptUrl}?site=${siteId}"`}</span>
{"\n async\n defer\n"}
<span className="text-code-accent">{"></script>"}</span>
</pre>
<ol className="flex list-decimal flex-col gap-1 pl-4 text-muted-foreground text-xs/relaxed">
<li>In Tag Manager, add a new Custom HTML tag.</li>
<li>Paste the snippet above as the tag's HTML.</li>
<li>
Paste this snippet — not the one above — as the tag's HTML.
</li>
<li>
Trigger it on All Pages, then publish the container. Keep{" "}
<span className="font-mono text-foreground">{scriptUrl}</span>{" "}
off any consent-blocked category you do not need.
</li>
</ol>
<div className="flex items-center justify-between gap-4">
<p className="text-muted-foreground text-xs/relaxed">
Tag Manager drops a{" "}
<span className="font-mono text-foreground">data-site</span>{" "}
attribute when it injects a script, so this form carries the
site ID in the URL instead.
</p>
<Button
size="xs"
variant="outline"
type="button"
onClick={() => copy(tagManagerSnippet)}
>
<Icon icon={Copy} data-icon="inline-start" />
Copy
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function VerifyInstallation() {
</div>
</CardTitle>
<CardDescription>
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.
</CardDescription>

<CardAction>
Expand Down Expand Up @@ -127,6 +128,16 @@ function Indicator({ result }: { result: Result }) {
);
}

if (result.status === "found" && result.container?.carriesSiteId === false) {
return (
<StatusIndicator
size="sm"
tone="warning"
label="Tag Manager needs a fix"
/>
);
}

return (
<StatusIndicator
size="sm"
Expand Down Expand Up @@ -159,6 +170,28 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) {
The page answered in {result.responseMs} ms, but the tag was not in
the HTML. Check that it sits in the head, above anything that rewrites
the page.
{result.containers.length > 0
? ` We also read Tag Manager container ${result.containers.join(" and ")}, and the tag is not in there either.`
: ""}
</AlertDescription>
</Alert>
);
}

if (result.container && !result.container.carriesSiteId) {
return (
<Alert variant="destructive">
<Icon icon={Warning} />
<AlertTitle>Tag Manager will drop the site ID</AlertTitle>
<AlertDescription>
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."
: ""}
</AlertDescription>
</Alert>
);
Expand All @@ -167,10 +200,17 @@ function Outcome({ result, siteId }: { result: Result; siteId: string }) {
return (
<Alert>
<Icon icon={CheckmarkFilled} className="text-success" />
<AlertTitle>Script found on {result.host}</AlertTitle>
<AlertTitle>
{result.container
? `Script found in container ${result.container.id}`
: `Script found on ${result.host}`}
</AlertTitle>
<AlertDescription>
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."}
Expand Down
10 changes: 5 additions & 5 deletions apps/app/lib/tracking/loader.ts
Original file line number Diff line number Diff line change
@@ -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);
})();
`;
Loading