mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 00:02:05 +00:00
148 lines
6.7 KiB
TypeScript
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;
|
|
}
|