Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions frontend/src/__tests__/use-analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("react")>();
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();
});
});
8 changes: 4 additions & 4 deletions frontend/src/components/editor-ai-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
useTambo,
useTamboThreadInput,
} from '@tambo-ai/react'
import { usePostHog } from 'posthog-js/react'
import {
type ChangeEvent,
type FormEvent,
Expand All @@ -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'
Expand Down Expand Up @@ -287,7 +287,7 @@ function MagicChat({ quickPrompts }: { quickPrompts: string[] }) {
const [error, setError] = useState<string | null>(null)
const scrollerRef = useRef<HTMLDivElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const posthog = usePostHog()
const analytics = useAnalytics()

const displayMessages = useMemo(() => groupMessagesForDisplay(messages), [messages])

Expand All @@ -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.')
}
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/editor-export-menu.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -180,7 +180,7 @@ export default function EditorExportMenu({ disabled, getPages, onExport }: Props
const [pages, setPages] = useState<ExportPageOption[]>([])
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([])
const [pagesLoading, setPagesLoading] = useState(false)
const posthog = usePostHog()
const analytics = useAnalytics()
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const pickPanel = useCallback(() => panelRef.current, [])
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions frontend/src/components/file-grid-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -34,7 +34,7 @@ export default function FileGridCard({
}: FileGridCardProps) {
const [menuOpen, setMenuOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const posthog = usePostHog()
const analytics = useAnalytics()

useEffect(() => {
if (!menuOpen) return
Expand All @@ -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')
Expand All @@ -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')
}
})()
}
Expand All @@ -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')
}
})()
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/new-canvas-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
Expand Down Expand Up @@ -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',
Expand Down
150 changes: 150 additions & 0 deletions frontend/src/lib/analytics/event-payloads.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading