Files
warmbly/forms/src/observability.ts
T

148 lines
6.7 KiB
TypeScript

// Browser analytics and error reporting for the hosted form page.
//
// PostHog is the default backend and Sentry is still supported; the Go shell
// stamps whichever the operator configured, and with neither, which is every
// self-host and every install that has not configured one, nothing is fetched
// and nothing is sent.
//
// Public pages have to stay light, so unlike the dashboard each SDK loads as
// its own chunk. The cost is that an error thrown in the first few
// milliseconds is missed, which is the right trade on a page whose whole job is
// to render one form for a stranger.
//
// The visitor is a stranger on a customer's form, so PostHog runs cookieless
// here: nothing is stored in their browser and no person is ever created. That
// rules out session replay, which needs a session to exist, and it is the one
// surface where that is the right call: the screen would be somebody typing
// their answers into a customer's form. Pageviews, autocapture, heatmaps, web
// vitals, exceptions and the named funnel events below all work without it.
import type { CaptureResult, PostHog } from "posthog-js";
let client: PostHog | null = null;
// Funnel events fired before the SDK chunk resolves are held here and flushed
// once it does. `form_viewed` fires on mount, which is almost always earlier
// than a dynamic import returns, so without this the first step of every
// funnel would be dropped. Bounded, and emptied if the SDK never loads.
const PENDING_LIMIT = 20;
let pending: Array<{ event: Event; form: string }> | null = null;
function meta(name: string): string {
return document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)?.content?.trim() ?? "";
}
export function initErrorReporting(): void {
const release = meta("wf-release") || undefined;
const environment = meta("wf-environment") || undefined;
const posthogKey = meta("wf-posthog-key");
if (posthogKey) {
const errors = meta("wf-posthog-errors") !== "false";
pending = [];
void import("posthog-js").then(({ posthog }) => {
posthog.init(posthogKey, {
api_host: meta("wf-posthog-host") || "https://us.i.posthog.com",
cookieless_mode: "always",
person_profiles: "never",
autocapture: true,
capture_pageview: true,
capture_pageleave: true,
capture_dead_clicks: true,
capture_heatmaps: true,
rageclick: true,
capture_performance: { web_vitals: true, network_timing: true },
disable_session_recording: true,
respect_dnt: false,
capture_exceptions: errors
? {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
}
: false,
before_send: dropBrowserNoise,
});
// Named so form-page events are separable from the dashboard's in a
// shared project, the same way the Go services set a service
// property.
posthog.register(release
? { service: "forms", environment, release }
: { service: "forms", environment });
client = posthog;
for (const { event, form } of pending ?? []) posthog.capture(event, { form });
pending = null;
}).catch(() => {
// A blocked or failed SDK load must never stop the form rendering.
pending = null;
});
}
const dsn = meta("wf-sentry-dsn");
if (dsn) {
void import("@sentry/browser").then((Sentry) => {
Sentry.init({
dsn,
release,
environment,
// Named so form-page errors are separable from the dashboard's
// in a shared project, the same way the Go services set
// ServerName.
initialScope: { tags: { service: "forms" } },
// A form page carries a stranger's answers. Default PII (their
// IP, their headers) is not ours to collect, and the
// dashboard's reasons for sending it do not apply here.
sendDefaultPii: false,
});
}).catch(() => {
// A blocked or failed SDK load must never stop the form rendering.
});
}
}
// Event is the closed set of named form-page events, the funnel a customer's
// form is measured by. The first-party beacons in events.ts feed the
// customer's own numbers; these feed ours.
export type Event = "form_viewed" | "form_started" | "form_submitted";
// track reports one named event: sent when the SDK is loaded, held while it is
// still in flight, and dropped forever when no key was stamped. The form's
// public id is the one property: it names the form, never the person filling
// it in.
export function track(event: Event, form: string): void {
if (client) {
client.capture(event, { form });
return;
}
if (pending && pending.length < PENDING_LIMIT) pending.push({ event, form });
}
// Browser noise: reported by the window error handler with no stack and no bug
// behind it. A form page is embedded in a customer's own site, so a script of
// theirs failing arrives here as the opaque "Script error."; the ResizeObserver
// notice is a benign scheduling message the spec requires browsers to fire.
// The dashboard, the admin panel and the marketing site drop the same three.
const NOISE = [
"Script error.",
"ResizeObserver loop completed with undelivered notifications.",
"ResizeObserver loop limit exceeded",
];
function dropBrowserNoise(event: CaptureResult | null): CaptureResult | null {
if (!event?.properties || event.event !== "$exception") return event;
const exceptionList = event.properties.$exception_list;
if (Array.isArray(exceptionList) && exceptionList.some((exception) => {
if (!exception || typeof exception !== "object") return false;
const entry = exception as Record<string, unknown>;
const value = entry.value ?? entry.$exception_value;
return typeof value === "string" && NOISE.includes(value.trim());
})) return null;
const message = event.properties.$exception_message;
if (typeof message === "string" && NOISE.includes(message.trim())) return null;
// Keep accepting flattened payloads while cached SDK chunks are still in
// browsers during a rolling release.
const values = event.properties.$exception_values;
if (!Array.isArray(values)) return event;
if (values.some((v) => typeof v === "string" && NOISE.includes(v.trim()))) return null;
return event;
}