Files
warmbly/web/src/lib/posthog.ts
T

241 lines
11 KiB
TypeScript

// The dashboard's one PostHog client.
//
// Product analytics, session replay and error tracking are the same project
// and the same key, so they are the same SDK instance: two `posthog.init` calls
// would be two visitors, two pageview streams and two sets of global error
// handlers. `lib/productAnalytics` owns what we name, `lib/observability` owns
// what we report; this file owns the client both of them borrow.
//
// It is hosted-only. The dashboard image is the same for the hosted service and
// for a self-host, so the key comes from the container-injected runtime config
// and an unset key means the SDK chunk is never fetched and no PostHog host is
// ever contacted. A self-host therefore ships this code path and never runs it.
//
// It is identified. A signed-in user is `identify`d by their account id, with
// their email and name as person properties, and their workspace is a group,
// so every event, replay and exception is attributable to the account and the
// workspace it happened in. The SDK keeps its device id in localStorage plus a
// cookie, which is what ties the anonymous visit before sign-in to the person
// after it and what makes sessions exist at all: session replay and session
// analytics have no session to hang off in the cookieless mode this used to
// run in.
//
// Everything the SDK can observe is on: autocapture, pageviews and pageleaves,
// heatmaps, rage and dead clicks, web vitals, network timing, session replay
// with console logs, surveys and exceptions. The only thing masked is what a
// password field holds, which is never ours to see. The privacy page on the
// marketing site describes exactly this and has to change with it.
import type { CaptureResult, PostHog, Properties } from "posthog-js";
import {
POSTHOG_ERROR_TRACKING,
POSTHOG_HOST,
POSTHOG_KEY,
POSTHOG_SESSION_REPLAY,
POSTHOG_UI_HOST,
SENTRY_ENVIRONMENT,
SENTRY_RELEASE,
} from "./information";
export type PostHogIdentity = {
userId: string;
email?: string | null;
name?: string | null;
organizationId?: string | null;
organizationName?: string | null;
plan?: string | null;
};
let client: PostHog | null = null;
let loading: Promise<PostHog | null> | null = null;
// identity is the last one applied, so a client that finishes loading after
// sign-in still learns who is signed in, and so exceptions can carry the ids
// as plain searchable properties as well as through the person.
let identity: PostHogIdentity | null = null;
// loadPostHog resolves the initialised client, or null when no key is
// configured or the chunk could not be fetched. It initialises on the first
// call and every later caller gets the same instance.
export function loadPostHog(): Promise<PostHog | null> {
if (!POSTHOG_KEY) return Promise.resolve(null);
if (loading) return loading;
loading = import("posthog-js")
.then(({ posthog }) => {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
ui_host: POSTHOG_UI_HOST,
// A person profile is created on identify and not before, so
// a visitor who never signs in is a device, not a person.
person_profiles: "identified_only",
autocapture: true,
// The dashboard is a single-page app, so page loads happen
// once and every navigation after that is a history change.
// Plain `true` would report one pageview per session.
capture_pageview: "history_change",
capture_pageleave: true,
capture_dead_clicks: true,
capture_heatmaps: true,
rageclick: true,
capture_performance: { web_vitals: true, network_timing: true },
disable_session_recording: !POSTHOG_SESSION_REPLAY,
session_recording: {
// Password inputs cover the mailbox app password and the
// API secret. They do not cover the one-time codes, which
// are plain inputs so the browser will autofill them from
// SMS and mail, nor a secret the page has already revealed
// as text. Those carry data-ph-mask / .ph-mask and are
// named here.
maskAllInputs: false,
maskInputOptions: { password: true },
maskTextSelector: "[data-ph-mask], .ph-mask, [data-otp-input], input[autocomplete='one-time-code']",
},
// Console output is replayed alongside the session, so anything
// printed is retained. Off: it is not worth one careless log of
// a token, and exceptions are captured separately below.
enable_recording_console_log: false,
respect_dnt: false,
capture_exceptions: POSTHOG_ERROR_TRACKING
? {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
}
: false,
before_send: decorate,
});
// Registered rather than passed per call so an autocaptured
// event carries them too, and so dashboard events are separable
// from the admin panel's in a shared project.
posthog.register(SENTRY_RELEASE
? { service: "dashboard", environment: SENTRY_ENVIRONMENT, release: SENTRY_RELEASE }
: { service: "dashboard", environment: SENTRY_ENVIRONMENT });
client = posthog;
if (identity) applyIdentity(posthog, identity);
return posthog;
})
.catch(() => {
// Blocked or failed: reporting is never a reason the dashboard breaks.
return null;
});
return loading;
}
// postHogClient is the loaded client, or null while it is still in flight. Use
// it where dropping the call is better than waiting for one.
export function postHogClient(): PostHog | null {
return client;
}
// setPostHogIdentity names the signed-in user and their workspace. Null on
// sign-out resets the SDK, so a shared machine's next session gets a fresh
// device id and is not attributed to whoever used it last.
export function setPostHogIdentity(next: PostHogIdentity | null): void {
identity = next;
if (!client) return;
if (next) {
applyIdentity(client, next);
} else {
client.reset();
}
}
function applyIdentity(posthog: PostHog, next: PostHogIdentity): void {
const person: Properties = {};
if (next.email) person.email = next.email;
if (next.name) person.name = next.name;
posthog.identify(next.userId, person);
if (next.organizationId) {
const group: Properties = {};
if (next.organizationName) group.name = next.organizationName;
if (next.plan) group.plan = next.plan;
posthog.group("organization", next.organizationId, group);
}
}
// notePostHogStep records one step on the trail attached to the next exception.
// PostHog buffers them itself, oldest evicted past its byte budget.
//
// Steps recorded before the SDK loaded are held by lib/observability, which
// replays them the moment this client settles, so nothing buffers twice.
export function notePostHogStep(message: string, properties?: Properties): void {
client?.addExceptionStep(message, properties);
}
// Browser noise: reported by the window error handler, carrying no stack we
// can act on and no bug behind it. "Script error." is what a cross-origin
// script is flattened to, and the ResizeObserver notice is a benign scheduling
// message the spec requires browsers to fire. Both drown the real issues.
const NOISE = [
"Script error.",
"ResizeObserver loop completed with undelivered notifications.",
"ResizeObserver loop limit exceeded",
];
// A session ending is a lifecycle event, not a crash: normalizeError turns an
// AuthError into a redirect and UserProvider sends the user to sign in. It
// reached error tracking only by also escaping to the global rejection handler.
const NOISE_TYPES = ["AuthError"];
// An exception whose message is an object's default toString, with no stack and
// no real Error behind it, carries nothing: no name, no cause, no place. They
// arrive from extensions and from handlers that concatenate a DOM Event into a
// string, and they all fingerprint together.
//
// All three conditions are required. `synthetic` marks a value PostHog wrapped
// because it was thrown as something other than an Error, so anything we
// construct ourselves is excluded even when its message also stringified an
// object; and a single frame would make it findable, so a stack of any depth is
// kept.
function isUninformative(entry: Record<string, unknown>): boolean {
const value = entry.value ?? entry.$exception_value;
if (typeof value !== "string" || !DEFAULT_OBJECT_STRING.test(value)) return false;
const mechanism = entry.mechanism as { synthetic?: unknown } | undefined;
if (mechanism?.synthetic !== true) return false;
const trace = entry.stacktrace as { frames?: unknown[] } | undefined;
return !Array.isArray(trace?.frames) || trace.frames.length === 0;
}
const DEFAULT_OBJECT_STRING = /\[object [A-Z][A-Za-z]*\]/;
function isNoise(properties: Properties): boolean {
const exceptionList = 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 type = entry.type ?? entry.$exception_type;
const value = entry.value ?? entry.$exception_value;
return (typeof type === "string" && NOISE_TYPES.includes(type))
|| (typeof value === "string" && NOISE.includes(value.trim()))
|| isUninformative(entry);
})) {
return true;
}
const type = properties.$exception_type;
if (typeof type === "string" && NOISE_TYPES.includes(type)) return true;
const message = properties.$exception_message;
if (typeof message === "string" && NOISE.includes(message.trim())) return true;
// Keep accepting flattened payloads while cached SDK chunks are still in
// browsers during a rolling release.
const types = properties.$exception_types;
if (Array.isArray(types) && types.some((t) => typeof t === "string" && NOISE_TYPES.includes(t))) return true;
const values = properties.$exception_values;
if (!Array.isArray(values)) return false;
return values.some((v) => typeof v === "string" && NOISE.includes(v.trim()));
}
// decorate is the last thing to touch an event before it is sent. Exceptions
// get the account and workspace ids as flat properties on top of the person,
// so "every error this workspace hit" is a property filter.
function decorate(event: CaptureResult | null): CaptureResult | null {
if (!event?.properties || event.event !== "$exception") return event;
if (isNoise(event.properties)) return null;
if (!identity) return event;
event.properties.user_id = identity.userId;
if (identity.organizationId) event.properties.organization_id = identity.organizationId;
return event;
}