From 7d07cb7805dd4f9ef4d6092c9ff3c779a2145171 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 21 Oct 2026 18:23:03 +0200 Subject: [PATCH] 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 to /get-started or /plans. Co-Authored-By: Claude Opus 5.5 --- .../app/get-started/page.tsx | 8 +- .../__tests__/marketingTracking.guard.test.ts | 14 ++- .../components/ai/ClosingCta.tsx | 4 +- .../components/landing/FinalCta.tsx | 16 ++-- .../components/landing/Hero.tsx | 8 +- .../lib/__tests__/entryAttribution.test.ts | 93 +++++++++++++++++++ .../lib/appInsightsRuntime.ts | 9 ++ .../lib/entryAttribution.ts | 73 +++++++++++++++ 8 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/entryAttribution.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/entryAttribution.ts diff --git a/src/Web/autopilot-monitor-web/app/get-started/page.tsx b/src/Web/autopilot-monitor-web/app/get-started/page.tsx index ece81b08..990028f6 100644 --- a/src/Web/autopilot-monitor-web/app/get-started/page.tsx +++ b/src/Web/autopilot-monitor-web/app/get-started/page.tsx @@ -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() {

When you need more later: the{" "} - Pro plan - {" "} + {" "} adds SLAs, support commitments, MSP delegation, higher operating limits and more — same service, nothing to migrate.

diff --git a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts index 9973d500..00ce6d24 100644 --- a/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts +++ b/src/Web/autopilot-monitor-web/components/__tests__/marketingTracking.guard.test.ts @@ -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(/]*\sid="push"[^>]*\sdata-track-section="push"/); }); + // A plain loads a new document: the click's entry channel (lib/entryAttribution.ts) would + // turn into the own host. The static markup cannot tell a from an , 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 => /]*\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, "'"); diff --git a/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx b/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx index da68b0b9..e670f474 100644 --- a/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx +++ b/src/Web/autopilot-monitor-web/components/ai/ClosingCta.tsx @@ -20,9 +20,9 @@ export function ClosingCta() { Free, open source, deployed in minutes.
{"Need SLAs, support, or MSP delegation? "} -
+ There's a Pro plan - + .

diff --git a/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx b/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx index f0e51fb1..9d45c615 100644 --- a/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/FinalCta.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + export function FinalCta() { return (
@@ -6,24 +8,26 @@ export function FinalCta() { Your next enrollment doesn't have to be a black box.
- Start monitoring now - +

Free, open source, deployed in minutes. Need SLAs, support, or MSP delegation?{" "} - There's a Pro plan - + .

diff --git a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx index fadda2a0..ba440e07 100644 --- a/src/Web/autopilot-monitor-web/components/landing/Hero.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/Hero.tsx @@ -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() {

- Get started - + { + 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 }; + 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 } = { 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 }; + expect(stampEntryAttribution(envelope, entry)).toBe(false); + expect(envelope.data).toEqual({ theme: "dark" }); + } + ); +}); diff --git a/src/Web/autopilot-monitor-web/lib/appInsightsRuntime.ts b/src/Web/autopilot-monitor-web/lib/appInsightsRuntime.ts index 0230a6c8..7db131f3 100644 --- a/src/Web/autopilot-monitor-web/lib/appInsightsRuntime.ts +++ b/src/Web/autopilot-monitor-web/lib/appInsightsRuntime.ts @@ -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. diff --git a/src/Web/autopilot-monitor-web/lib/entryAttribution.ts b/src/Web/autopilot-monitor-web/lib/entryAttribution.ts new file mode 100644 index 00000000..7aa7e276 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/entryAttribution.ts @@ -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>; + +/** 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 = { 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; +} + +/** + * 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; +}