feat(telemetry): Sentry + PostHog quality pass (#546)

Comprehensive telemetry quality improvements across Sentry and PostHog, grounded in an audit of the live data plus current best-practice research.

Sentry: job_id/instance_id tags, operational fingerprinting, PII-safe settings context on bug events, web tag population + extension-noise filtering, an early-crash buffer, http status/method kept on breadcrumbs, and a gated-off-by-default performance-tracing re-enable (tracesSampler that zeroes db/redis/queue-poll root spans + drops the Redis integration) with worker job spans and canonical-host cron monitors.

PostHog: history_change SPA pageviews, instance_id super property for fleet rollups, enriched tool_used (formats, byte sizes, is_batch, execution_hint, real error_kind taxonomy), the previously-dead result_saved/batch_processed/ai_bundle_prompted events fired, search click-through, editor + Automate authoring + auth instrumentation, a before_send PII boundary, and minimal opt-in landing-site pageviews.
This commit is contained in:
SnapOtter
2026-07-17 01:51:48 +00:00
committed by GitHub
parent 9247947704
commit 86251434b5
36 changed files with 936 additions and 54 deletions
@@ -75,7 +75,7 @@ export function ReviewPanel({
const handleDownload = () => {
import("@/lib/analytics").then(({ track }) => {
track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, {});
track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, { tool_id: currentToolId });
});
triggerDownload(downloadUrl, filename);
};
@@ -99,6 +99,12 @@ export function ReviewPanel({
});
if (!uploadRes.ok) throw new Error("Upload failed");
setSaveStatus("saved");
// "Save to library" is the real success signal for a self-hosted tool
// (there is no purchase). result_saved was defined + allowlisted but never
// fired, so save-rate was unmeasurable.
import("@/lib/analytics").then(({ track }) => {
track(ANALYTICS_EVENTS.RESULT_SAVED, { tool_id: currentToolId });
});
} catch {
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
+11 -1
View File
@@ -20,6 +20,8 @@ interface ToolCardProps {
variant?: "compact" | "descriptive";
showModalityBadge?: boolean;
showPin?: boolean;
/** Fired when the card is clicked (before navigation), for search attribution. */
onNavigate?: () => void;
}
function PinButton({ toolId }: { toolId: string }) {
@@ -57,7 +59,13 @@ const SECTION_COLOR_MAP: Record<string, string> = Object.fromEntries(
SECTIONS.map((s) => [s.id, s.color]),
);
export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin }: ToolCardProps) {
export function ToolCard({
tool,
variant = "compact",
showModalityBadge,
showPin,
onNavigate,
}: ToolCardProps) {
const { t } = useTranslation();
const IconComponent =
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
@@ -110,6 +118,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin
const card = (
<Link
to={tool.route}
onClick={onNavigate}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border border-border/60 bg-card transition-all",
"hover:border-border hover:shadow-sm",
@@ -156,6 +165,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin
return (
<Link
to={tool.route}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 p-2.5 px-3 rounded-lg transition-colors",
"hover:bg-muted",
@@ -1,6 +1,6 @@
// apps/web/src/components/editor/common/export-dialog.tsx
import { apiToolPath } from "@snapotter/shared";
import { ANALYTICS_EVENTS, apiToolPath } from "@snapotter/shared";
import {
Check,
ClipboardCopy,
@@ -158,6 +158,9 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
// Issue #6: Export using Konva stage.toDataURL for correct output
const handleExport = useCallback(() => {
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.EDITOR_EXPORTED, { output_format: settings.format }),
);
const stage = editorStageRefHolder.current;
if (!stage) return;
@@ -1,4 +1,5 @@
import {
ANALYTICS_EVENTS,
FEATURE_BUNDLES,
type FeatureBundleState,
getRequiredBundlesForTool,
@@ -145,6 +146,16 @@ export function FeatureInstallPrompt({
return () => clearInterval(interval);
}, [isInstalling]);
useEffect(() => {
if (!isAdmin || bundle.status === "installed") return;
// Install-prompt impression: the top of the AI-adoption funnel
// (prompted -> ai_bundle_action install -> tool_used is_ai_tool). Non-admins
// see a different "not enabled" message, not this prompt.
import("@/lib/analytics").then(({ track }) => {
track(ANALYTICS_EVENTS.AI_BUNDLE_PROMPTED, { bundle_id: bundle.id });
});
}, [bundle.id, bundle.status, isAdmin]);
const eta = (() => {
if (!progress || !startTime || progress.percent <= 2) return null;
const elapsed = now - startTime;
+3
View File
@@ -116,6 +116,9 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
let cancelled = false;
// Tag Sentry with the active locale so locale-specific i18n/interpolation
// crashes are identifiable as such.
import("@/lib/analytics").then(({ setSentryTag }) => setSentryTag("locale", locale));
loadLocale(locale).then((t) => {
if (!cancelled) setTranslations(t);
});
+14
View File
@@ -566,6 +566,17 @@ export function useToolProcessor(toolId: string) {
return;
}
// batch_processed fires once for the batch as a unit (distinct from the N
// per-file tool_used events), so batch usage is separable from single runs.
const trackBatch = (status: "completed" | "failed") =>
void import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.BATCH_PROCESSED, {
tool_id: toolId,
file_count: files.length,
status,
}),
);
const { updateEntry, setBatchZip } = useFileStore.getState();
setError(null);
@@ -648,6 +659,7 @@ export function useToolProcessor(toolId: string) {
setError(errorMsg);
setProcessing(false);
setProgress(IDLE_PROGRESS);
trackBatch("failed");
return;
}
@@ -693,6 +705,7 @@ export function useToolProcessor(toolId: string) {
setProcessing(false);
setProgress(IDLE_PROGRESS);
clearActiveJob();
trackBatch("completed");
} catch (err) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (eventSourceRef.current) {
@@ -703,6 +716,7 @@ export function useToolProcessor(toolId: string) {
setProcessing(false);
setProgress(IDLE_PROGRESS);
clearActiveJob();
trackBatch("failed");
}
},
[toolId, processFiles, setProcessing, setError, clearActiveJob, toolName],
+54 -3
View File
@@ -1,4 +1,5 @@
import type { AnalyticsConfig } from "@snapotter/shared";
import { flushEarlyErrors } from "./early-errors";
type PostHogInstance = import("posthog-js").PostHog;
@@ -17,6 +18,13 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
search: new Set(["results_count", "clicked_tool_id"]),
ai_bundle_prompted: new Set(["bundle_id"]),
batch_processed: new Set(["tool_id", "file_count", "status"]),
editor_opened: new Set<string>([]),
editor_tool_used: new Set(["editor_tool"]),
editor_exported: new Set(["output_format"]),
pipeline_opened: new Set<string>([]),
pipeline_step_added: new Set(["tool_id"]),
pipeline_saved: new Set(["step_count"]),
pipeline_template_selected: new Set(["template_id"]),
};
function sanitize(event: string, properties?: Record<string, unknown>): Record<string, unknown> {
@@ -46,11 +54,29 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
posthogJs.init(config.posthogApiKey, {
api_host: config.posthogHost,
autocapture: false,
capture_pageview: true,
// Fire $pageview on SPA history changes, not just the initial hard
// load, so react-router route changes (tool pages, editor, automate,
// files) are captured. capture_pageleave gives accurate time-on-page.
capture_pageview: "history_change",
capture_pageleave: true,
disable_session_recording: true,
ip: false,
persistence: "localStorage",
person_profiles: "identified_only",
// Last-line PII boundary at the SDK, independent of track()'s per-call
// sanitize(): strip any query string / fragment from URL properties.
// SnapOtter routes carry no PII, but pageview, survey, and other
// SDK-generated events never pass through track()'s allowlist, so the
// invariant is enforced here too.
before_send: (event) => {
const props = event?.properties;
if (props) {
const strip = (u: unknown) => (typeof u === "string" ? u.replace(/[?#].*$/, "") : u);
props.$current_url = strip(props.$current_url);
props.$referrer = strip(props.$referrer);
}
return event;
},
}) ?? null;
initialized = true;
enabled = true;
@@ -68,8 +94,15 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
} catch {
// ignore
}
// app_version only; no instance_id, so plain events stay person-less.
posthog.register({ app_version: (await import("@snapotter/shared")).APP_VERSION });
// Super properties on every event. instance_id is an event PROPERTY (not an
// identify() call), so events stay anonymous and person-less while enabling
// fleet rollups ("how many distinct instances use tool X") via a HogQL
// uniq(). Omitted when empty so we never register a blank value.
const superProps: Record<string, string> = {
app_version: (await import("@snapotter/shared")).APP_VERSION,
};
if (config.instanceId) superProps.instance_id = config.instanceId;
posthog.register(superProps);
}
try {
@@ -100,6 +133,24 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
} catch (err) {
console.warn("[analytics] Sentry init failed:", err);
}
// Replay crashes captured before Sentry was ready (no-op if Sentry did not
// init, e.g. analytics disabled, so opt-out is respected).
if (enabled) void flushEarlyErrors();
}
/**
* Set an allowlisted Sentry tag (route / tool_id / locale / error_class, see
* sentry-scrub.ts TAG_ALLOWLIST) so web errors become filterable by which tool
* and route the user was on. No-op until Sentry is initialized; lazy so this
* module keeps no static @sentry/react import.
*/
export function setSentryTag(key: string, value: string): void {
void import("@sentry/react")
.then((Sentry) => {
if (Sentry.getClient()) Sentry.setTag(key, value);
})
.catch(() => {});
}
export function track(event: string, properties?: Record<string, unknown>): void {
+55
View File
@@ -0,0 +1,55 @@
/**
* Early-error buffer. Sentry initializes late in the web app (inside App, after
* the analytics config round-trip), so any crash during initial bundle eval or
* first render, often the most important ones, happens with no Sentry client
* installed and is lost. This captures those in a small buffer from the very
* first line of the entry, then replays them once Sentry is initialized.
*
* The buffer respects opt-out for free: flushEarlyErrors only sends if a Sentry
* client exists, and Sentry is initialized only when analytics is enabled.
*/
const MAX_BUFFERED = 10;
const buffer: unknown[] = [];
let capturing = false;
function onError(e: ErrorEvent): void {
if (e.error !== undefined && e.error !== null) push(e.error);
}
function onRejection(e: PromiseRejectionEvent): void {
push(e.reason);
}
function push(err: unknown): void {
if (buffer.length < MAX_BUFFERED) buffer.push(err);
}
/** Install global handlers before Sentry exists. Idempotent. */
export function startEarlyErrorCapture(): void {
if (capturing || typeof window === "undefined") return;
capturing = true;
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
}
/** Stop buffering and replay to Sentry. Called once Sentry is initialized. */
export async function flushEarlyErrors(): Promise<void> {
if (typeof window !== "undefined" && capturing) {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
}
capturing = false;
if (buffer.length === 0) return;
const pending = buffer.splice(0);
try {
const Sentry = await import("@sentry/react");
if (!Sentry.getClient()) return;
for (const err of pending) Sentry.captureException(err);
} catch {
// never throw from telemetry
}
}
/** Test-only reset. */
export function resetEarlyErrorsForTests(): void {
buffer.length = 0;
capturing = false;
}
+17
View File
@@ -14,6 +14,14 @@ export const IGNORE_ERRORS: (string | RegExp)[] = [
"Load failed",
/^ResizeObserver loop/,
"The operation was aborted.",
// Third-party browser-extension and injected-webview noise. These throw from
// the page context, so DENY_URLS on the extension origin never sees them;
// match the telltale message instead. Seen as WEB-2 (password-manager
// autofill) and WEB-7 (Android WebView bridge). Not our code.
/sendExtensionMessage/i,
/getUrlAutofillTargetingRules/i,
/onLongParse/i,
/Java exception was raised during method invocation/i,
];
export const DENY_URLS: RegExp[] = [
@@ -81,6 +89,15 @@ function scrubBreadcrumb(entry: unknown): AnyEvent | null {
if (b[k] !== undefined) out[k] = b[k];
}
if (typeof b.message === "string") out.message = scrubText(b.message);
// For network breadcrumbs keep the non-PII status_code + method (the url is
// dropped with the rest of `data`): "what request failed before the crash".
if (b.category === "fetch" || b.category === "xhr") {
const data = asObj(b.data);
const safe: AnyEvent = {};
if (data?.status_code !== undefined) safe.status_code = data.status_code;
if (typeof data?.method === "string") safe.method = data.method;
if (Object.keys(safe).length) out.data = safe;
}
return out;
}
+5
View File
@@ -1,8 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { startEarlyErrorCapture } from "./lib/early-errors";
import "./styles/globals.css";
// Buffer crashes that happen before Sentry initializes (it inits late, after
// the analytics config fetch); flushEarlyErrors replays them once it is ready.
startEarlyErrorCapture();
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
+14 -1
View File
@@ -1,4 +1,4 @@
import { modalityForExtension, type PipelineTemplate } from "@snapotter/shared";
import { ANALYTICS_EVENTS, modalityForExtension, type PipelineTemplate } from "@snapotter/shared";
import {
CheckCircle2,
ChevronDown,
@@ -69,6 +69,10 @@ function previewIcon(kind: string) {
export function AutomatePage() {
const { t } = useTranslation();
usePageTitle(t.sidebar.automate);
useEffect(() => {
import("@/lib/analytics").then(({ track }) => track(ANALYTICS_EVENTS.PIPELINE_OPENED, {}));
}, []);
const {
files,
entries,
@@ -232,6 +236,9 @@ export function AutomatePage() {
}),
});
if (res.ok) {
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.PIPELINE_SAVED, { step_count: steps.length }),
);
const listRes = await fetch("/api/v1/pipeline/list", {
headers: formatHeaders(),
});
@@ -281,6 +288,9 @@ export function AutomatePage() {
const handleUseTemplate = useCallback(
(template: PipelineTemplate) => {
loadSteps(template.steps);
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.PIPELINE_TEMPLATE_SELECTED, { template_id: template.id }),
);
},
[loadSteps],
);
@@ -412,6 +422,9 @@ export function AutomatePage() {
const handleAddStep = useCallback(
(toolId: string) => {
addStep(toolId);
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.PIPELINE_STEP_ADDED, { tool_id: toolId }),
);
if (isMobile) setMobileToolPaletteOpen(false);
},
[addStep, isMobile],
+5 -1
View File
@@ -1,5 +1,5 @@
// apps/web/src/pages/editor-page.tsx
import { apiToolPath } from "@snapotter/shared";
import { ANALYTICS_EVENTS, apiToolPath } from "@snapotter/shared";
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog";
@@ -52,6 +52,10 @@ export function EditorPage() {
onFillDialog: () => setFillDialogOpen(true),
});
useEffect(() => {
import("@/lib/analytics").then(({ track }) => track(ANALYTICS_EVENTS.EDITOR_OPENED, {}));
}, []);
// Listen for fill-dialog custom event (dispatched from Shift+Backspace shortcut)
useEffect(() => {
const handler = () => setFillDialogOpen(true);
+16 -1
View File
@@ -403,7 +403,22 @@ function SearchResults({
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{results.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge showPin />
<ToolCard
key={tool.id}
tool={tool}
variant="descriptive"
showModalityBadge
showPin
onNavigate={() => {
// search -> click attribution: which result the user opened.
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.SEARCH, {
results_count: results.length,
clicked_tool_id: tool.id,
}),
);
}}
/>
))}
</div>
<div className="pt-1 text-center">
+5 -1
View File
@@ -268,7 +268,11 @@ export function ToolPage() {
useEffect(() => {
if (tool) {
recordRecentTool(tool.id);
import("@/lib/analytics").then(({ track }) => {
import("@/lib/analytics").then(({ track, setSentryTag }) => {
// Tag Sentry so a frontend crash is filterable by which tool/section the
// user was on (the web TAG_ALLOWLIST reserves these but nothing set them).
setSentryTag("tool_id", tool.id);
setSentryTag("route", toolSection(tool));
track(ANALYTICS_EVENTS.TOOL_OPENED, {
tool_id: tool.id,
modality: tool.modality,
+8
View File
@@ -1,5 +1,6 @@
// apps/web/src/stores/editor-store.ts
import { ANALYTICS_EVENTS } from "@snapotter/shared";
import { temporal } from "zundo";
import { create } from "zustand";
import { generateId } from "@/lib/utils";
@@ -253,6 +254,13 @@ export const useEditorStore = create<EditorState & EditorStateExtensions>()(
setTool: (tool) => {
const { activeTool, canvasSize, cropState } = get();
if (tool !== activeTool) {
// Which editor tools users actually engage (brush/shape/adjust/...);
// the single chokepoint, so UI clicks and keyboard shortcuts both count.
import("@/lib/analytics").then(({ track }) =>
track(ANALYTICS_EVENTS.EDITOR_TOOL_USED, { editor_tool: tool }),
);
}
const leavingCrop = activeTool === "crop" && tool !== "crop";
const enteringCrop = tool === "crop" && activeTool !== "crop";
set({