feat: retrofit safe product analytics events

This commit is contained in:
lucaronin
2026-04-30 02:58:36 +02:00
parent fd3afc2493
commit 18b4f9d078
13 changed files with 345 additions and 58 deletions

View File

@@ -781,6 +781,11 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Product Events
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.

View File

@@ -961,6 +961,7 @@ sequenceDiagram
- `anonymous_id` is a locally-generated UUID, never tied to identity
- `send_default_pii: false` on both SDKs
- PostHog: `autocapture: false`, `persistence: 'memory'`, no cookies
- Product events use categorical metadata only: file preview kind/action, AI agent id/permission mode/counts/status, and All Notes visibility category/enabled state.
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`

View File

@@ -7,6 +7,14 @@ import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const { trackEventMock } = vi.hoisted(() => ({
trackEventMock: vi.fn(),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
@@ -80,6 +88,7 @@ describe('AiPanel', () => {
mockClearConversation.mockReset()
mockAddLocalMarker.mockReset()
mockUseCliAiAgent.mockReset()
trackEventMock.mockClear()
resetVaultConfigStore()
bindVaultConfigStore({
zoom: null,
@@ -141,6 +150,10 @@ describe('AiPanel', () => {
expect(mockAddLocalMarker).toHaveBeenCalledWith(
'AI permission mode changed to Power User. It will apply to the next message.',
)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_permission_mode_changed', {
agent: 'claude_code',
permission_mode: 'power_user',
})
})
it('disables permission mode changes while the AI agent is running', () => {

View File

@@ -1,12 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FilePreview } from './FilePreview'
import type { VaultEntry } from '../types'
const { trackEventMock } = vi.hoisted(() => ({
trackEventMock: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: (path: string) => `asset://${path}`,
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
const imageEntry: VaultEntry = {
path: '/vault/Attachments/photo.png',
filename: 'photo.png',
@@ -48,6 +56,10 @@ const pdfEntry: VaultEntry = {
}
describe('FilePreview', () => {
beforeEach(() => {
trackEventMock.mockClear()
})
it('routes header file actions to the active file path', () => {
const onRevealFile = vi.fn()
const onCopyFilePath = vi.fn()
@@ -62,6 +74,8 @@ describe('FilePreview', () => {
/>,
)
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'image' })
fireEvent.click(screen.getByRole('button', { name: 'Reveal' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy path' }))
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
@@ -69,6 +83,18 @@ describe('FilePreview', () => {
expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
action: 'reveal',
preview_kind: 'image',
})
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
action: 'copy_path',
preview_kind: 'image',
})
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
action: 'open_external',
preview_kind: 'image',
})
})
it('renders supported PDF files through the asset preview path', () => {
@@ -90,4 +116,16 @@ describe('FilePreview', () => {
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('PDF preview failed')
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
})
it('tracks image preview failures without leaking the file path', () => {
render(<FilePreview entry={imageEntry} />)
fireEvent.error(screen.getByTestId('image-file-preview'))
expect(trackEventMock).toHaveBeenCalledWith('file_preview_failed', { preview_kind: 'image' })
expect(trackEventMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ path: expect.any(String) }),
)
})
})

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState, type KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics'
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
import { openLocalFile } from '../utils/url'
@@ -228,9 +229,17 @@ export function FilePreview({
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
const fileTypeLabel = previewFileTypeLabel(entry)
const imageFailed = failedImagePath === entry.path
const handleImageError = useCallback(() => setFailedImagePath(entry.path), [entry.path])
const handleImageError = useCallback(() => {
setFailedImagePath(entry.path)
trackFilePreviewFailed('image')
}, [entry.path])
useEffect(() => {
trackFilePreviewOpened(previewKind)
}, [entry.path, previewKind])
const handleOpenExternal = useCallback(() => {
trackFilePreviewAction('open_external', previewKind)
if (onOpenExternalFile) {
onOpenExternalFile(entry.path)
return
@@ -239,15 +248,17 @@ export function FilePreview({
void openLocalFile(entry.path).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entry.path, onOpenExternalFile])
}, [entry.path, onOpenExternalFile, previewKind])
const handleRevealFile = useCallback(() => {
trackFilePreviewAction('reveal', previewKind)
onRevealFile?.(entry.path)
}, [entry.path, onRevealFile])
}, [entry.path, onRevealFile, previewKind])
const handleCopyFilePath = useCallback(() => {
trackFilePreviewAction('copy_path', previewKind)
onCopyFilePath?.(entry.path)
}, [entry.path, onCopyFilePath])
}, [entry.path, onCopyFilePath, previewKind])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return

