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;
+}