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
29 changes: 21 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,28 @@ ships with the next promotion — the items below are what's left after that.
(portwing's landed), so a Playwright canary's silence proves nothing
either way. Verify with a real browser or by reading the deployed chunk
for the property names.
- [ ] Acquisition data: decided org-wide in `CodesWhat/ops`
- [x] Acquisition data: decided org-wide in `CodesWhat/ops`
`standards/analytics.md` ("Acquisition data and consent", as of
`c161ebc`) — that file is the authority. Shape: `save_campaign_params:
true` with `gclid`/`fbclid`/`msclkid` excluded; `save_referrer: true`
with the sanitizer forwarding `$referring_domain` only (drop it unless
it's a bare hostname, never copy `$referrer`); geo stays off (cookieless
strips the IP upstream, PostHog #48660). No banner needed. Implement
AFTER the `$pathname`/`$pageleave` promotion is verified in production,
so the two changes are separately attributable.
`c161ebc`) — that file is the authority. Landed: `save_campaign_params:
true` with `mask_personal_data_properties: true` (masks
`gclid`/`fbclid`/`msclkid` and the rest of the SDK's per-click ids);
`save_referrer: true` with the sanitizer forwarding `$referring_domain`
only (bare hostname, never `$referrer`). The sanitizer forwards only
`utm_source` (github, x, bluesky, linkedin, reddit, hackernews,
newsletter, producthunt), `utm_medium` (social, email, referral,
community) and `utm_campaign` (`/^[a-z0-9-]{1,40}$/`); everything else is
dropped. The vocabulary is pending Scott's approval. Geo stays off
(cookieless strips the IP upstream, PostHog #48660). No banner needed.
- [x] `$web_vitals` carries `$pathname` from the sanitized path
- [x] `cta activated` for the project cards: `project_<slug>` ids, placement
`projects`. `cta activated` had zero rows in 120 days on codeswhat.com;
reproduced in a browser and the capture, sanitizer and send path all work,
so it was low traffic (76 pageviews) rather than a bug. Check it fires after
deploy.
- [x] `newsletter submitted` (`result`, plus `error_class` from
invalid_email / rate_limited / unavailable / network on error)
- [x] `client error` (`error_kind`, allowlisted `error_name`, sanitized path;
max 5 per page load; `capture_exceptions` stays off)

## Telemetry and badge audit (2026-08-14)

Expand Down
17 changes: 16 additions & 1 deletion frontend/components/EmailSignupForm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
"use client";

import { Bell, Loader2 } from "lucide-react";
import posthog from "posthog-js";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";

// Maps the /api/subscribe status to a fixed error_class. Never the email or the
// response text; the sanitizer drops anything outside this list.
function classifyNewsletterError(status: number) {
if (status === 429) return "rate_limited";
if (status === 400) return "invalid_email";
return "unavailable";
}

export function EmailSignupForm() {
const [email, setEmail] = useState("");
const [honeypot, setHoneypot] = useState(""); // Bot trap
Expand Down Expand Up @@ -52,9 +61,10 @@ export function EmailSignupForm() {
body: JSON.stringify({ email: trimmedEmail }),
});

const data = await response.json();
const data = await response.json().catch(() => ({}));

if (response.ok) {
posthog.capture("newsletter submitted", { result: "success" });
setStatus("success");
toast.success("What's next? Check your inbox to find out 👀", {
icon: "🎯",
Expand All @@ -67,6 +77,10 @@ export function EmailSignupForm() {
setStatus("idle");
}, 10000);
} else {
posthog.capture("newsletter submitted", {
result: "error",
error_class: classifyNewsletterError(response.status),
});
setStatus("error");
toast.error(data.error || "❌ Something went wrong", {
duration: 5000,
Expand All @@ -78,6 +92,7 @@ export function EmailSignupForm() {
}, 5000);
}
} catch {
posthog.capture("newsletter submitted", { result: "error", error_class: "network" });
setStatus("error");
toast.error("🌐 Network error. Please check your connection and try again.", {
duration: 5000,
Expand Down
13 changes: 10 additions & 3 deletions frontend/components/site/TrackedCtaLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,24 @@ import posthog from "posthog-js";
import type { AnchorHTMLAttributes } from "react";

type TrackedCtaLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
placement: "header" | "hero" | "footer";
placement: "header" | "hero" | "footer" | "projects";
/** Must be in ALLOWED_CTA_IDS (lib/posthog-privacy.ts) or the sanitizer drops the event. */
ctaId?: string;
};

export function TrackedCtaLink({ placement, onClick, ...props }: TrackedCtaLinkProps) {
export function TrackedCtaLink({
placement,
ctaId = "github_org",
onClick,
...props
}: TrackedCtaLinkProps) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: this link records an allowlisted outbound CTA.
<a
{...props}
// biome-ignore lint/a11y/useValidAnchor: this remains an anchor for native link behavior.
onClick={(event) => {
posthog.capture("cta activated", { cta_id: "github_org", placement });
posthog.capture("cta activated", { cta_id: ctaId, placement });
onClick?.(event);
}}
/>
Expand Down
7 changes: 5 additions & 2 deletions frontend/components/site/sections/Projects.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ArrowUpRight } from "lucide-react";
import { TrackedCtaLink } from "@/components/site/TrackedCtaLink";
import { PROJECTS } from "@/lib/site-config";

/**
Expand Down Expand Up @@ -26,7 +27,9 @@ export function Projects() {
<ul className="grid gap-5 sm:grid-cols-2">
{PROJECTS.map((project) => (
<li key={project.slug}>
<a
<TrackedCtaLink
ctaId={`project_${project.slug}`}
placement="projects"
href={project.href}
target="_blank"
rel="noopener noreferrer"
Expand All @@ -46,7 +49,7 @@ export function Projects() {
<span className="mt-auto pt-2 font-mono text-xs tracking-wide text-fg-faint uppercase">
{project.status}
</span>
</a>
</TrackedCtaLink>
</li>
))}
</ul>
Expand Down
33 changes: 31 additions & 2 deletions frontend/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import posthog from "posthog-js";
import { getPostHogConfig, sanitizeEvent, sanitizeRoute } from "./lib/posthog-privacy";
import {
classifyClientErrorName,
getPostHogConfig,
sanitizeEvent,
sanitizeRoute,
} from "./lib/posthog-privacy";

const posthogConfig = getPostHogConfig({
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN: process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN,
Expand All @@ -11,6 +16,21 @@ function capturePageview(url: string) {
posthog.capture("$pageview", { path: sanitizeRoute(url) });
}

const MAX_CLIENT_ERRORS_PER_PAGE_LOAD = 5;
let clientErrorCount = 0;

// Only a bounded kind, an allowlisted constructor name and the sanitized route
// leave the page. No message, stack, filename or URL is ever read.
function captureClientError(errorKind: "error" | "unhandled_rejection", cause: unknown) {
if (clientErrorCount >= MAX_CLIENT_ERRORS_PER_PAGE_LOAD) return;
clientErrorCount += 1;
posthog.capture("client error", {
error_kind: errorKind,
error_name: classifyClientErrorName(cause),
path: sanitizeRoute(window.location.pathname),
});
}

if (typeof window !== "undefined" && posthogConfig) {
posthog.init(posthogConfig.token, {
api_host: posthogConfig.apiHost,
Expand Down Expand Up @@ -42,7 +62,11 @@ if (typeof window !== "undefined" && posthogConfig) {
disable_persistence: true,
respect_dnt: true,
save_referrer: true,
save_campaign_params: false,
// Campaign labels only: the sanitizer forwards utm_source/medium/campaign
// through a bounded vocabulary, and masking drops the per-click ids
// (gclid, fbclid, msclkid and the rest of PERSONAL_DATA_CAMPAIGN_PARAMS).
save_campaign_params: true,
mask_personal_data_properties: true,
disable_capture_url_hashes: true,
disable_scroll_properties: true,
mask_all_element_attributes: true,
Expand All @@ -56,6 +80,11 @@ if (typeof window !== "undefined" && posthogConfig) {
before_send: (event) => sanitizeEvent(event) as typeof event | null,
});

window.addEventListener("error", (event) => captureClientError("error", event.error));
window.addEventListener("unhandledrejection", (event) =>
captureClientError("unhandled_rejection", event.reason),
);

capturePageview(window.location.pathname);
}

Expand Down
116 changes: 111 additions & 5 deletions frontend/lib/posthog-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,54 @@ export const POSTHOG_API_HOST = "https://e.codeswhat.com";
export const POSTHOG_UI_HOST = "https://us.posthog.com";
export const PRODUCTION_ORIGIN = "https://codeswhat.com";
export const ALLOWED_ROUTES = ["/"] as const;
export const ALLOWED_CTA_IDS = ["github_org"] as const;
export const ALLOWED_CTA_IDS = [
"github_org",
"project_sockguard",
"project_drydock",
"project_portwing",
"project_portkey-admin-mcp",
] as const;
export const NEWSLETTER_ERROR_CLASSES = [
"invalid_email",
"rate_limited",
"unavailable",
"network",
] as const;
export const CLIENT_ERROR_KINDS = ["error", "unhandled_rejection"] as const;
export const CLIENT_ERROR_NAMES = [
"TypeError",
"ReferenceError",
"SyntaxError",
"RangeError",
"ChunkLoadError",
"Error",
"other",
] as const;
// Incoming UTM values are untrusted, so only this bounded vocabulary (plus a
// strict slug shape for utm_campaign) is forwarded. Everything else is dropped.
export const UTM_SOURCES = [
"github",
"x",
"bluesky",
"linkedin",
"reddit",
"hackernews",
"newsletter",
"producthunt",
] as const;
export const UTM_MEDIUMS = ["social", "email", "referral", "community"] as const;

const OTHER_PATH = "/_other";
const PROJECT_TOKEN_PATTERN = /^phc_[A-Za-z0-9_-]+$/u;
const allowedRoutes = new Set<string>(ALLOWED_ROUTES);
const allowedCtaIds = new Set<string>(ALLOWED_CTA_IDS);
const allowedPlacements = new Set(["header", "hero", "footer"]);
const projectCtaPrefix = "project_";
const allowedNewsletterErrorClasses = new Set<string>(NEWSLETTER_ERROR_CLASSES);
const allowedClientErrorKinds = new Set<string>(CLIENT_ERROR_KINDS);
const allowedClientErrorNames = new Set<string>(CLIENT_ERROR_NAMES);
const allowedUtmSources = new Set<string>(UTM_SOURCES);
const allowedUtmMediums = new Set<string>(UTM_MEDIUMS);
const UTM_CAMPAIGN_PATTERN = /^[a-z0-9-]{1,40}$/u;
const allowedVitalKeys = new Set([
"$web_vitals_CLS_value",
"$web_vitals_FCP_value",
Expand Down Expand Up @@ -51,7 +92,13 @@ type EventInput = {
};

type SanitizedEvent = {
event: "$pageview" | "$pageleave" | "cta activated" | "$web_vitals";
event:
| "$pageview"
| "$pageleave"
| "cta activated"
| "$web_vitals"
| "newsletter submitted"
| "client error";
properties: Record<string, boolean | number | string>;
timestamp?: Date;
uuid?: string;
Expand Down Expand Up @@ -81,6 +128,30 @@ function sanitizeReferringDomain(value: unknown): string | undefined {
return value.toLowerCase();
}

function sanitizeUtmValue(value: unknown, allowed: Set<string>): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.toLowerCase();
return allowed.has(normalized) ? normalized : undefined;
}

function sanitizeUtmCampaign(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.toLowerCase();
return UTM_CAMPAIGN_PATTERN.test(normalized) ? normalized : undefined;
}

export function classifyClientErrorName(value: unknown): string {
const name = value instanceof Error ? value.name : undefined;
return typeof name === "string" && allowedClientErrorNames.has(name) ? name : "other";
}

function isAllowedCtaPlacement(ctaId: string, placement: string): boolean {
if (ctaId === "github_org") {
return placement === "header" || placement === "hero" || placement === "footer";
}
return ctaId.startsWith(projectCtaPrefix) && placement === "projects";
}

function createCommonProperties(properties: Record<string, unknown>) {
const token = properties.token;
// PostHog's cookieless server-hash ingestion step computes the anonymous
Expand Down Expand Up @@ -128,6 +199,12 @@ function createCommonProperties(properties: Record<string, unknown>) {
}
const referringDomain = sanitizeReferringDomain(properties.$referring_domain);
if (referringDomain !== undefined) common.$referring_domain = referringDomain;
const utmSource = sanitizeUtmValue(properties.utm_source, allowedUtmSources);
if (utmSource !== undefined) common.utm_source = utmSource;
const utmMedium = sanitizeUtmValue(properties.utm_medium, allowedUtmMediums);
if (utmMedium !== undefined) common.utm_medium = utmMedium;
const utmCampaign = sanitizeUtmCampaign(properties.utm_campaign);
if (utmCampaign !== undefined) common.utm_campaign = utmCampaign;
return common;
}

Expand Down Expand Up @@ -173,7 +250,7 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null {
if (event === "cta activated") {
const ctaId = typeof values.cta_id === "string" ? values.cta_id : "";
const placement = typeof values.placement === "string" ? values.placement : "";
return allowedCtaIds.has(ctaId) && allowedPlacements.has(placement)
return allowedCtaIds.has(ctaId) && isAllowedCtaPlacement(ctaId, placement)
? createSanitizedEvent(eventInput, event, { ...common, cta_id: ctaId, placement })
: null;
}
Expand All @@ -187,7 +264,36 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null {
}
}
return Object.keys(vitalProperties).length > 0
? createSanitizedEvent(eventInput, event, { ...common, ...vitalProperties })
? createSanitizedEvent(eventInput, event, {
...common,
// Same already-sanitized path the pageview branch uses, so Web
// analytics reports that key off $pathname resolve vitals too.
$pathname: common.path,
...vitalProperties,
})
: null;
}

if (event === "newsletter submitted") {
const result = values.result;
if (result === "success") {
return createSanitizedEvent(eventInput, event, { ...common, result });
}
const errorClass = typeof values.error_class === "string" ? values.error_class : "";
return result === "error" && allowedNewsletterErrorClasses.has(errorClass)
? createSanitizedEvent(eventInput, event, { ...common, result, error_class: errorClass })
: null;
}

if (event === "client error") {
const errorKind = typeof values.error_kind === "string" ? values.error_kind : "";
const errorName = typeof values.error_name === "string" ? values.error_name : "";
return allowedClientErrorKinds.has(errorKind) && allowedClientErrorNames.has(errorName)
? createSanitizedEvent(eventInput, event, {
...common,
error_kind: errorKind,
error_name: errorName,
})
: null;
}

Expand Down
Loading
Loading