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