View File

@@ -4,6 +4,14 @@ import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
const { trackEventMock } = vi.hoisted(() => ({
trackEventMock: vi.fn(),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
const emptySettings: Settings = {
auto_pull_interval_minutes: null,
autogit_enabled: null,
@@ -55,6 +63,7 @@ describe('SettingsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
trackEventMock.mockClear()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
window.localStorage.clear()
document.documentElement.removeAttribute('data-theme')
@@ -181,6 +190,23 @@ describe('SettingsPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('tracks All Notes visibility toggles with categorical metadata only', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox'))
expect(trackEventMock).toHaveBeenCalledWith('all_notes_visibility_changed', {
category: 'images',
enabled: 1,
})
expect(trackEventMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ path: expect.any(String) }),
)
})
it('defaults the color mode control to light', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />

View File

@@ -39,6 +39,7 @@ import {
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import { trackEvent } from '../lib/telemetry'
import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics'
import {
resolveAllNotesFileVisibility,
settingsWithAllNotesFileVisibility,
@@ -308,9 +309,10 @@ function SettingsPanelInner({
}, [onSave, settings, updateDraft])
const handleAllNotesFileVisibilityChange = useCallback((value: AllNotesFileVisibility) => {
trackAllNotesVisibilityChanged(draft.allNotesFileVisibility, value)
updateDraft('allNotesFileVisibility', value)
onSave(settingsWithAllNotesFileVisibility(settings, value))
}, [onSave, settings, updateDraft])
}, [draft.allNotesFileVisibility, onSave, settings, updateDraft])
const handleThemeModeChange = useCallback((value: ThemeMode) => {
updateDraft('themeMode', value)

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import { trackAiAgentPermissionModeChanged } from '../lib/productAnalytics'
import {
aiAgentPermissionModeMarker,
normalizeAiAgentPermissionMode,
@@ -132,8 +133,9 @@ export function useAiPanelController({
if (isActive || nextMode === permissionMode) return
updateVaultConfigField('ai_agent_permission_mode', nextMode)
trackAiAgentPermissionModeChanged(defaultAiAgent, nextMode)
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode, locale))
}, [agent, isActive, locale, permissionMode])
}, [agent, defaultAiAgent, isActive, locale, permissionMode])
const handleNewChat = useCallback(() => {
agent.clearConversation()

View File

@@ -7,6 +7,7 @@ const {
formatMessageWithHistoryMock,
nextMessageIdMock,
streamAiAgentMock,
trackEventMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
@@ -14,6 +15,7 @@ const {
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
nextMessageIdMock: vi.fn(),
streamAiAgentMock: vi.fn(async () => {}),
trackEventMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
@@ -36,6 +38,10 @@ vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
vi.mock('./telemetry', () => ({
trackEvent: trackEventMock,
}))
import {
clearAgentConversation,
sendAgentMessage,
@@ -79,6 +85,61 @@ function createRuntime(
}
}
type RuntimeFixture = ReturnType<typeof createRuntime>
const completedHistory: AiAgentMessage = {
id: 'msg-1',
userMessage: 'Previous question',
actions: [],
response: 'Previous answer',
}
const streamingHistory: AiAgentMessage = {
id: 'msg-2',
userMessage: 'Ignored streaming question',
actions: [],
isStreaming: true,
}
const expectedChatHistory = [
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
]
function expectStreamingRuntimeState(session: RuntimeFixture): void {
expect(session.runtime.abortRef.current).toEqual({ aborted: false })
expect(session.runtime.responseAccRef.current).toBe('')
expect(session.runtime.toolInputMapRef.current.size).toBe(0)
expect(session.getStatus()).toBe('thinking')
expect(session.getMessages().at(-1)).toEqual({
userMessage: 'Latest question',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
actions: [],
isStreaming: true,
id: 'msg-stream',
})
}
function expectFormattedHistoryUsed(): void {
expect(trimHistoryMock).toHaveBeenCalledWith(expectedChatHistory, 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith(expectedChatHistory, 'Latest question')
}
function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiAgentMock).toHaveBeenCalledWith({
agent: 'codex',
message: 'formatted:Latest question',
systemPrompt: 'OVERRIDE',
vaultPath: '/vault',
permissionMode: 'power_user',
callbacks: { stream: 'callbacks' },
})
}
describe('aiAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,12 +148,19 @@ describe('aiAgentSession', () => {
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
streamAiAgentMock.mockResolvedValue(undefined)
trackEventMock.mockClear()
})
async function expectLocalResponse(options: {
messageId: string
context: { agent: string; ready: boolean; vaultPath: string }
context: {
agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini'
ready: boolean
vaultPath: string
permissionMode: 'safe' | 'power_user'
}
prompt: { text: string; references?: [] }
reason: 'agent_unavailable' | 'missing_vault'
response: string
}) {
nextMessageIdMock.mockReturnValue(options.messageId)
@@ -114,20 +182,24 @@ describe('aiAgentSession', () => {
},
])
expect(streamAiAgentMock).not.toHaveBeenCalled()
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_blocked', {
agent: options.context.agent,
reason: options.reason,
})
}
it('ignores blank prompts and busy runtimes', async () => {
const idleRuntime = createRuntime()
await sendAgentMessage({
runtime: idleRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
context: { agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: ' ' },
})
const busyRuntime = createRuntime([], 'thinking')
await sendAgentMessage({
runtime: busyRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
context: { agent: 'codex', ready: true, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: 'Question' },
})
@@ -140,14 +212,16 @@ describe('aiAgentSession', () => {
const fallbackCases = [
{
messageId: 'msg-local',
context: { agent: 'codex', ready: true, vaultPath: '' },
context: { agent: 'codex', ready: true, vaultPath: '', permissionMode: 'safe' },
prompt: { text: 'Open a note' },
reason: 'missing_vault',
response: 'No vault loaded. Open a vault first.',
},
{
messageId: 'msg-missing',
context: { agent: 'codex', ready: false, vaultPath: '/vault' },
context: { agent: 'codex', ready: false, vaultPath: '/vault', permissionMode: 'safe' },
prompt: { text: 'Open a note', references: [] },
reason: 'agent_unavailable',
response:
'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
},
@@ -160,29 +234,18 @@ describe('aiAgentSession', () => {
it('starts a streaming session with formatted history and fresh refs', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const completedHistory: AiAgentMessage = {
id: 'msg-1',
userMessage: 'Previous question',
actions: [],
response: 'Previous answer',
}
const streamingHistory: AiAgentMessage = {
id: 'msg-2',
userMessage: 'Ignored streaming question',
actions: [],
isStreaming: true,
}
const { runtime, getMessages, getStatus } = createRuntime([
const session = createRuntime([
completedHistory,
streamingHistory,
])
await sendAgentMessage({
runtime,
runtime: session.runtime,
context: {
agent: 'codex',
ready: true,
vaultPath: '/vault',
permissionMode: 'power_user',
systemPromptOverride: 'OVERRIDE',
},
prompt: {
@@ -191,37 +254,15 @@ describe('aiAgentSession', () => {
},
})
expect(runtime.abortRef.current).toEqual({ aborted: false })
expect(runtime.responseAccRef.current).toBe('')
expect(runtime.toolInputMapRef.current.size).toBe(0)
expect(getStatus()).toBe('thinking')
expect(getMessages().at(-1)).toEqual({
userMessage: 'Latest question',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
actions: [],
isStreaming: true,
id: 'msg-stream',
})
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
], 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
], 'Latest question')
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiAgentMock).toHaveBeenCalledWith({
expectStreamingRuntimeState(session)
expectFormattedHistoryUsed()
expectStreamingRequest(session.runtime)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_message_sent', {
agent: 'codex',
message: 'formatted:Latest question',
systemPrompt: 'OVERRIDE',
vaultPath: '/vault',
callbacks: { stream: 'callbacks' },
permission_mode: 'power_user',
has_context: 1,
reference_count: 1,
history_message_count: 1,
})
})

View File

@@ -13,6 +13,7 @@ import {
import type { AgentFileCallbacks } from './aiAgentFileOperations'
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
import type { ToolInvocation } from './aiAgentMessageState'
import { trackAiAgentMessageBlocked, trackAiAgentMessageSent } from './productAnalytics'
import { streamAiAgent } from '../utils/streamAiAgent'
export interface AiAgentSessionRuntime {
@@ -39,6 +40,10 @@ function normalizePrompt(prompt: PendingUserPrompt): PendingUserPrompt {
}
}
function completedMessageCount(messages: AiAgentMessage[]): number {
return messages.filter((message) => !message.isStreaming && !message.localMarker).length
}
export async function sendAgentMessage({
runtime,
context,
@@ -50,11 +55,13 @@ export async function sendAgentMessage({
if (!normalizedPrompt.text || currentStatus === 'thinking' || currentStatus === 'tool-executing') return
if (!context.vaultPath) {
trackAiAgentMessageBlocked(context.agent, 'missing_vault')
appendLocalResponse(runtime.setMessages, normalizedPrompt, 'No vault loaded. Open a vault first.')
return
}
if (!context.ready) {
trackAiAgentMessageBlocked(context.agent, 'agent_unavailable')
appendLocalResponse(
runtime.setMessages,
normalizedPrompt,
@@ -63,6 +70,14 @@ export async function sendAgentMessage({
return
}
trackAiAgentMessageSent({
agent: context.agent,
permissionMode: context.permissionMode,
hasContext: !!context.systemPromptOverride,
referenceCount: normalizedPrompt.references?.length ?? 0,
historyMessageCount: completedMessageCount(runtime.messagesRef.current),
})
runtime.abortRef.current = { aborted: false }
runtime.responseAccRef.current = ''
runtime.toolInputMapRef.current = new Map()

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
const { detectFileOperationMock } = vi.hoisted(() => ({
const { detectFileOperationMock, trackEventMock } = vi.hoisted(() => ({
detectFileOperationMock: vi.fn(),
trackEventMock: vi.fn(),
}))
vi.mock('./aiAgentFileOperations', async (importOriginal) => ({
@@ -10,6 +11,10 @@ vi.mock('./aiAgentFileOperations', async (importOriginal) => ({
detectFileOperation: detectFileOperationMock,
}))
vi.mock('./telemetry', () => ({
trackEvent: trackEventMock,
}))
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
function createMessageStore(initialMessages: AiAgentMessage[]) {
@@ -37,6 +42,7 @@ function createStatusStore(initialStatus: AgentStatus = 'idle') {
describe('aiAgentStreamCallbacks', () => {
beforeEach(() => {
vi.clearAllMocks()
trackEventMock.mockClear()
})
it('handles the happy-path lifecycle and refreshes the vault at the end', () => {
@@ -85,6 +91,11 @@ describe('aiAgentStreamCallbacks', () => {
callbacks: fileCallbacks,
})
expect(fileCallbacks.onVaultChanged).toHaveBeenCalledTimes(1)
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_completed', {
agent: 'claude_code',
had_text: 1,
tool_count: 1,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
@@ -137,6 +148,12 @@ describe('aiAgentStreamCallbacks', () => {
callbacks.onError('boom')
expect(status.getStatus()).toBe('error')
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_failed', {
agent: 'claude_code',
error_kind: 'stream_error',
had_partial_response: 1,
tool_count: 0,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
@@ -183,6 +200,11 @@ describe('aiAgentStreamCallbacks', () => {
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_response_completed', {
agent,
had_text: 0,
tool_count: 0,
})
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',

View File

@@ -8,6 +8,10 @@ import {
type ToolInvocation,
} from './aiAgentMessageState'
import { getAiAgentDefinition, type AiAgentId } from './aiAgents'
import {
trackAiAgentResponseCompleted,
trackAiAgentResponseFailed,
} from './productAnalytics'
export interface StreamMutationContext {
agent: AiAgentId
@@ -39,6 +43,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
toolInputMapRef,
fileCallbacksRef,
} = context
let failureTracked = false
return {
onThinking: (chunk: string) => {
@@ -93,6 +98,8 @@ export function createStreamCallbacks(context: StreamMutationContext) {
setStatus('error')
const partial = responseAccRef.current
failureTracked = true
trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size)
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,
@@ -109,6 +116,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
setStatus('done')
const finalResponse = finalResponseText(responseAccRef.current, agent)
trackAiAgentResponseCompleted(agent, responseAccRef.current, toolInputMapRef.current.size, failureTracked)
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,

103
src/lib/productAnalytics.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { AiAgentId } from './aiAgents'
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
import { trackEvent } from './telemetry'
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
import type { FilePreviewKind } from '../utils/filePreview'
type TrackedPreviewKind = FilePreviewKind | 'unsupported'
type FilePreviewAction = 'copy_path' | 'open_external' | 'reveal'
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
const ALL_NOTES_VISIBILITY_CATEGORIES: ReadonlyArray<keyof AllNotesFileVisibility> = [
'pdfs',
'images',
'unsupported',
]
function trackedPreviewKind(previewKind: FilePreviewKind | null): TrackedPreviewKind {
return previewKind ?? 'unsupported'
}
function numericFlag(value: boolean): number {
return value ? 1 : 0
}
export function trackFilePreviewOpened(previewKind: FilePreviewKind | null): void {
trackEvent('file_preview_opened', {
preview_kind: trackedPreviewKind(previewKind),
})
}
export function trackFilePreviewAction(action: FilePreviewAction, previewKind: FilePreviewKind | null): void {
trackEvent('file_preview_action', {
action,
preview_kind: trackedPreviewKind(previewKind),
})
}
export function trackFilePreviewFailed(previewKind: FilePreviewKind): void {
trackEvent('file_preview_failed', { preview_kind: previewKind })
}
export function trackAllNotesVisibilityChanged(
previous: AllNotesFileVisibility,
next: AllNotesFileVisibility,
): void {
for (const category of ALL_NOTES_VISIBILITY_CATEGORIES) {
if (previous[category] === next[category]) continue
trackEvent('all_notes_visibility_changed', {
category,
enabled: numericFlag(next[category]),
})
}
}
export function trackAiAgentMessageBlocked(agent: AiAgentId, reason: AgentBlockedReason): void {
trackEvent('ai_agent_message_blocked', { agent, reason })
}
export function trackAiAgentMessageSent(params: {
agent: AiAgentId
permissionMode: AiAgentPermissionMode
hasContext: boolean
referenceCount: number
historyMessageCount: number
}): void {
trackEvent('ai_agent_message_sent', {
agent: params.agent,
permission_mode: params.permissionMode,
has_context: numericFlag(params.hasContext),
reference_count: params.referenceCount,
history_message_count: params.historyMessageCount,
})
}
export function trackAiAgentResponseCompleted(
agent: AiAgentId,
response: string,
toolCount: number,
skipped: boolean,
): void {
if (skipped) return
trackEvent('ai_agent_response_completed', {
agent,
had_text: numericFlag(response.trim().length > 0),
tool_count: toolCount,
})
}
export function trackAiAgentResponseFailed(agent: AiAgentId, response: string, toolCount: number): void {
trackEvent('ai_agent_response_failed', {
agent,
error_kind: 'stream_error',
had_partial_response: numericFlag(response.trim().length > 0),
tool_count: toolCount,
})
}
export function trackAiAgentPermissionModeChanged(agent: AiAgentId, permissionMode: AiAgentPermissionMode): void {
trackEvent('ai_agent_permission_mode_changed', {
agent,
permission_mode: permissionMode,
})
}