diff --git a/frontend/src/__tests__/use-analytics.test.ts b/frontend/src/__tests__/use-analytics.test.ts new file mode 100644 index 0000000..6b99052 --- /dev/null +++ b/frontend/src/__tests__/use-analytics.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { useAnalytics } from "../lib/analytics"; + +const capture = vi.fn(); +const captureException = vi.fn(); + +vi.mock("posthog-js/react", () => ({ + usePostHog: () => ({ capture, captureException }), +})); + +// useAnalytics only relies on useMemo to memoize the facade. Invoke the factory +// directly so the hook can run outside a React render (the suite runs in node). +vi.mock("react", async (importActual) => { + const actual = await importActual(); + return { ...actual, useMemo: (factory: () => unknown) => factory() }; +}); + +describe("useAnalytics", () => { + it("forwards an event with its payload to posthog", () => { + capture.mockClear(); + useAnalytics().fileOpened({ file_id: "abc", method: "new_tab" }); + expect(capture).toHaveBeenCalledWith("file_opened", { + file_id: "abc", + method: "new_tab", + }); + }); + + it("forwards a payloadless event with no properties", () => { + capture.mockClear(); + useAnalytics().removeBgPageViewed(); + expect(capture).toHaveBeenCalledWith("remove_bg_page_viewed", undefined); + }); + + it("reports errors via captureException and logs the context", () => { + captureException.mockClear(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const error = new Error("boom"); + useAnalytics().captureError(error, "while doing X"); + expect(captureException).toHaveBeenCalledWith(error); + expect(consoleError).toHaveBeenCalledWith("while doing X", error); + consoleError.mockRestore(); + }); +}); diff --git a/frontend/src/components/editor-ai-panel.tsx b/frontend/src/components/editor-ai-panel.tsx index 82eab3a..ec81eb8 100644 --- a/frontend/src/components/editor-ai-panel.tsx +++ b/frontend/src/components/editor-ai-panel.tsx @@ -14,7 +14,6 @@ import { useTambo, useTamboThreadInput, } from '@tambo-ai/react' -import { usePostHog } from 'posthog-js/react' import { type ChangeEvent, type FormEvent, @@ -26,6 +25,7 @@ import { } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import { useAnalytics } from '../lib/analytics' import type { AiDesignController } from '../lib/avnac-ai-controller' import { buildAvnacTamboTools } from '../lib/avnac-ai-tambo-tools' import { pickMagicQuickPrompts } from '../lib/avnac-magic-quick-prompts' @@ -287,7 +287,7 @@ function MagicChat({ quickPrompts }: { quickPrompts: string[] }) { const [error, setError] = useState(null) const scrollerRef = useRef(null) const imageInputRef = useRef(null) - const posthog = usePostHog() + const analytics = useAnalytics() const displayMessages = useMemo(() => groupMessagesForDisplay(messages), [messages]) @@ -302,14 +302,14 @@ function MagicChat({ quickPrompts }: { quickPrompts: string[] }) { const hasText = value.trim().length > 0 if ((!hasText && stagedImages.length === 0) || isPending || isStreaming) return setError(null) - posthog.capture('ai_prompt_submitted', { + analytics.aiPromptSubmitted({ prompt_length: value.trim().length, image_count: stagedImages.length, }) try { await submit() } catch (err) { - posthog.captureException(err) + analytics.captureError(err) setError(err instanceof Error ? err.message : 'Something went wrong.') } } diff --git a/frontend/src/components/editor-export-menu.tsx b/frontend/src/components/editor-export-menu.tsx index 07f7d1f..209a459 100644 --- a/frontend/src/components/editor-export-menu.tsx +++ b/frontend/src/components/editor-export-menu.tsx @@ -1,8 +1,8 @@ import { ArrowDown01Icon, FileExportIcon, Tick02Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' -import { usePostHog } from 'posthog-js/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useViewportAwarePopoverPlacement } from '../hooks/use-viewport-aware-popover' +import { useAnalytics } from '../lib/analytics' import EditorRangeSlider from './editor-range-slider' import { floatingToolbarPopoverMenuClass } from './floating-toolbar-shell' import { Button } from './ui' @@ -180,7 +180,7 @@ export default function EditorExportMenu({ disabled, getPages, onExport }: Props const [pages, setPages] = useState([]) const [selectedPageIds, setSelectedPageIds] = useState([]) const [pagesLoading, setPagesLoading] = useState(false) - const posthog = usePostHog() + const analytics = useAnalytics() const rootRef = useRef(null) const panelRef = useRef(null) const pickPanel = useCallback(() => panelRef.current, []) @@ -631,7 +631,7 @@ export default function EditorExportMenu({ disabled, getPages, onExport }: Props pageIds: hasMultiplePages ? selectedPageIds : undefined, transparent: transparentAllowed ? opts.transparent : false, } - posthog.capture('image_exported', { + analytics.imageExported({ format: finalOpts.format, scale: finalOpts.multiplier, transparent: finalOpts.transparent, diff --git a/frontend/src/components/file-grid-card.tsx b/frontend/src/components/file-grid-card.tsx index 283a640..b0cb6c6 100644 --- a/frontend/src/components/file-grid-card.tsx +++ b/frontend/src/components/file-grid-card.tsx @@ -7,8 +7,8 @@ import { Tick02Icon, } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' -import { usePostHog } from 'posthog-js/react' import { useEffect, useRef, useState } from 'react' +import { useAnalytics } from '../lib/analytics' import { type AvnacEditorIdbListItem, idbDuplicateDocument } from '../lib/avnac-editor-idb' import { downloadAvnacJsonForId } from '../lib/avnac-files-export' import FileGridPreview from './file-grid-preview' @@ -34,7 +34,7 @@ export default function FileGridCard({ }: FileGridCardProps) { const [menuOpen, setMenuOpen] = useState(false) const wrapRef = useRef(null) - const posthog = usePostHog() + const analytics = useAnalytics() useEffect(() => { if (!menuOpen) return @@ -59,7 +59,7 @@ export default function FileGridCard({ onRequestOpen(row, 'menu') return } - posthog.capture('file_opened', { file_id: row.id, method: 'new_tab' }) + analytics.fileOpened({ file_id: row.id, method: 'new_tab' }) const u = new URL('/create', window.location.origin) u.searchParams.set('id', row.id) window.open(u.toString(), '_blank', 'noopener,noreferrer') @@ -71,15 +71,14 @@ export default function FileGridCard({ try { const newId = await idbDuplicateDocument(row.id) if (newId) { - posthog.capture('file_duplicated', { + analytics.fileDuplicated({ file_id: row.id, new_file_id: newId, }) onListChange() } } catch (err) { - posthog.captureException(err) - console.error('[avnac] duplicate failed', err) + analytics.captureError(err, '[avnac] duplicate failed') } })() } @@ -89,10 +88,9 @@ export default function FileGridCard({ void (async () => { try { await downloadAvnacJsonForId(row.id) - posthog.capture('file_downloaded', { file_id: row.id, format: 'json' }) + analytics.fileDownloaded({ file_id: row.id, format: 'json' }) } catch (err) { - posthog.captureException(err) - console.error('[avnac] download failed', err) + analytics.captureError(err, '[avnac] download failed') } })() } diff --git a/frontend/src/components/new-canvas-dialog.tsx b/frontend/src/components/new-canvas-dialog.tsx index ff403f4..c8717bd 100644 --- a/frontend/src/components/new-canvas-dialog.tsx +++ b/frontend/src/components/new-canvas-dialog.tsx @@ -1,10 +1,10 @@ import { StarIcon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { useNavigate } from '@tanstack/react-router' -import { usePostHog } from 'posthog-js/react' import { useEffect, useId, useRef, useState } from 'react' import { ARTBOARD_PRESETS, type ArtboardPresetCategory } from '../data/artboard-presets' import { useEditorUnsupportedOnThisDevice } from '../hooks/use-editor-device-support' +import { useAnalytics } from '../lib/analytics' const CANVAS_MIN = 100 const CANVAS_MAX = 16000 @@ -96,7 +96,7 @@ type NewCanvasDialogProps = { export default function NewCanvasDialog({ open, onClose }: NewCanvasDialogProps) { const navigate = useNavigate() - const posthog = usePostHog() + const analytics = useAnalytics() const editorUnsupported = useEditorUnsupportedOnThisDevice() const titleId = useId() const panelRef = useRef(null) @@ -130,7 +130,7 @@ export default function NewCanvasDialog({ open, onClose }: NewCanvasDialogProps) const goCreate = (w: number, h: number, presetLabel?: string) => { const W = Math.min(CANVAS_MAX, Math.max(CANVAS_MIN, Math.round(w))) const H = Math.min(CANVAS_MAX, Math.max(CANVAS_MIN, Math.round(h))) - posthog.capture('canvas_created', { + analytics.canvasCreated({ width: W, height: H, creation_mode: presetLabel ? 'preset' : 'custom', diff --git a/frontend/src/lib/analytics/event-payloads.ts b/frontend/src/lib/analytics/event-payloads.ts new file mode 100644 index 0000000..3633035 --- /dev/null +++ b/frontend/src/lib/analytics/event-payloads.ts @@ -0,0 +1,150 @@ +/** + * Payload type per analytics event. Re-exported from `events.ts` as the + * `Payloads` namespace and mapped to event names there. + */ + +type LegacyConversionSurface = "create_page" | "files_page"; + +/** Shared shape for the legacy-file conversion lifecycle events. */ +interface LegacyConversionPayload { + surface: LegacyConversionSurface; + trigger_source: string; + file_count: number; + file_ids: string[]; + open_after_conversion: boolean; +} + +/** Per-file properties attached to remove-background events. */ +interface RemoveBgFilePayload { + file_extension: string | null; + file_size: number; + file_type: string; +} + +// Navigation / creation +export interface EditorOpenedPayload { + source: string; + destination: string; + existing_file_count: number; +} +export interface CanvasCreatedPayload { + width: number; + height: number; + creation_mode: "preset" | "custom"; + preset_label: string | null; +} + +// Files +export interface FileOpenedPayload { + file_id: string; + method: string; +} +export interface FileDuplicatedPayload { + file_id: string; + new_file_id: string; +} +export interface FileDownloadedPayload { + file_id: string; + format: "json"; +} +export interface FileDeletedPayload { + file_count: number; + file_ids: string[]; +} +export interface FilesBulkDownloadedPayload { + file_count: number; +} +export interface FileImportedPayload { + file_id: string; + file_name: string; + source_name: string; + source_type: "json"; + imported_version: number; +} +export interface DocumentRenamedPayload { + file_id: string; + new_name: string; +} + +// Editor +export interface AiPromptSubmittedPayload { + prompt_length: number; + image_count: number; +} +export interface ImageExportedPayload { + format: string; + scale: number; + transparent: boolean; + flattenPdf: boolean | undefined; + pageCount: number; +} + +// Legacy file conversion +export interface LegacyConversionPromptOpenedPayload extends LegacyConversionPayload {} +export interface LegacyConversionStartedPayload extends LegacyConversionPayload {} +export interface LegacyConversionCompletedPayload extends LegacyConversionPayload { + opened_file_id: string | null; +} +export interface LegacyConversionFailedPayload extends LegacyConversionPayload {} +export interface LegacyConversionCancelledPayload extends LegacyConversionPayload {} + +// Background removal (paused feature route) +export type RemoveBgPageViewedPayload = undefined; +export interface RemoveBgHistoryLoadedPayload { + has_history: boolean; + history_count: number; +} +export interface RemoveBgHistorySelectedPayload { + history_count: number | null; + item_age_ms: number; + output_size: number; + source: string; +} +export interface RemoveBgHistoryDeletedPayload { + history_count_after: number; + was_selected: boolean; +} +export type RemoveBgHistoryDeleteFailedPayload = undefined; +export interface RemoveBgStartedPayload extends RemoveBgFilePayload { + source: string; +} +export interface RemoveBgCompletedPayload extends RemoveBgFilePayload { + duration_ms: number; + output_size: number; + output_type: string; + source: string; +} +export interface RemoveBgFailedPayload extends RemoveBgFilePayload { + duration_ms: number; + error_message: string; + source: string; +} +export interface RemoveBgFileSelectedPayload extends RemoveBgFilePayload { + source: string; +} +export interface RemoveBgInvalidFileSelectedPayload extends RemoveBgFilePayload { + source: string; +} +export interface RemoveBgFileTooLargePayload extends RemoveBgFilePayload { + limit_bytes: number; + limit_label: string; + source: string; +} +export interface RemoveBgDownloadedPayload { + extension: "png"; + output_size: number | null; + prompt_suppressed: boolean; +} +export type RemoveBgCompareStartedPayload = undefined; +export interface RemoveBgUploadPickerOpenedPayload { + surface: string; +} +export interface RemoveBgSponsorPromptShownPayload { + trigger: string; +} +export interface RemoveBgSponsorPromptClosedPayload { + reason: string; +} +export type RemoveBgSponsorPromptDismissedForeverPayload = undefined; +export type RemoveBgSponsorPromptSponsorClickedPayload = undefined; +export type RemoveBgSponsorHeaderClickedPayload = undefined; diff --git a/frontend/src/lib/analytics/events.ts b/frontend/src/lib/analytics/events.ts new file mode 100644 index 0000000..c920554 --- /dev/null +++ b/frontend/src/lib/analytics/events.ts @@ -0,0 +1,107 @@ +/** + * The analytics event catalog. + * + * `Payloads` is the per-event payload namespace (see `event-payloads.ts`). + * `AnalyticsEvents` maps each event name to its payload and is what the facade + * and `emit` are typed against — the single source of truth for every product + * event Avnac emits. + */ + +export * as Payloads from "./event-payloads"; + +import type * as Payloads from "./event-payloads"; + +/** Event name -> payload. The backbone the facade and `emit` are typed against. */ +export type AnalyticsEvents = { + editor_opened: Payloads.EditorOpenedPayload; + canvas_created: Payloads.CanvasCreatedPayload; + file_opened: Payloads.FileOpenedPayload; + file_duplicated: Payloads.FileDuplicatedPayload; + file_downloaded: Payloads.FileDownloadedPayload; + file_deleted: Payloads.FileDeletedPayload; + files_bulk_downloaded: Payloads.FilesBulkDownloadedPayload; + file_imported: Payloads.FileImportedPayload; + document_renamed: Payloads.DocumentRenamedPayload; + ai_prompt_submitted: Payloads.AiPromptSubmittedPayload; + image_exported: Payloads.ImageExportedPayload; + legacy_conversion_prompt_opened: Payloads.LegacyConversionPromptOpenedPayload; + legacy_conversion_started: Payloads.LegacyConversionStartedPayload; + legacy_conversion_completed: Payloads.LegacyConversionCompletedPayload; + legacy_conversion_failed: Payloads.LegacyConversionFailedPayload; + legacy_conversion_cancelled: Payloads.LegacyConversionCancelledPayload; + remove_bg_page_viewed: Payloads.RemoveBgPageViewedPayload; + remove_bg_history_loaded: Payloads.RemoveBgHistoryLoadedPayload; + remove_bg_history_selected: Payloads.RemoveBgHistorySelectedPayload; + remove_bg_history_deleted: Payloads.RemoveBgHistoryDeletedPayload; + remove_bg_history_delete_failed: Payloads.RemoveBgHistoryDeleteFailedPayload; + remove_bg_started: Payloads.RemoveBgStartedPayload; + remove_bg_completed: Payloads.RemoveBgCompletedPayload; + remove_bg_failed: Payloads.RemoveBgFailedPayload; + remove_bg_file_selected: Payloads.RemoveBgFileSelectedPayload; + remove_bg_invalid_file_selected: Payloads.RemoveBgInvalidFileSelectedPayload; + remove_bg_file_too_large: Payloads.RemoveBgFileTooLargePayload; + remove_bg_downloaded: Payloads.RemoveBgDownloadedPayload; + remove_bg_compare_started: Payloads.RemoveBgCompareStartedPayload; + remove_bg_upload_picker_opened: Payloads.RemoveBgUploadPickerOpenedPayload; + remove_bg_sponsor_prompt_shown: Payloads.RemoveBgSponsorPromptShownPayload; + remove_bg_sponsor_prompt_closed: Payloads.RemoveBgSponsorPromptClosedPayload; + remove_bg_sponsor_prompt_dismissed_forever: Payloads.RemoveBgSponsorPromptDismissedForeverPayload; + remove_bg_sponsor_prompt_sponsor_clicked: Payloads.RemoveBgSponsorPromptSponsorClickedPayload; + remove_bg_sponsor_header_clicked: Payloads.RemoveBgSponsorHeaderClickedPayload; +}; + +export type AnalyticsEventName = keyof AnalyticsEvents; + +/** + * Method-name -> event-name map used by the facade. `satisfies` ties every + * value to a real key of `AnalyticsEvents`, so a typo or stale name fails to + * compile rather than silently sending an unknown event. + */ +export const ANALYTICS_EVENTS = { + // Navigation / creation + editorOpened: "editor_opened", + canvasCreated: "canvas_created", + + // Files + fileOpened: "file_opened", + fileDuplicated: "file_duplicated", + fileDownloaded: "file_downloaded", + fileDeleted: "file_deleted", + filesBulkDownloaded: "files_bulk_downloaded", + fileImported: "file_imported", + documentRenamed: "document_renamed", + + // Editor + aiPromptSubmitted: "ai_prompt_submitted", + imageExported: "image_exported", + + // Legacy file conversion + legacyConversionPromptOpened: "legacy_conversion_prompt_opened", + legacyConversionStarted: "legacy_conversion_started", + legacyConversionCompleted: "legacy_conversion_completed", + legacyConversionFailed: "legacy_conversion_failed", + legacyConversionCancelled: "legacy_conversion_cancelled", + + // Background removal (paused feature route) + removeBgPageViewed: "remove_bg_page_viewed", + removeBgHistoryLoaded: "remove_bg_history_loaded", + removeBgHistorySelected: "remove_bg_history_selected", + removeBgHistoryDeleted: "remove_bg_history_deleted", + removeBgHistoryDeleteFailed: "remove_bg_history_delete_failed", + removeBgStarted: "remove_bg_started", + removeBgCompleted: "remove_bg_completed", + removeBgFailed: "remove_bg_failed", + removeBgFileSelected: "remove_bg_file_selected", + removeBgInvalidFileSelected: "remove_bg_invalid_file_selected", + removeBgFileTooLarge: "remove_bg_file_too_large", + removeBgDownloaded: "remove_bg_downloaded", + removeBgCompareStarted: "remove_bg_compare_started", + removeBgUploadPickerOpened: "remove_bg_upload_picker_opened", + removeBgSponsorPromptShown: "remove_bg_sponsor_prompt_shown", + removeBgSponsorPromptClosed: "remove_bg_sponsor_prompt_closed", + removeBgSponsorPromptDismissedForever: + "remove_bg_sponsor_prompt_dismissed_forever", + removeBgSponsorPromptSponsorClicked: + "remove_bg_sponsor_prompt_sponsor_clicked", + removeBgSponsorHeaderClicked: "remove_bg_sponsor_header_clicked", +} as const satisfies Record; diff --git a/frontend/src/lib/analytics/index.ts b/frontend/src/lib/analytics/index.ts new file mode 100644 index 0000000..3130b23 --- /dev/null +++ b/frontend/src/lib/analytics/index.ts @@ -0,0 +1,2 @@ +export type { AnalyticsEventName, AnalyticsEvents } from './events' +export { useAnalytics } from './use-analytics' diff --git a/frontend/src/lib/analytics/use-analytics.ts b/frontend/src/lib/analytics/use-analytics.ts new file mode 100644 index 0000000..35134ec --- /dev/null +++ b/frontend/src/lib/analytics/use-analytics.ts @@ -0,0 +1,107 @@ +import { usePostHog } from 'posthog-js/react' +import { useMemo } from 'react' +import { ANALYTICS_EVENTS, type AnalyticsEvents, type Payloads } from './events' + +/** Events whose payload type is `undefined` are called with no argument. */ +type EmptyEvents = { + [E in keyof AnalyticsEvents]: AnalyticsEvents[E] extends undefined ? E : never +}[keyof AnalyticsEvents] + +/** + * Typed analytics facade. + * + * Every product event is a named method whose payload is a named type in the + * `Payloads` namespace. Event names come from `ANALYTICS_EVENTS`, not inline + * strings. All calls funnel through `emit`, which is the single place a + * provider (PostHog today) is named — add or swap a provider there and every + * call site comes along for free. + */ +export function useAnalytics() { + const posthog = usePostHog() + + return useMemo(() => { + function emit(event: E): void + function emit(event: E, props: AnalyticsEvents[E]): void + function emit(event: E, props?: AnalyticsEvents[E]): void { + posthog.capture(event, props ?? undefined) + } + + return { + // Navigation / creation + editorOpened: (p: Payloads.EditorOpenedPayload) => emit(ANALYTICS_EVENTS.editorOpened, p), + canvasCreated: (p: Payloads.CanvasCreatedPayload) => emit(ANALYTICS_EVENTS.canvasCreated, p), + + // Files + fileOpened: (p: Payloads.FileOpenedPayload) => emit(ANALYTICS_EVENTS.fileOpened, p), + fileDuplicated: (p: Payloads.FileDuplicatedPayload) => + emit(ANALYTICS_EVENTS.fileDuplicated, p), + fileDownloaded: (p: Payloads.FileDownloadedPayload) => + emit(ANALYTICS_EVENTS.fileDownloaded, p), + fileDeleted: (p: Payloads.FileDeletedPayload) => emit(ANALYTICS_EVENTS.fileDeleted, p), + filesBulkDownloaded: (p: Payloads.FilesBulkDownloadedPayload) => + emit(ANALYTICS_EVENTS.filesBulkDownloaded, p), + fileImported: (p: Payloads.FileImportedPayload) => emit(ANALYTICS_EVENTS.fileImported, p), + documentRenamed: (p: Payloads.DocumentRenamedPayload) => + emit(ANALYTICS_EVENTS.documentRenamed, p), + + // Editor + aiPromptSubmitted: (p: Payloads.AiPromptSubmittedPayload) => + emit(ANALYTICS_EVENTS.aiPromptSubmitted, p), + imageExported: (p: Payloads.ImageExportedPayload) => emit(ANALYTICS_EVENTS.imageExported, p), + + // Legacy file conversion + legacyConversionPromptOpened: (p: Payloads.LegacyConversionPromptOpenedPayload) => + emit(ANALYTICS_EVENTS.legacyConversionPromptOpened, p), + legacyConversionStarted: (p: Payloads.LegacyConversionStartedPayload) => + emit(ANALYTICS_EVENTS.legacyConversionStarted, p), + legacyConversionCompleted: (p: Payloads.LegacyConversionCompletedPayload) => + emit(ANALYTICS_EVENTS.legacyConversionCompleted, p), + legacyConversionFailed: (p: Payloads.LegacyConversionFailedPayload) => + emit(ANALYTICS_EVENTS.legacyConversionFailed, p), + legacyConversionCancelled: (p: Payloads.LegacyConversionCancelledPayload) => + emit(ANALYTICS_EVENTS.legacyConversionCancelled, p), + + // Background removal (paused feature route) + removeBgPageViewed: () => emit(ANALYTICS_EVENTS.removeBgPageViewed), + removeBgHistoryLoaded: (p: Payloads.RemoveBgHistoryLoadedPayload) => + emit(ANALYTICS_EVENTS.removeBgHistoryLoaded, p), + removeBgHistorySelected: (p: Payloads.RemoveBgHistorySelectedPayload) => + emit(ANALYTICS_EVENTS.removeBgHistorySelected, p), + removeBgHistoryDeleted: (p: Payloads.RemoveBgHistoryDeletedPayload) => + emit(ANALYTICS_EVENTS.removeBgHistoryDeleted, p), + removeBgHistoryDeleteFailed: () => emit(ANALYTICS_EVENTS.removeBgHistoryDeleteFailed), + removeBgStarted: (p: Payloads.RemoveBgStartedPayload) => + emit(ANALYTICS_EVENTS.removeBgStarted, p), + removeBgCompleted: (p: Payloads.RemoveBgCompletedPayload) => + emit(ANALYTICS_EVENTS.removeBgCompleted, p), + removeBgFailed: (p: Payloads.RemoveBgFailedPayload) => + emit(ANALYTICS_EVENTS.removeBgFailed, p), + removeBgFileSelected: (p: Payloads.RemoveBgFileSelectedPayload) => + emit(ANALYTICS_EVENTS.removeBgFileSelected, p), + removeBgInvalidFileSelected: (p: Payloads.RemoveBgInvalidFileSelectedPayload) => + emit(ANALYTICS_EVENTS.removeBgInvalidFileSelected, p), + removeBgFileTooLarge: (p: Payloads.RemoveBgFileTooLargePayload) => + emit(ANALYTICS_EVENTS.removeBgFileTooLarge, p), + removeBgDownloaded: (p: Payloads.RemoveBgDownloadedPayload) => + emit(ANALYTICS_EVENTS.removeBgDownloaded, p), + removeBgCompareStarted: () => emit(ANALYTICS_EVENTS.removeBgCompareStarted), + removeBgUploadPickerOpened: (p: Payloads.RemoveBgUploadPickerOpenedPayload) => + emit(ANALYTICS_EVENTS.removeBgUploadPickerOpened, p), + removeBgSponsorPromptShown: (p: Payloads.RemoveBgSponsorPromptShownPayload) => + emit(ANALYTICS_EVENTS.removeBgSponsorPromptShown, p), + removeBgSponsorPromptClosed: (p: Payloads.RemoveBgSponsorPromptClosedPayload) => + emit(ANALYTICS_EVENTS.removeBgSponsorPromptClosed, p), + removeBgSponsorPromptDismissedForever: () => + emit(ANALYTICS_EVENTS.removeBgSponsorPromptDismissedForever), + removeBgSponsorPromptSponsorClicked: () => + emit(ANALYTICS_EVENTS.removeBgSponsorPromptSponsorClicked), + removeBgSponsorHeaderClicked: () => emit(ANALYTICS_EVENTS.removeBgSponsorHeaderClicked), + + /** Report an unexpected error. Mirrors the prior captureException + console.error pattern. */ + captureError: (error: unknown, context?: string) => { + posthog.captureException(error) + if (context) console.error(context, error) + }, + } + }, [posthog]) +} diff --git a/frontend/src/routes/create.tsx b/frontend/src/routes/create.tsx index d7a733e..638ecf2 100644 --- a/frontend/src/routes/create.tsx +++ b/frontend/src/routes/create.tsx @@ -1,13 +1,13 @@ import { Home05Icon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { createFileRoute, Link } from '@tanstack/react-router' -import { usePostHog } from 'posthog-js/react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' import DocumentMigrationDialog from '../components/document-migration-dialog' import EditorExportMenu from '../components/editor-export-menu' import SceneEditor, { type SceneEditorHandle } from '../components/scene-editor' import { buttonClassName, iconButtonClassName, Kicker, Surface, Text } from '../components/ui' import { useEditorUnsupportedOnThisDevice } from '../hooks/use-editor-device-support' +import { useAnalytics } from '../lib/analytics' import { idbGetEditorRecord, idbMigrateLegacyDocument, @@ -51,7 +51,7 @@ function CreatePage() { const initialW = search.w const initialH = search.h const navigate = Route.useNavigate() - const posthog = usePostHog() + const analytics = useAnalytics() const editorUnsupported = useEditorUnsupportedOnThisDevice() useLayoutEffect(() => { @@ -87,7 +87,7 @@ function CreatePage() { setDocumentTitle(t) if (id) { void idbSetDocumentName(id, t) - posthog.capture('document_renamed', { file_id: id, new_name: t }) + analytics.documentRenamed({ file_id: id, new_name: t }) } } @@ -96,14 +96,14 @@ function CreatePage() { useEffect(() => { if (!legacyBlocked || !id) return - posthog.capture('legacy_conversion_prompt_opened', { + analytics.legacyConversionPromptOpened({ surface: 'create_page', trigger_source: 'direct_open', file_count: 1, file_ids: [id], open_after_conversion: false, }) - }, [id, legacyBlocked, posthog]) + }, [id, legacyBlocked, analytics]) if (editorUnsupported) { return ( @@ -226,7 +226,7 @@ function CreatePage() { busy={migrationBusy} onClose={() => { if (migrationBusy) return - posthog.capture('legacy_conversion_cancelled', { + analytics.legacyConversionCancelled({ surface: 'create_page', trigger_source: 'direct_open', file_count: 1, @@ -237,7 +237,7 @@ function CreatePage() { }} onConfirm={() => { if (migrationBusy) return - posthog.capture('legacy_conversion_started', { + analytics.legacyConversionStarted({ surface: 'create_page', trigger_source: 'direct_open', file_count: 1, @@ -248,7 +248,7 @@ function CreatePage() { void (async () => { try { await idbMigrateLegacyDocument(id) - posthog.capture('legacy_conversion_completed', { + analytics.legacyConversionCompleted({ surface: 'create_page', trigger_source: 'direct_open', file_count: 1, @@ -258,15 +258,14 @@ function CreatePage() { }) setDocumentStorageKind('current') } catch (err) { - posthog.capture('legacy_conversion_failed', { + analytics.legacyConversionFailed({ surface: 'create_page', trigger_source: 'direct_open', file_count: 1, file_ids: [id], open_after_conversion: false, }) - posthog.captureException(err) - console.error('[avnac] legacy migration failed', err) + analytics.captureError(err, '[avnac] legacy migration failed') } finally { setMigrationBusy(false) } diff --git a/frontend/src/routes/files.tsx b/frontend/src/routes/files.tsx index e570c6d..b9acbf2 100644 --- a/frontend/src/routes/files.tsx +++ b/frontend/src/routes/files.tsx @@ -1,13 +1,13 @@ import { ArrowDown01Icon, CloudUploadIcon } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { createFileRoute } from '@tanstack/react-router' -import { usePostHog } from 'posthog-js/react' import { useCallback, useEffect, useRef, useState } from 'react' import DeleteConfirmDialog from '../components/delete-confirm-dialog' import DocumentMigrationDialog from '../components/document-migration-dialog' import FileGridCard from '../components/file-grid-card' import FilesMultiselectBar from '../components/files-multiselect-bar' import NewCanvasDialog from '../components/new-canvas-dialog' +import { useAnalytics } from '../lib/analytics' import { parseAvnacDocument } from '../lib/avnac-document' import { avnacDocumentPreviewEvictPersistId } from '../lib/avnac-document-preview' import { @@ -62,7 +62,7 @@ function FilesPage() { const [migrationBusy, setMigrationBusy] = useState(false) const actionsRef = useRef(null) const fileInputRef = useRef(null) - const posthog = usePostHog() + const analytics = useAnalytics() const navigate = Route.useNavigate() const clearSelection = useCallback(() => setSelectedIds([]), []) @@ -133,7 +133,7 @@ function FilesPage() { const bulkDownload = useCallback(() => { const ids = [...selectedIds] - posthog.capture('files_bulk_downloaded', { file_count: ids.length }) + analytics.filesBulkDownloaded({ file_count: ids.length }) void (async () => { try { for (const id of ids) { @@ -141,11 +141,10 @@ function FilesPage() { await new Promise(r => setTimeout(r, 140)) } } catch (err) { - posthog.captureException(err) - console.error('[avnac] bulk download failed', err) + analytics.captureError(err, '[avnac] bulk download failed') } })() - }, [selectedIds, posthog]) + }, [selectedIds, analytics]) const bulkTrash = useCallback(() => { const ids = [...selectedIds] @@ -165,7 +164,7 @@ function FilesPage() { if (!deleteDialog) return const ids = [...deleteDialog.ids] setDeleteDialog(null) - posthog.capture('file_deleted', { file_count: ids.length, file_ids: ids }) + analytics.fileDeleted({ file_count: ids.length, file_ids: ids }) void (async () => { try { for (const id of ids) { @@ -175,11 +174,10 @@ function FilesPage() { setSelectedIds(prev => prev.filter(id => !ids.includes(id))) refreshList() } catch (err) { - posthog.captureException(err) - console.error('[avnac] delete failed', err) + analytics.captureError(err, '[avnac] delete failed') } })() - }, [deleteDialog, refreshList, posthog]) + }, [deleteDialog, refreshList, analytics]) const requestDeleteFile = useCallback((id: string) => { setDeleteDialog({ @@ -198,7 +196,7 @@ function FilesPage() { try { raw = JSON.parse(await file.text()) as unknown } catch (err) { - posthog.captureException(err) + analytics.captureError(err) setImportError( 'That file is not valid JSON. Choose an exported Avnac JSON document and try again.', ) @@ -214,7 +212,7 @@ function FilesPage() { const id = crypto.randomUUID() const name = nameFromImportFilename(file.name) await idbPutDocument(id, document, { name }) - posthog.capture('file_imported', { + analytics.fileImported({ file_id: id, file_name: name, source_name: file.name, @@ -224,13 +222,13 @@ function FilesPage() { refreshList() void navigate({ to: '/create', search: { id } }) } catch (err) { - posthog.captureException(err) + analytics.captureError(err) setImportError( 'The file could not be imported into this browser right now. Try again in a moment.', ) } }, - [navigate, posthog, refreshList], + [navigate, analytics, refreshList], ) const onImportInputChange = useCallback( @@ -262,7 +260,7 @@ function FilesPage() { triggerSource: source, openFileId: row.id, }) - posthog.capture('legacy_conversion_prompt_opened', { + analytics.legacyConversionPromptOpened({ surface: 'files_page', trigger_source: source, file_count: 1, @@ -271,13 +269,13 @@ function FilesPage() { }) return } - posthog.capture('file_opened', { + analytics.fileOpened({ file_id: row.id, method: source, }) void navigate({ to: '/create', search: { id: row.id } }) }, - [navigate, posthog], + [navigate, analytics], ) const requestMigrateAll = useCallback(() => { @@ -295,19 +293,19 @@ function FilesPage() { confirmLabel: legacyItems.length === 1 ? 'Convert file' : 'Migrate all files', triggerSource: 'banner', }) - posthog.capture('legacy_conversion_prompt_opened', { + analytics.legacyConversionPromptOpened({ surface: 'files_page', trigger_source: 'banner', file_count: legacyItems.length, file_ids: legacyItems.map(row => row.id), open_after_conversion: false, }) - }, [legacyItems]) + }, [legacyItems, analytics]) const confirmMigration = useCallback(() => { if (!migrationDialog || migrationBusy) return const { ids, openFileId, triggerSource } = migrationDialog - posthog.capture('legacy_conversion_started', { + analytics.legacyConversionStarted({ surface: 'files_page', trigger_source: triggerSource, file_count: ids.length, @@ -320,7 +318,7 @@ function FilesPage() { for (const id of ids) { await idbMigrateLegacyDocument(id) } - posthog.capture('legacy_conversion_completed', { + analytics.legacyConversionCompleted({ surface: 'files_page', trigger_source: triggerSource, file_count: ids.length, @@ -334,20 +332,20 @@ function FilesPage() { void navigate({ to: '/create', search: { id: openFileId } }) } } catch (err) { - posthog.capture('legacy_conversion_failed', { + analytics.legacyConversionFailed({ surface: 'files_page', trigger_source: triggerSource, file_count: ids.length, file_ids: ids, open_after_conversion: openFileId != null, }) - posthog.captureException(err) + analytics.captureError(err) setImportError('Those files could not be converted right now. Try again in a moment.') } finally { setMigrationBusy(false) } })() - }, [migrationBusy, migrationDialog, navigate, posthog, refreshList]) + }, [migrationBusy, migrationDialog, navigate, analytics, refreshList]) return (
@@ -515,7 +513,7 @@ function FilesPage() { onClose={() => { if (migrationBusy) return if (migrationDialog) { - posthog.capture('legacy_conversion_cancelled', { + analytics.legacyConversionCancelled({ surface: 'files_page', trigger_source: migrationDialog.triggerSource, file_count: migrationDialog.ids.length, diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 3b24bcb..151aaa7 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -15,7 +15,6 @@ import { useSpring, useTransform, } from "motion/react"; -import { usePostHog } from "posthog-js/react"; import { type CSSProperties, useCallback, @@ -26,6 +25,7 @@ import { } from "react"; import doodleSvgRaw from "../assets/doodle.svg?raw"; import NewCanvasDialog from "../components/new-canvas-dialog"; +import { useAnalytics } from "../lib/analytics"; import { idbListDocuments } from "../lib/avnac-editor-idb"; export const Route = createFileRoute("/")({ component: Landing }); @@ -222,7 +222,7 @@ function Landing() { const [stickers, setStickers] = useState(initialStickers); const [activeStickerId, setActiveStickerId] = useState(null); const [activeToolIndex, setActiveToolIndex] = useState(0); - const posthog = usePostHog(); + const analytics = useAnalytics(); const stickerLayerRef = useRef(null); const toolsSectionRef = useRef(null); const vectorsSectionRef = useRef(null); @@ -353,7 +353,7 @@ function Landing() { const docs = await idbListDocuments(); setSavedFileCount(docs.length); const destination = docs.length > 0 ? "/files" : "/create"; - posthog.capture("editor_opened", { + analytics.editorOpened({ source: "landing_hero", destination, existing_file_count: docs.length, @@ -363,11 +363,11 @@ function Landing() { return; } } catch (err) { - posthog.captureException(err); + analytics.captureError(err); } setNewCanvasOpen(true); })(); - }, [navigate, posthog]); + }, [navigate, analytics]); const hasSavedFiles = (savedFileCount ?? 0) > 0; const primaryCtaLabel = hasSavedFiles ? "Open files" : "Open editor"; diff --git a/frontend/src/routes/remove-bg.tsx b/frontend/src/routes/remove-bg.tsx index 02fffbf..9f575c3 100644 --- a/frontend/src/routes/remove-bg.tsx +++ b/frontend/src/routes/remove-bg.tsx @@ -11,9 +11,9 @@ import { } from '@hugeicons/core-free-icons' import { HugeiconsIcon } from '@hugeicons/react' import { createFileRoute, Link } from '@tanstack/react-router' -import { usePostHog } from 'posthog-js/react' import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react' import { cx } from '../components/ui' +import { useAnalytics } from '../lib/analytics' import { removeBackgroundFromFile } from '../lib/avnac-background-removal' import { REMOVE_BG_FEATURE_ENABLED, REMOVE_BG_UNAVAILABLE_MESSAGE } from '../lib/feature-flags' import { @@ -117,7 +117,7 @@ function RemoveBgPage() { const [selectedHistoryId, setSelectedHistoryId] = useState(null) const [sponsorPromptOpen, setSponsorPromptOpen] = useState(false) const [sponsorPromptDismissed, setSponsorPromptDismissed] = useState(false) - const posthog = usePostHog() + const analytics = useAnalytics() const showHistoryItem = useCallback( ( @@ -133,14 +133,14 @@ function RemoveBgPage() { setCompareHeld(false) setError(null) setSelectedHistoryId(item.id) - posthog.capture('remove_bg_history_selected', { + analytics.removeBgHistorySelected({ history_count: historyCount ?? null, item_age_ms: Math.max(0, Date.now() - item.createdAt), output_size: item.resultBlob.size, source, }) }, - [posthog], + [analytics], ) useEffect(() => { @@ -167,8 +167,8 @@ function RemoveBgPage() { useEffect(() => { setSponsorPromptDismissed(readSponsorPromptDismissed()) - posthog.capture('remove_bg_page_viewed') - }, [posthog]) + analytics.removeBgPageViewed() + }, [analytics]) useEffect(() => { if (!sponsorPromptOpen) return @@ -176,7 +176,7 @@ function RemoveBgPage() { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setSponsorPromptOpen(false) - posthog.capture('remove_bg_sponsor_prompt_closed', { + analytics.removeBgSponsorPromptClosed({ reason: 'escape', }) } @@ -184,7 +184,7 @@ function RemoveBgPage() { window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [posthog, sponsorPromptOpen]) + }, [analytics, sponsorPromptOpen]) useEffect(() => { let cancelled = false @@ -193,7 +193,7 @@ function RemoveBgPage() { .then(items => { if (cancelled) return setHistoryItems(items) - posthog.capture('remove_bg_history_loaded', { + analytics.removeBgHistoryLoaded({ has_history: items.length > 0, history_count: items.length, }) @@ -202,13 +202,13 @@ function RemoveBgPage() { } }) .catch(err => { - if (!cancelled) posthog.captureException(err) + if (!cancelled) analytics.captureError(err) }) return () => { cancelled = true } - }, [posthog, showHistoryItem]) + }, [analytics, showHistoryItem]) const rememberHistoryItem = useCallback(async (file: File, blob: Blob, filename: string) => { const item: RemoveBgHistoryItem = { @@ -234,7 +234,7 @@ function RemoveBgPage() { await deleteRemoveBgHistoryItem(item.id) const items = await listRemoveBgHistory() setHistoryItems(items) - posthog.capture('remove_bg_history_deleted', { + analytics.removeBgHistoryDeleted({ history_count_after: items.length, was_selected: wasSelected, }) @@ -252,11 +252,11 @@ function RemoveBgPage() { setError(null) setSelectedHistoryId(null) })().catch(err => { - posthog.capture('remove_bg_history_delete_failed') - posthog.captureException(err) + analytics.removeBgHistoryDeleteFailed() + analytics.captureError(err) }) }, - [posthog, selectedHistoryId, showHistoryItem], + [analytics, selectedHistoryId, showHistoryItem], ) const processFile = useCallback( @@ -271,7 +271,7 @@ function RemoveBgPage() { setError(null) setSelectedHistoryId(null) const startedAt = performance.now() - posthog.capture('remove_bg_started', { + analytics.removeBgStarted({ ...fileAnalyticsFor(file), source, }) @@ -285,9 +285,9 @@ function RemoveBgPage() { setResultFilename(filename) setStatus('done') void rememberHistoryItem(file, output.blob, filename).catch(err => { - posthog.captureException(err) + analytics.captureError(err) }) - posthog.capture('remove_bg_completed', { + analytics.removeBgCompleted({ ...fileAnalyticsFor(file), duration_ms: Math.round(performance.now() - startedAt), output_size: output.blob.size, @@ -302,17 +302,17 @@ function RemoveBgPage() { : 'Could not remove the background.' setStatus('error') setError(message) - posthog.capture('remove_bg_failed', { + analytics.removeBgFailed({ ...fileAnalyticsFor(file), duration_ms: Math.round(performance.now() - startedAt), error_message: message, source, }) - posthog.captureException(err) + analytics.captureError(err) } })() }, - [posthog, rememberHistoryItem], + [analytics, rememberHistoryItem], ) const chooseFile = useCallback( @@ -320,7 +320,7 @@ function RemoveBgPage() { if (!file) return if (!isImageFile(file)) { setError('Choose an image file.') - posthog.capture('remove_bg_invalid_file_selected', { + analytics.removeBgInvalidFileSelected({ ...fileAnalyticsFor(file), source, }) @@ -328,7 +328,7 @@ function RemoveBgPage() { } if (file.size > MAX_REMOVE_BG_FILE_SIZE_BYTES) { setError(`Choose an image ${MAX_REMOVE_BG_FILE_SIZE_LABEL} or smaller.`) - posthog.capture('remove_bg_file_too_large', { + analytics.removeBgFileTooLarge({ ...fileAnalyticsFor(file), limit_bytes: MAX_REMOVE_BG_FILE_SIZE_BYTES, limit_label: MAX_REMOVE_BG_FILE_SIZE_LABEL, @@ -336,13 +336,13 @@ function RemoveBgPage() { }) return } - posthog.capture('remove_bg_file_selected', { + analytics.removeBgFileSelected({ ...fileAnalyticsFor(file), source, }) processFile(file, source) }, - [posthog, processFile], + [analytics, processFile], ) useEffect(() => { @@ -364,57 +364,57 @@ function RemoveBgPage() { a.download = resultFilename a.click() const promptSuppressed = sponsorPromptDismissed || readSponsorPromptDismissed() - posthog.capture('remove_bg_downloaded', { + analytics.removeBgDownloaded({ extension: 'png', output_size: resultBlob?.size ?? null, prompt_suppressed: promptSuppressed, }) if (!promptSuppressed) { setSponsorPromptOpen(true) - posthog.capture('remove_bg_sponsor_prompt_shown', { + analytics.removeBgSponsorPromptShown({ trigger: 'download', }) } - }, [posthog, resultBlob?.size, resultFilename, resultUrl, sponsorPromptDismissed]) + }, [analytics, resultBlob?.size, resultFilename, resultUrl, sponsorPromptDismissed]) const closeSponsorPrompt = useCallback( (reason: SponsorPromptCloseReason = 'remind_later') => { setSponsorPromptOpen(false) - posthog.capture('remove_bg_sponsor_prompt_closed', { + analytics.removeBgSponsorPromptClosed({ reason, }) }, - [posthog], + [analytics], ) const dismissSponsorPromptForever = useCallback(() => { writeSponsorPromptDismissed() setSponsorPromptDismissed(true) setSponsorPromptOpen(false) - posthog.capture('remove_bg_sponsor_prompt_dismissed_forever') - }, [posthog]) + analytics.removeBgSponsorPromptDismissedForever() + }, [analytics]) const openSponsorPage = useCallback(() => { setSponsorPromptOpen(false) - posthog.capture('remove_bg_sponsor_prompt_sponsor_clicked') - }, [posthog]) + analytics.removeBgSponsorPromptSponsorClicked() + }, [analytics]) const trackHeaderSponsorClick = useCallback(() => { - posthog.capture('remove_bg_sponsor_header_clicked') - }, [posthog]) + analytics.removeBgSponsorHeaderClicked() + }, [analytics]) const trackCompareStart = useCallback(() => { if (compareHeld) return setCompareHeld(true) - posthog.capture('remove_bg_compare_started') - }, [compareHeld, posthog]) + analytics.removeBgCompareStarted() + }, [compareHeld, analytics]) const openUploadPicker = useCallback( (surface: RemoveBgUploadSurface) => { - posthog.capture('remove_bg_upload_picker_opened', { surface }) + analytics.removeBgUploadPickerOpened({ surface }) inputRef.current?.click() }, - [posthog], + [analytics], ) const onInputChange = useCallback(