mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Marketing clicks and page views on www carry the channel their page load came in through (D-373)
On the public host a telemetry initializer adds the referrer's host and the utm_source, utm_medium and utm_campaign tags of the entry URL to every page view and custom event. Both come from the document itself (document.referrer and the navigation entry), so nothing is stored and no identifier is added; metrics, dependencies and the portal stay as they are. The hero and final get-started buttons and the three Pro plan links now navigate client-side, so landing, plans, get-started and the sign-in click stay one page load. A guard fails on a plain <a> to /get-started or /plans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SignupConsentCta } from "../../components/landing/SignupConsentCta";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
@@ -102,13 +103,14 @@ export default function GetStartedPage() {
|
||||
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
|
||||
When you need more later: the{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="get_started_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
Pro plan
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
|
||||
same service, nothing to migrate.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -73,6 +73,18 @@ describe("marketing page tracking", () => {
|
||||
expect(html).toMatch(/<section\b[^>]*\sid="push"[^>]*\sdata-track-section="push"/);
|
||||
});
|
||||
|
||||
// A plain <a> loads a new document: the click's entry channel (lib/entryAttribution.ts) would
|
||||
// turn into the own host. The static markup cannot tell a <Link> from an <a>, so the source is read.
|
||||
it("links to get-started and plans are client-side Links, so the page load and its entry channel carry over", () => {
|
||||
const root = join(__dirname, "..", "..");
|
||||
const plain = ["components", "app"].flatMap(dir =>
|
||||
readdirSync(join(root, dir), { encoding: "utf-8", recursive: true })
|
||||
.filter(file => file.endsWith(".tsx") && !file.includes("__tests__"))
|
||||
.filter(file => /<a\b[^>]*\shref="\/(?:get-started|plans)\b/.test(readFileSync(join(root, dir, file), "utf-8")))
|
||||
);
|
||||
expect(plain).toEqual([]);
|
||||
});
|
||||
|
||||
it("the AI page carries every question text in its static HTML", () => {
|
||||
const html = render(AiPage);
|
||||
const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'");
|
||||
|
||||
@@ -20,9 +20,9 @@ export function ClosingCta() {
|
||||
Free, open source, deployed in minutes.
|
||||
<br />
|
||||
{"Need SLAs, support, or MSP delegation? "}
|
||||
<a href="/plans/" data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
<Link href="/plans" prefetch={false} data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function FinalCta() {
|
||||
return (
|
||||
<section data-track-section="final_cta" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
|
||||
@@ -6,24 +8,26 @@ export function FinalCta() {
|
||||
Your next enrollment doesn't have to be a black box.
|
||||
</h2>
|
||||
<div className="shrink-0 lg:text-right">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="final_get_started"
|
||||
className="inline-block px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Start monitoring now
|
||||
</a>
|
||||
</Link>
|
||||
<p className="mt-3 text-sm text-[var(--lp-ink-faint)] lg:text-right">
|
||||
Free, open source, deployed in minutes.
|
||||
<span className="block mt-0.5">
|
||||
Need SLAs, support, or MSP delegation?{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="final_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { HeroShots } from "./HeroShots";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
@@ -50,13 +51,14 @@ export function Hero() {
|
||||
</p>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="hero_get_started"
|
||||
className="px-6 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
data-track="hero_docs"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entryAttribution, stampEntryAttribution } from "../entryAttribution";
|
||||
|
||||
const PAGE = "https://www.example.test/";
|
||||
|
||||
describe("entry referrer", () => {
|
||||
it.each([
|
||||
["no referrer", "", "direct"],
|
||||
["a search engine origin", "https://search.example.test/", "search.example.test"],
|
||||
["a full URL, cut to its host", "https://blog.example.test/2026/10/post?ref=feed&id=42", "blog.example.test"],
|
||||
["an Android app referrer", "android-app://com.example.reader/", "com.example.reader"],
|
||||
["upper case", "https://Social.EXAMPLE.test/", "social.example.test"],
|
||||
["a port", "http://intranet.example.test:8080/wiki/page", "intranet.example.test:8080"],
|
||||
["an earlier page load on the own host", "https://www.example.test/plans/", "www.example.test"],
|
||||
["no host", "about:blank", "unknown"],
|
||||
["garbage", "not a url", "unknown"],
|
||||
])("%s", (_name, referrer, expected) => {
|
||||
expect(entryAttribution(referrer, PAGE).entryReferrer).toBe(expected);
|
||||
});
|
||||
|
||||
it("caps a long host", () => {
|
||||
const host = `${"a".repeat(120)}.example.test`;
|
||||
expect(entryAttribution(`https://${host}/`, PAGE).entryReferrer).toBe(host.slice(0, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry UTM tags", () => {
|
||||
it("reads source, medium and campaign of the document URL", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_source=social-net&utm_medium=social&utm_campaign=launch_2026.10`);
|
||||
expect(entry).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "social-net",
|
||||
entryUtmMedium: "social",
|
||||
entryUtmCampaign: "launch_2026.10",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a missing tag out", () => {
|
||||
expect(entryAttribution("", `${PAGE}get-started/?utm_source=newsletter`)).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "newsletter",
|
||||
});
|
||||
expect(entryAttribution("", PAGE)).toEqual({ entryReferrer: "direct" });
|
||||
});
|
||||
|
||||
it("never reads utm_content, utm_term or other parameters", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_content=recipient-123&utm_term=abc&ref=x&utm_source=event`);
|
||||
expect(entry).toEqual({ entryReferrer: "direct", entryUtmSource: "event" });
|
||||
});
|
||||
|
||||
it("lower-cases and trims a tag", () => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_source=%20Social-Net%20`).entryUtmSource).toBe("social-net");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["spaces inside", "summit+2026"],
|
||||
["an address", "someone%40example.test"],
|
||||
["an empty value", ""],
|
||||
["a leading separator", "-launch"],
|
||||
["too long", "a".repeat(65)],
|
||||
])("reports a tag with %s as other", (_name, value) => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_campaign=${value}`).entryUtmCampaign).toBe("other");
|
||||
});
|
||||
|
||||
it("keeps the referrer when the document URL cannot be parsed", () => {
|
||||
expect(entryAttribution("https://search.example.test/", "not a url")).toEqual({ entryReferrer: "search.example.test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampEntryAttribution", () => {
|
||||
const entry = { entryReferrer: "search.example.test", entryUtmSource: "newsletter" };
|
||||
|
||||
it.each([["PageviewData"], ["EventData"]])("stamps a %s item and keeps its data", (baseType) => {
|
||||
const envelope = { baseType, data: { tenantId: "t", theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(true);
|
||||
expect(envelope.data).toEqual({ tenantId: "t", theme: "dark", ...entry });
|
||||
});
|
||||
|
||||
it("creates the data bag when the item has none", () => {
|
||||
const envelope: { baseType: string; data?: Record<string, unknown> } = { baseType: "EventData" };
|
||||
stampEntryAttribution(envelope, entry);
|
||||
expect(envelope.data).toEqual(entry);
|
||||
});
|
||||
|
||||
it.each([["MetricData"], ["RemoteDependencyData"], ["PageviewPerformanceData"], ["ExceptionData"], [undefined]])(
|
||||
"leaves a %s item alone",
|
||||
(baseType) => {
|
||||
const envelope = { baseType, data: { theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(false);
|
||||
expect(envelope.data).toEqual({ theme: "dark" });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
|
||||
import { API_BASE_URL, PORTAL_URL, SITE_URL } from "@/lib/config";
|
||||
import type { TelemetryContext } from "./appInsights";
|
||||
import { readEntryAttribution, stampEntryAttribution } from "./entryAttribution";
|
||||
import { isOnPublicHost } from "./hostRouting";
|
||||
import { normalizeRoute, toWebVitalTelemetry } from "./webVitals";
|
||||
import { buildOwnOriginNonApiPatterns, shouldDropDependency, stripPageViewFragments } from "./webTelemetryExclusions";
|
||||
|
||||
@@ -78,6 +80,13 @@ export function createAppInsights(connectionString: string, context: TelemetryCo
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stripPageViewFragments(envelope);
|
||||
});
|
||||
// Public host: page views and custom events carry the channel this page load came in through.
|
||||
const entry = isOnPublicHost() ? readEntryAttribution() : null;
|
||||
if (entry) {
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stampEntryAttribution(envelope, entry);
|
||||
});
|
||||
}
|
||||
|
||||
appInsights.loadAppInsights();
|
||||
// Returning false drops the item (D-277); undefined keeps it.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The channel a page load on the public host came in through: the referrer's host and the UTM
|
||||
* tags of the URL the document was loaded with. Both stay the same for the whole page load —
|
||||
* a client-side navigation changes neither `document.referrer` nor the navigation entry — so
|
||||
* nothing is stored. Stamped on page views and custom events, counted per channel, never per
|
||||
* person (D-304, D-305).
|
||||
*/
|
||||
|
||||
export type EntryAttribution = Readonly<Record<string, string>>;
|
||||
|
||||
/** Only these three tags: `utm_content` and `utm_term` are where mail tools put recipient ids. */
|
||||
const UTM_PROPERTIES = [
|
||||
["utm_source", "entryUtmSource"],
|
||||
["utm_medium", "entryUtmMedium"],
|
||||
["utm_campaign", "entryUtmCampaign"],
|
||||
] as const;
|
||||
|
||||
const UTM_VALUE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const MAX_REFERRER_HOST = 100;
|
||||
|
||||
/** `baseType` of the SDK items that carry the entry: page views and custom events. */
|
||||
const STAMPED_BASE_TYPES: readonly string[] = ["PageviewData", "EventData"];
|
||||
|
||||
/** The referrer's host only, never its path or query; `direct` without a referrer. */
|
||||
function referrerHost(referrer: string): string {
|
||||
if (!referrer) return "direct";
|
||||
try {
|
||||
const host = new URL(referrer).host.toLowerCase();
|
||||
return host ? host.slice(0, MAX_REFERRER_HOST) : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function entryAttribution(referrer: string, documentUrl: string): EntryAttribution {
|
||||
const entry: Record<string, string> = { entryReferrer: referrerHost(referrer) };
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
params = new URL(documentUrl).searchParams;
|
||||
} catch {
|
||||
return entry;
|
||||
}
|
||||
for (const [param, property] of UTM_PROPERTIES) {
|
||||
const value = params.get(param);
|
||||
if (value === null) continue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
entry[property] = UTM_VALUE.test(normalized) ? normalized : "other";
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** This page load's entry, read in the browser. */
|
||||
export function readEntryAttribution(): EntryAttribution {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
return entryAttribution(document.referrer, navigation?.name || window.location.href);
|
||||
}
|
||||
|
||||
/** The fields of an ITelemetryItem the stamp reads and writes. */
|
||||
interface EnvelopeLike {
|
||||
baseType?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* For `addTelemetryInitializer`: adds the entry to a page view or custom event, in place. Other
|
||||
* item kinds are left alone. Returns true when it stamped, for the tests — the initializer must
|
||||
* not pass it on, a returned false drops the item.
|
||||
*/
|
||||
export function stampEntryAttribution(envelope: EnvelopeLike, entry: EntryAttribution): boolean {
|
||||
if (!envelope.baseType || !STAMPED_BASE_TYPES.includes(envelope.baseType)) return false;
|
||||
envelope.data = Object.assign(envelope.data ?? {}, entry);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user