feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan: - Vault: vault_opened (with has_git, note_count), vault_switched - Notes: note_created (with has_type, creation_path), note_deleted, note_trashed, note_archived, note_favorited, note_unfavorited - Git: commit_made, sync_triggered - Search: search_used - Editor: raw_mode_toggled, wikilink_inserted - Types & Views: type_created, view_created - Telemetry: telemetry_opted_in, telemetry_opted_out All events gated by PostHog initialization (only fires when opted in). No PII in any event properties — counts, booleans, and enums only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -57,6 +57,7 @@ import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -118,6 +119,14 @@ function App() {
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
const vaultOpenedRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
|
||||
vaultOpenedRef.current = resolvedPath
|
||||
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
@@ -331,6 +340,7 @@ function App() {
|
||||
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent('view_created')
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
@@ -136,6 +137,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
@@ -152,6 +153,10 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
const newAnalytics = analytics
|
||||
if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in')
|
||||
if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out')
|
||||
onSave(buildSettings())
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { useCreateBlockNote, SuggestionMenuController } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
@@ -87,6 +88,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
])
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface KeyboardActions {
|
||||
onQuickOpen: () => void
|
||||
@@ -104,6 +105,7 @@ export function useAppKeyboard({
|
||||
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
trackEvent('search_used')
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
@@ -219,5 +220,10 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
const triggerSync = useCallback(() => {
|
||||
trackEvent('sync_triggered')
|
||||
performPull()
|
||||
}, [performPull])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
@@ -25,6 +26,7 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
if (result.status === 'ok') {
|
||||
trackEvent('commit_made')
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface ConfirmDeleteState {
|
||||
title: string
|
||||
@@ -50,7 +51,10 @@ export function useDeleteActions({
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
const ok = await deleteNoteFromDisk(path)
|
||||
if (ok) setToastMessage('Note permanently deleted')
|
||||
if (ok) {
|
||||
trackEvent('note_deleted')
|
||||
setToastMessage('Note permanently deleted')
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
@@ -32,6 +33,7 @@ export function useEntryActions({
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
const trashedAt = Date.now() / 1000
|
||||
updateEntry(path, { trashed: true, trashedAt })
|
||||
trackEvent('note_trashed')
|
||||
setToastMessage('Note moved to trash')
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
@@ -64,6 +66,7 @@ export function useEntryActions({
|
||||
await onBeforeAction?.(path)
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
updateEntry(path, { archived: true })
|
||||
trackEvent('note_archived')
|
||||
setToastMessage('Note archived')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, '_archived', true, { silent: true })
|
||||
@@ -129,6 +132,7 @@ export function useEntryActions({
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
if (!entry) return
|
||||
if (entry.favorite) {
|
||||
trackEvent('note_unfavorited')
|
||||
updateEntry(path, { favorite: false, favoriteIndex: null })
|
||||
try {
|
||||
await handleDeleteProperty(path, '_favorite', { silent: true })
|
||||
@@ -139,6 +143,7 @@ export function useEntryActions({
|
||||
setToastMessage('Failed to unfavorite — rolled back')
|
||||
}
|
||||
} else {
|
||||
trackEvent('note_favorited')
|
||||
const maxIndex = entries.filter((e) => e.favorite).reduce((max, e) => Math.max(max, e.favoriteIndex ?? 0), 0)
|
||||
const newIndex = maxIndex + 1
|
||||
updateEntry(path, { favorite: true, favoriteIndex: newIndex })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, addMockEntry } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
export interface NewEntryParams {
|
||||
path: string
|
||||
@@ -250,6 +251,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
@@ -257,6 +259,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' })
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
@@ -269,7 +272,10 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
persistNew(resolveNewType(typeName, config.vaultPath))
|
||||
trackEvent('type_created')
|
||||
}, [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
@@ -32,6 +33,7 @@ export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: Us
|
||||
const rawMode = rawEnabled && activeTabPath !== null
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
trackEvent('raw_mode_toggled')
|
||||
if (rawEnabled) {
|
||||
onBeforeRawEnd?.()
|
||||
setRawEnabled(false)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
@@ -91,6 +92,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
}, [])
|
||||
|
||||
const switchVault = useCallback((path: string) => {
|
||||
trackEvent('vault_switched')
|
||||
setVaultPath(path)
|
||||
onSwitchRef.current()
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { _scrubPathsForTest as scrubPaths } from './telemetry'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent } from './telemetry'
|
||||
|
||||
describe('telemetry scrubPaths', () => {
|
||||
it('redacts macOS absolute paths', () => {
|
||||
@@ -29,3 +29,17 @@ describe('telemetry scrubPaths', () => {
|
||||
expect(scrubPaths(input)).toBe('Failed copying <redacted-path> to <redacted-path>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('does not throw when PostHog is not initialized', () => {
|
||||
expect(() => trackEvent('test_event', { count: 1 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts event name with no properties', () => {
|
||||
expect(() => trackEvent('note_created')).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts event name with string and number properties', () => {
|
||||
expect(() => trackEvent('note_created', { has_type: 1, creation_path: 'cmd_n' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user