diff --git a/e2e/questdb b/e2e/questdb index 8bb20f7ce..e8387cdd1 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8bb20f7ce06a65a7c846816cb75458929ad7cdc2 +Subproject commit e8387cdd1352743f04b9d7541bfa80247f2386d1 diff --git a/package.json b/package.json index 778044fb1..3740eebce 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@styled-icons/remix-line": "10.46.0", "allotment": "^1.19.3", "animate.css": "3.7.2", + "bowser": "^2.14.1", "compare-versions": "^5.0.1", "core-js": "^3.22.8", "date-fns": "4.1.0", @@ -146,6 +147,7 @@ "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", + "fake-indexeddb": "^6.2.5", "globals": "^15.0.0", "husky": "^9.1.7", "jiti": "^2.6.1", diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx index 4d8ee9b3f..eddc82e6b 100644 --- a/src/components/CopyButton/index.tsx +++ b/src/components/CopyButton/index.tsx @@ -19,11 +19,13 @@ export const CopyButton = ({ text, iconOnly, size = "md", + onCopy, ...props }: { text: string iconOnly?: boolean size?: ButtonProps["size"] + onCopy?: () => void } & ButtonProps) => { const [copied, setCopied] = useState(false) const timeoutRef = useRef | null>(null) @@ -47,6 +49,7 @@ export const CopyButton = ({ e.stopPropagation() setCopied(true) timeoutRef.current = setTimeout(() => setCopied(false), 2000) + onCopy?.() }} {...(!iconOnly && { prefixIcon: , diff --git a/src/components/Drawer/index.tsx b/src/components/Drawer/index.tsx index 14a6ca6ef..44d4867af 100644 --- a/src/components/Drawer/index.tsx +++ b/src/components/Drawer/index.tsx @@ -11,6 +11,8 @@ import { Button } from "../Button" import { ContentWrapper } from "./content-wrapper" import { Panel } from "../../components/Panel" import { XIcon, ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" type DrawerProps = { mode?: "modal" | "side" @@ -143,10 +145,12 @@ export const Drawer = ({ const canGoForward = useSelector(selectors.console.canGoForwardInSidebar) const handleNavigateBack = () => { + void trackEvent(ConsoleEvent.SIDEBAR_NAVIGATE) dispatch(actions.console.goBackInSidebar()) } const handleNavigateForward = () => { + void trackEvent(ConsoleEvent.SIDEBAR_NAVIGATE) dispatch(actions.console.goForwardInSidebar()) } diff --git a/src/components/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx index 74ffc5d25..e8c64ba26 100644 --- a/src/components/ExplainQueryButton/index.tsx +++ b/src/components/ExplainQueryButton/index.tsx @@ -13,6 +13,8 @@ import { executeAIFlow, createExplainFlowConfig, } from "../../utils/executeAIFlow" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const KeyBinding = styled(Box).attrs({ alignItems: "center", gap: "0" })` color: ${({ theme }) => theme.color.pinkPrimary}; @@ -54,6 +56,7 @@ export const ExplainQueryButton = ({ } = useAIConversation() const handleExplainQuery = () => { + void trackEvent(ConsoleEvent.AI_EXPLAIN_QUERY) const currentModel = currentModelValue! const apiKey = apiKeyValue! diff --git a/src/components/FixQueryButton/index.tsx b/src/components/FixQueryButton/index.tsx index b79b552e9..525d81023 100644 --- a/src/components/FixQueryButton/index.tsx +++ b/src/components/FixQueryButton/index.tsx @@ -12,6 +12,8 @@ import { useAIConversation } from "../../providers/AIConversationProvider" import { extractErrorByQueryKey } from "../../scenes/Editor/utils" import type { ExecutionRefs } from "../../scenes/Editor/index" import { executeAIFlow, createFixFlowConfig } from "../../utils/executeAIFlow" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const FixButton = styled(Button)` gap: 1rem; @@ -34,6 +36,7 @@ export const FixQueryButton = () => { } = useAIConversation() const handleFixQuery = () => { + void trackEvent(ConsoleEvent.AI_FIX_QUERY) const conversationId = chatWindowState.activeConversationId! const conversation = getConversationMeta(conversationId)! diff --git a/src/components/SetupAIAssistant/ConfigurationModal.tsx b/src/components/SetupAIAssistant/ConfigurationModal.tsx index d48f22ab5..3a7e4d5fa 100644 --- a/src/components/SetupAIAssistant/ConfigurationModal.tsx +++ b/src/components/SetupAIAssistant/ConfigurationModal.tsx @@ -21,6 +21,8 @@ import { OpenAIIcon } from "./OpenAIIcon" import { AnthropicIcon } from "./AnthropicIcon" import { BrainIcon } from "./BrainIcon" import { theme } from "../../theme" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const ModalContent = styled.div` display: flex; @@ -753,6 +755,10 @@ export const ConfigurationModal = ({ const handleComplete = () => { if (!selectedProvider || enabledModels.length === 0) return + void trackEvent(ConsoleEvent.AI_PROVIDER_CONFIGURE, { + type: selectedProvider, + }) + const selectedModel = enabledModels.find( (m) => MODEL_OPTIONS.find((mo) => mo.value === m)?.default, @@ -812,6 +818,7 @@ export const ConfigurationModal = ({ setEnabledModels(defaultModels) } setError(null) + void trackEvent(ConsoleEvent.AI_CONFIGURATION_VALIDATE) return true } catch (err) { const errorMessage = diff --git a/src/components/SetupAIAssistant/ModelDropdown.tsx b/src/components/SetupAIAssistant/ModelDropdown.tsx index 063d53845..ee0466d11 100644 --- a/src/components/SetupAIAssistant/ModelDropdown.tsx +++ b/src/components/SetupAIAssistant/ModelDropdown.tsx @@ -13,6 +13,8 @@ import { OpenAIIcon } from "./OpenAIIcon" import { AnthropicIcon } from "./AnthropicIcon" import { BrainIcon } from "./BrainIcon" import { Tooltip } from "../Tooltip" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const ExpandUpDown = () => ( { }, [enabledModelValues]) const handleModelSelect = (modelValue: string) => { + void trackEvent(ConsoleEvent.AI_MODEL_CHANGE) updateSettings(StoreKey.AI_ASSISTANT_SETTINGS, { ...aiAssistantSettings, selectedModel: modelValue, diff --git a/src/components/SetupAIAssistant/SettingsModal.tsx b/src/components/SetupAIAssistant/SettingsModal.tsx index be389a38f..f46d1c6df 100644 --- a/src/components/SetupAIAssistant/SettingsModal.tsx +++ b/src/components/SetupAIAssistant/SettingsModal.tsx @@ -29,6 +29,8 @@ import type { AiAssistantSettings } from "../../providers/LocalStorageProvider/t import { ForwardRef } from "../ForwardRef" import { Badge, BadgeType } from "../../components/Badge" import { CheckboxCircle } from "@styled-icons/remix-fill" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const ModalContent = styled.div` display: flex; @@ -657,6 +659,7 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { ) const handleRemoveApiKey = useCallback((provider: Provider) => { + void trackEvent(ConsoleEvent.AI_SETTINGS_API_KEY_REMOVE) // Remove API key from local state only // Settings will be persisted when Save Settings is clicked setApiKeys((prev) => ({ ...prev, [provider]: "" })) @@ -668,6 +671,7 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { const handleModelToggle = useCallback( (provider: Provider, modelValue: string) => { + void trackEvent(ConsoleEvent.AI_SETTINGS_MODEL_TOGGLE) setEnabledModels((prev) => { const current = prev[provider] const isEnabled = current.includes(modelValue) @@ -684,6 +688,9 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { const handleSchemaAccessChange = useCallback( (provider: Provider, checked: boolean) => { + if (!checked) { + void trackEvent(ConsoleEvent.AI_SETTINGS_SCHEMA_ACCESS_REMOVE) + } setGrantSchemaAccess((prev) => ({ ...prev, [provider]: checked })) }, [], @@ -908,6 +915,9 @@ export const SettingsModal = ({ open, onOpenChange }: SettingsModalProps) => { { + void trackEvent( + ConsoleEvent.AI_SETTINGS_API_KEY_EDIT, + ) inputRef.current?.focus() }} title="Edit API key" diff --git a/src/components/SetupAIAssistant/index.tsx b/src/components/SetupAIAssistant/index.tsx index b942df5b2..41769a9f4 100644 --- a/src/components/SetupAIAssistant/index.tsx +++ b/src/components/SetupAIAssistant/index.tsx @@ -8,6 +8,8 @@ import { ConfigurationModal } from "./ConfigurationModal" import { SettingsModal } from "./SettingsModal" import { ModelDropdown } from "./ModelDropdown" import { useAIStatus } from "../../providers/AIStatusProvider" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const SettingsButton = styled(Button)` padding: 0.6rem; @@ -26,12 +28,14 @@ export const SetupAIAssistant = () => { const handleSettingsClick = () => { if (isConfigured) { + void trackEvent(ConsoleEvent.AI_SETTINGS_OPEN) setSettingsModalOpen(true) } else { if (showPromo) { setShowPromo(false) setConfigModalOpen(true) } else { + void trackEvent(ConsoleEvent.AI_PROMO_OPEN) setShowPromo(true) } } @@ -60,6 +64,7 @@ export const SetupAIAssistant = () => { showPromo={showPromo} setShowPromo={setShowPromo} onSetupClick={() => { + void trackEvent(ConsoleEvent.AI_CONFIGURATION_OPEN) setShowPromo(false) setConfigModalOpen(true) }} diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 7283ce8f4..3273df4ea 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -24,6 +24,8 @@ import { copyToClipboard } from "../../utils/copyToClipboard" import { unescapeHtml } from "../../utils/escapeHtml" import { toast } from "../../components" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const hashString = (str) => { let hash = 0 @@ -1755,6 +1757,7 @@ export function grid(rootElement, _paginationFn, id) { function copyActiveCellToClipboard() { if (focusedCell) { + void trackEvent(ConsoleEvent.GRID_CELL_COPY) if (activeCellPulseClearTimer) { clearTimeout(activeCellPulseClearTimer) } diff --git a/src/modules/ConsoleEventTracker/db.test.ts b/src/modules/ConsoleEventTracker/db.test.ts new file mode 100644 index 000000000..df7a2c1c1 --- /dev/null +++ b/src/modules/ConsoleEventTracker/db.test.ts @@ -0,0 +1,174 @@ +import "fake-indexeddb/auto" +import { vi, describe, it, expect, beforeEach } from "vitest" + +// Stub browser globals before db.ts singleton is created +vi.hoisted(() => { + const storage: Record = {} + globalThis.localStorage = { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((k) => delete storage[k]) + }, + get length() { + return Object.keys(storage).length + }, + key: () => null, + } as Storage + + // Set window = globalThis so that window.indexedDB works after fake-indexeddb polyfills it + globalThis.window = globalThis as unknown as Window & typeof globalThis + ;(globalThis as Record).location = { + href: "http://localhost/", + } + ;(globalThis as Record).history = { + replaceState: () => {}, + } +}) + +import { db } from "../../store/db" +import { + putEvent, + getEntriesAfter, + deleteEntriesUpTo, + trimToMaxRows, +} from "./db" + +beforeEach(async () => { + await db.events.clear() +}) + +describe("putEvent", () => { + it("stores event with correct fields", async () => { + await putEvent("query.exec") + const entries = await db.events.toArray() + expect(entries).toHaveLength(1) + expect(entries[0].name).toBe("query.exec") + expect(entries[0].created).toBeGreaterThan(0) + expect(entries[0].props).toBeUndefined() + }) + + it("stores event with props", async () => { + await putEvent("sidebar.change", '{"panel":"tables"}') + const entries = await db.events.toArray() + expect(entries).toHaveLength(1) + expect(entries[0].props).toBe('{"panel":"tables"}') + }) + + it("stores multiple events", async () => { + await putEvent("query.exec") + await putEvent("chart.draw") + await putEvent("ai.explain") + await putEvent("sidebar.change") + await putEvent("query.exec") + expect(await db.events.count()).toBe(5) + }) +}) + +describe("getEntriesAfter", () => { + it("returns entries with created > cursor", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + + const entries = await getEntriesAfter(100, 10) + expect(entries).toHaveLength(2) + expect(entries[0].name).toBe("b") + expect(entries[1].name).toBe("c") + }) + + it("returns all entries when cursor is 0", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + + const entries = await getEntriesAfter(0, 10) + expect(entries).toHaveLength(3) + }) + + it("respects limit", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + + const entries = await getEntriesAfter(0, 2) + expect(entries).toHaveLength(2) + expect(entries[0].name).toBe("a") + expect(entries[1].name).toBe("b") + }) + + it("returns empty array when no entries match", async () => { + await db.events.add({ created: 100, name: "a" }) + const entries = await getEntriesAfter(100, 10) + expect(entries).toHaveLength(0) + }) + + it("returns entries sorted by created", async () => { + await db.events.add({ created: 300, name: "c" }) + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + + const entries = await getEntriesAfter(0, 10) + expect(entries.map((e) => e.name)).toEqual(["a", "b", "c"]) + }) +}) + +describe("deleteEntriesUpTo", () => { + it("deletes entries with created <= value", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + + const result = await deleteEntriesUpTo(200) + expect(result).toBe(2) + + const remaining = await db.events.toArray() + expect(remaining).toHaveLength(1) + expect(remaining[0].name).toBe("c") + }) + + it("is a no-op on empty table", async () => { + const result = await deleteEntriesUpTo(100) + expect(result).toBe(0) + expect(await db.events.count()).toBe(0) + }) +}) + +describe("trimToMaxRows", () => { + it("deletes oldest rows when count exceeds max", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + await db.events.add({ created: 400, name: "d" }) + await db.events.add({ created: 500, name: "e" }) + + const result = await trimToMaxRows(3) + expect(result).toBe(true) + + const remaining = await db.events.toArray() + expect(remaining).toHaveLength(3) + expect(remaining.map((e) => e.name)).toEqual( + expect.arrayContaining(["c", "d", "e"]), + ) + }) + + it("is a no-op when count is within limit", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + + const result = await trimToMaxRows(5) + expect(result).toBe(true) + expect(await db.events.count()).toBe(2) + }) + + it("is a no-op on empty table", async () => { + const result = await trimToMaxRows(5) + expect(result).toBe(true) + expect(await db.events.count()).toBe(0) + }) +}) diff --git a/src/modules/ConsoleEventTracker/db.ts b/src/modules/ConsoleEventTracker/db.ts new file mode 100644 index 000000000..d89c54c22 --- /dev/null +++ b/src/modules/ConsoleEventTracker/db.ts @@ -0,0 +1,70 @@ +import { db } from "../../store/db" + +export type TelemetryEvent = { + id?: number + created: number + name: string + props?: string +} + +export const putEvent = async ( + name: string, + props?: string, +): Promise => { + try { + await db.events.add({ created: Date.now(), name, props }) + return true + } catch (e) { + console.error("Failed to store telemetry event", e) + return false + } +} + +export const getEntryCount = async (): Promise => { + try { + return await db.events.count() + } catch (e) { + console.error("Failed to get telemetry event count", e) + return 0 + } +} + +export const getEntriesAfter = async ( + cursor: number, + limit: number, +): Promise => { + try { + return await db.events.where("created").above(cursor).limit(limit).toArray() + } catch (e) { + console.error("Failed to read telemetry events from IndexedDB", e) + return [] + } +} + +export const deleteEntriesUpTo = async (created: number): Promise => { + try { + const deleteCount = await db.events + .where("created") + .belowOrEqual(created) + .delete() + return deleteCount + } catch (e) { + console.error("Failed to delete sent telemetry events", e) + return -1 + } +} + +export const trimToMaxRows = async (maxRows: number): Promise => { + try { + const count = await db.events.count() + if (count <= maxRows) return true + const overflow = count - maxRows + const oldest = await db.events.orderBy("created").limit(overflow).toArray() + const ids = oldest.map((e) => e.id!) + await db.events.bulkDelete(ids) + return true + } catch (e) { + console.error("Failed to trim telemetry events in IndexedDB", e) + return false + } +} diff --git a/src/modules/ConsoleEventTracker/events.ts b/src/modules/ConsoleEventTracker/events.ts new file mode 100644 index 000000000..4ddb963a9 --- /dev/null +++ b/src/modules/ConsoleEventTracker/events.ts @@ -0,0 +1,87 @@ +export enum ConsoleEvent { + SCHEMA_OPEN = "schema.open", + SCHEMA_CLOSE = "schema.close", + SCHEMA_NAME_COPY = "schema.name_copy", + SCHEMA_FILTER = "schema.filter", + SEARCH_EXECUTE = "search.execute", + SEARCH_OPEN = "search.open", + + EDITOR_RUN_ALL = "editor.run_all", + EDITOR_GLYPH_RUN = "editor.glyph_run", + EDITOR_GLYPH_CONTEXT_OPEN = "editor.glyph_context_open", + EDITOR_GLYPH_CONTEXT_QUERY_PLAN = "editor.glyph_context_query_plan", + + GRID_CSV_DOWNLOAD = "grid.csv_download", + GRID_PARQUET_DOWNLOAD = "grid.parquet_download", + GRID_MARKDOWN_COPY = "grid.markdown_copy", + GRID_REFRESH = "grid.refresh", + GRID_LAYOUT_RESET = "grid.layout_reset", + GRID_COLUMN_FREEZE = "grid.column_freeze", + GRID_CELL_COPY = "grid.cell_copy", + + IMPORT_FILE_UPLOAD = "import.file_upload", + IMPORT_ADD_SCHEMA = "import.add_schema", + IMPORT_CHANGE_WRITE_MODE = "import.change_write_mode", + IMPORT_SETTINGS_CHANGE = "import.settings_change", + IMPORT_DETAILS_OPEN = "import.details_open", + IMPORT_RESULTS_OPEN = "import.results_open", + + AI_CHAT_OPEN = "ai.chat_open", + AI_CHAT_CLOSE = "ai.chat_close", + AI_CHAT_SEND = "ai.chat_send", + AI_CHAT_ACCEPT = "ai.chat_accept", + AI_EDITOR_SUGGESTION_ACCEPT = "ai.editor.suggestion_accept", + AI_CHAT_REJECT = "ai.chat_reject", + AI_CHAT_DELETE = "ai.chat_delete", + AI_CHAT_RENAME = "ai.chat_rename", + AI_HISTORY_OPEN = "ai.history_open", + AI_EXPLAIN_QUERY = "ai.explain_query", + AI_FIX_QUERY = "ai.fix_query", + AI_GLYPH_CLICK = "ai.glyph_click", + AI_CONTEXT_BADGE_CLICK = "ai.context_badge_click", + AI_PROVIDER_CONFIGURE = "ai.provider_configure", + AI_PROMO_OPEN = "ai.promo_open", + AI_CONFIGURATION_OPEN = "ai.configuration_open", + AI_CONFIGURATION_VALIDATE = "ai.configuration_validate", + AI_SETTINGS_OPEN = "ai.settings_open", + AI_SETTINGS_MODEL_TOGGLE = "ai.settings_model_toggle", + AI_SETTINGS_API_KEY_REMOVE = "ai.settings_api_key_remove", + AI_SETTINGS_API_KEY_EDIT = "ai.settings_api_key_edit", + AI_SETTINGS_SCHEMA_ACCESS_REMOVE = "ai.settings_schema_access_remove", + AI_MODEL_CHANGE = "ai.model_change", + + PANEL_BOTTOM_SWITCH = "panel.bottom.switch", + + SCHEMA_FILTER_SUSPENDED = "schema.filter_suspended", + SCHEMA_CONTEXT_MENU_OPEN = "schema.context_menu_open", + SCHEMA_CONTEXT_RESUME_WAL = "schema.context_resume_wal", + SCHEMA_CONTEXT_COPY_DDL = "schema.context_copy_ddl", + SCHEMA_CONTEXT_EXPLAIN = "schema.context_explain", + SCHEMA_COPY_MULTIPLE = "schema.copy_multiple", + + TABLE_DETAILS_OPEN = "table_details.open", + TABLE_DETAILS_SCHEMA_EXPLAIN = "table_details.schema_explain", + TABLE_DETAILS_ASK_AI = "table_details.ask_ai", + TABLE_DETAILS_COPY_DDL = "table_details.copy_ddl", + + SIDEBAR_NAVIGATE = "sidebar.navigate", + + TAB_IMPORT = "tab.import", + TAB_EXPORT = "tab.export", + TAB_RENAME = "tab.rename", + TAB_HISTORY_OPEN = "tab.history_open", + TAB_HISTORY_RESTORE = "tab.history_restore", + TAB_HISTORY_CLEAR = "tab.history_clear", + + QUERY_LOG_OPEN = "query_log.open", + QUERY_LOG_QUERY_COPY = "query_log.query_copy", + QUERY_LOG_CLEAR = "query_log.clear", + + METRIC_ADD = "metric.add", + METRIC_TAB_OPEN = "metric.tab_open", + + HELP_OPEN = "help.open", + HELP_FEEDBACK_SUBMIT = "help.feedback_submit", + + NEWS_OPEN = "news.open", +} diff --git a/src/modules/ConsoleEventTracker/index.test.ts b/src/modules/ConsoleEventTracker/index.test.ts new file mode 100644 index 000000000..f5e506979 --- /dev/null +++ b/src/modules/ConsoleEventTracker/index.test.ts @@ -0,0 +1,177 @@ +import "fake-indexeddb/auto" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" + +// Stub browser globals before db.ts singleton is created +vi.hoisted(() => { + const storage: Record = {} + globalThis.localStorage = { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((k) => delete storage[k]) + }, + get length() { + return Object.keys(storage).length + }, + key: () => null, + } as Storage + + globalThis.window = globalThis as unknown as Window & typeof globalThis + ;(globalThis as Record).location = { + href: "http://localhost/", + } + ;(globalThis as Record).history = { + replaceState: () => {}, + } +}) + +import { db } from "../../store/db" + +const { mockStartPipeline, mockStopPipeline } = vi.hoisted(() => ({ + mockStartPipeline: vi.fn(), + mockStopPipeline: vi.fn(), +})) + +vi.mock("./sendPipeline", () => ({ + startPipeline: mockStartPipeline, + stopPipeline: mockStopPipeline, +})) + +import { trackEvent, start, stop } from "./index" + +const mockConfig = { + id: "test-id", + version: "1.0.0", + os: "linux", + package: "docker", + enabled: true, + instance_name: "", + instance_type: "", + instance_desc: "", +} + +beforeEach(async () => { + vi.clearAllMocks() + await db.events.clear() + // Reset the `started` flag by calling stop + stop() +}) + +afterEach(() => { + stop() +}) + +describe("trackEvent", () => { + it("writes event to IDB when started", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + await start(mockConfig) + + await trackEvent("query.exec") + const entries = await db.events.toArray() + expect(entries).toHaveLength(1) + expect(entries[0].name).toBe("query.exec") + expect(entries[0].created).toBeGreaterThan(0) + + import.meta.env.MODE = originalMode + }) + + it("writes event with props", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + await start(mockConfig) + + await trackEvent("sidebar.change", { panel: "tables" }) + const entries = await db.events.toArray() + expect(entries).toHaveLength(1) + expect(entries[0].props).toBe('{"panel":"tables"}') + + import.meta.env.MODE = originalMode + }) + + it("is a no-op before start", async () => { + await trackEvent("query.exec") + const entries = await db.events.toArray() + expect(entries).toHaveLength(0) + }) + + it("silently drops on IDB write error", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + await start(mockConfig) + + // Close db to simulate unavailability + db.close() + await expect(trackEvent("query.exec")).resolves.not.toThrow() + // Reopen for other tests + await db.open() + + import.meta.env.MODE = originalMode + }) +}) + +describe("start", () => { + it("calls startPipeline with config", async () => { + // Override import.meta.env.MODE for this test + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + + await start(mockConfig) + expect(mockStartPipeline).toHaveBeenCalledWith(mockConfig) + + import.meta.env.MODE = originalMode + }) + + it("is idempotent", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + + await start(mockConfig) + await start(mockConfig) + expect(mockStartPipeline).toHaveBeenCalledTimes(1) + + import.meta.env.MODE = originalMode + }) + + it("does not start in development mode", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "development" + + await start(mockConfig) + expect(mockStartPipeline).not.toHaveBeenCalled() + + import.meta.env.MODE = originalMode + }) + + it("trims overflow rows on startup, keeping newest", async () => { + const originalMode = import.meta.env.MODE + import.meta.env.MODE = "production" + + // Insert 5 rows; module MAX_EVENTS is 10_000 but we can verify + // the trimToMaxRows call by inserting rows and checking behavior. + // We'll add rows and verify the oldest are removed. + for (let i = 1; i <= 5; i++) { + await db.events.add({ created: i * 100, name: `event-${i}` }) + } + + await start(mockConfig) + + // With MAX_EVENTS=10_000, 5 rows is under the limit — all kept + const entries = await db.events.toArray() + expect(entries).toHaveLength(5) + + import.meta.env.MODE = originalMode + }) +}) + +describe("stop", () => { + it("calls stopPipeline", () => { + stop() + expect(mockStopPipeline).toHaveBeenCalled() + }) +}) diff --git a/src/modules/ConsoleEventTracker/index.ts b/src/modules/ConsoleEventTracker/index.ts new file mode 100644 index 000000000..fff6899f0 --- /dev/null +++ b/src/modules/ConsoleEventTracker/index.ts @@ -0,0 +1,39 @@ +import * as telemetryDb from "./db" +import { startPipeline, stopPipeline } from "./sendPipeline" +import type { TelemetryConfigShape } from "../../store/Telemetry/types" + +const MAX_EVENTS = 10_000 + +let started = false + +export const trackEvent = async ( + name: string, + payload?: Record, +): Promise => { + if (!started) return + try { + const props = payload ? JSON.stringify(payload) : undefined + await telemetryDb.putEvent(name, props) + } catch { + console.error("Could not track event in IndexedDB:", name) + } +} + +export const start = async (config: TelemetryConfigShape): Promise => { + if (started) return + if ( + import.meta.env.MODE === "development" && + !import.meta.env.VITE_TELEMETRY_DEV + ) + return + started = true + + await telemetryDb.trimToMaxRows(MAX_EVENTS) + + startPipeline(config) +} + +export const stop = (): void => { + stopPipeline() + started = false +} diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts new file mode 100644 index 000000000..38a08457f --- /dev/null +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -0,0 +1,499 @@ +import "fake-indexeddb/auto" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import type { MockInstance } from "vitest" +import type { _internals as InternalsType } from "./sendPipeline" +import type { db as DbType } from "../../store/db" + +// Stub browser globals before db.ts singleton is created +vi.hoisted(() => { + const storage: Record = {} + globalThis.localStorage = { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((k) => delete storage[k]) + }, + get length() { + return Object.keys(storage).length + }, + key: () => null, + } as Storage + + globalThis.window = globalThis as unknown as Window & typeof globalThis + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36", + userAgentData: { + platform: "macOS", + brands: [{ brand: "Google Chrome", version: "122" }], + }, + }, + }) + ;(globalThis as Record).location = { + href: "http://localhost/", + } + ;(globalThis as Record).history = { + replaceState: () => {}, + } +}) + +vi.mock("../../consts", () => ({ + API: "https://test.questdb.io", +})) + +let _internals: typeof InternalsType +let stopPipeline: () => void +let db: typeof DbType + +let fetchSpy: MockInstance< + [input: RequestInfo | URL, init?: RequestInit], + Promise +> + +const mockConfig = { + id: "test-id", + version: "1.0.0", + os: "linux", + package: "docker", + enabled: true, + instance_name: "", + instance_type: "", + instance_desc: "", +} + +const mockCheckLatestResponse = (lastUpdated: number | null) => + new Response( + JSON.stringify({ + lastUpdated: + lastUpdated !== null ? new Date(lastUpdated).toISOString() : null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + +const mockSendResponse = (status: number) => new Response(null, { status }) + +// After a successful send+cleanup, run() exits (one batch per run). +const mockFullCycle = () => { + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // checkLatest + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries +} + +let originalBackoff: typeof _internals.backoff + +beforeEach(async () => { + vi.restoreAllMocks() + vi.resetModules() + ;({ db } = await import("../../store/db")) + ;({ _internals, stopPipeline } = await import("./sendPipeline")) + + await db.events.clear() + _internals.resetState() + fetchSpy = vi + .spyOn(global, "fetch") + .mockRejectedValue(new Error("unmocked fetch")) + + // Replace backoff with a no-op so tests run instantly + originalBackoff = _internals.backoff + _internals.backoff = async () => {} + + vi.spyOn(globalThis.localStorage, "getItem").mockReturnValue("test-client-id") + vi.spyOn(globalThis.localStorage, "setItem").mockImplementation(() => {}) +}) + +afterEach(() => { + stopPipeline?.() + _internals.backoff = originalBackoff + vi.restoreAllMocks() +}) + +describe("run — happy path", () => { + it("sends events and cleans up IDB", async () => { + await db.events.add({ created: 100, name: "query.exec" }) + await db.events.add({ created: 200, name: "chart.draw" }) + + mockFullCycle() + + _internals.setConfig(mockConfig) + await _internals.run() + + // checkLatest + sendEntries + expect(fetchSpy).toHaveBeenCalledTimes(2) + + const sendCall = fetchSpy.mock.calls[1] + const body = JSON.parse(sendCall[1]?.body as string) as { + id: string + client_id: string + events: Array<{ created: number; name: string }> + } + expect(body.events).toHaveLength(2) + expect(body.id).toBe("test-id") + expect(body.client_id).toBe("test-client-id") + expect(await db.events.count()).toBe(0) + }) + + it("cursor filtering: only sends events after cursor", async () => { + await db.events.add({ created: 100, name: "a" }) + await db.events.add({ created: 200, name: "b" }) + await db.events.add({ created: 300, name: "c" }) + + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(200)) + .mockResolvedValueOnce(mockSendResponse(200)) + .mockResolvedValueOnce(mockCheckLatestResponse(300)) // 2nd pass, empty + + _internals.setConfig(mockConfig) + await _internals.run() + + const sendCall = fetchSpy.mock.calls[1] + const body = JSON.parse(sendCall[1]?.body as string) as { + events: Array<{ created: number; name: string }> + } + expect(body.events).toHaveLength(1) + expect(body.events[0].name).toBe("c") + }) + + it("empty IDB: only calls checkLatest once", async () => { + fetchSpy.mockResolvedValueOnce(mockCheckLatestResponse(0)) + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it("sends events without browser metadata when navigator is unavailable", async () => { + await db.events.add({ created: 100, name: "query.exec" }) + + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ) + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: undefined, + }) + + try { + mockFullCycle() + _internals.setConfig(mockConfig) + await _internals.run() + + expect(fetchSpy).toHaveBeenCalledTimes(2) + const sendCall = fetchSpy.mock.calls[1] + const body = JSON.parse(sendCall[1]?.body as string) as Record< + string, + unknown + > + + expect(body).not.toHaveProperty("client_os") + expect(body).not.toHaveProperty("browser") + expect(body).not.toHaveProperty("browser_version") + expect(await db.events.count()).toBe(0) + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator) + } + } + }) +}) + +describe("run — checkLatest backoff", () => { + it("retries checkLatest on failure then succeeds", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockRejectedValueOnce(new Error("Network error")) // checkLatest fail + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // checkLatest ok + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(fetchSpy).toHaveBeenCalledTimes(3) + expect(await db.events.count()).toBe(0) + }) + + it("checkLatest 4xx retries with backoff", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockResolvedValueOnce(new Response(null, { status: 403 })) // checkLatest 4xx + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // checkLatest ok + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(fetchSpy).toHaveBeenCalledTimes(3) + expect(await db.events.count()).toBe(0) + }) + + it("checkLatest NaN cursor retries with backoff", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockResolvedValueOnce( + new Response(JSON.stringify({ lastUpdated: "not-a-number" }), { + status: 200, + }), + ) // checkLatest NaN → treated as failure + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // checkLatest ok + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(fetchSpy).toHaveBeenCalledTimes(3) + expect(await db.events.count()).toBe(0) + }) +}) + +describe("run — sendEntries backoff (independent from checkLatest)", () => { + it("sendEntries failure restarts from checkLatest", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 1st checkLatest ok + .mockRejectedValueOnce(new Error("Network error")) // sendEntries fail + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 2nd checkLatest ok (restart) + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(_internals.sendAttempt).toBe(0) + expect(fetchSpy).toHaveBeenCalledTimes(4) + expect(await db.events.count()).toBe(0) + }) + + it("sendEntries 5xx restarts from checkLatest", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 1st checkLatest + .mockResolvedValueOnce(mockSendResponse(500)) // sendEntries 5xx + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 2nd checkLatest + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 3rd checkLatest, empty + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.sendAttempt).toBe(0) + expect(await db.events.count()).toBe(0) + }) + + it("sendEntries 4xx retries with backoff", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 1st checkLatest + .mockResolvedValueOnce(mockSendResponse(400)) // sendEntries 4xx + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 2nd checkLatest + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 3rd checkLatest, empty + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.sendAttempt).toBe(0) + expect(await db.events.count()).toBe(0) + }) + + it("checkLatest failures don't affect sendEntries backoff", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy + .mockRejectedValueOnce(new Error("check fail")) // checkLatest fail + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // checkLatest ok + .mockResolvedValueOnce(mockSendResponse(200)) // sendEntries ok + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(_internals.sendAttempt).toBe(0) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) +}) + +describe("run — IDB cleanup failure", () => { + it("IDB cleanup failure does not affect backoff", async () => { + await db.events.add({ created: 100, name: "a" }) + + // 1st pass: send ok, cleanup fails → entry stays in IDB + // 2nd pass: send ok (re-sent), cleanup ok → entry removed + // 3rd pass: checkLatest → empty → break + fetchSpy + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 1st checkLatest + .mockResolvedValueOnce(mockSendResponse(200)) // 1st sendEntries + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 2nd checkLatest + .mockResolvedValueOnce(mockSendResponse(200)) // 2nd sendEntries (re-send) + .mockResolvedValueOnce(mockCheckLatestResponse(0)) // 3rd checkLatest, empty + + // Sabotage IDB cleanup only on first call + const deleteSpy = vi + .spyOn(await import("./db"), "deleteEntriesUpTo") + .mockResolvedValueOnce(-1) + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(_internals.checkAttempt).toBe(0) + expect(_internals.sendAttempt).toBe(0) + + deleteSpy.mockRestore() + }) +}) + +describe("run — stopPipeline", () => { + it("stops retrying when stopPipeline is called", async () => { + await db.events.add({ created: 100, name: "a" }) + + // checkLatest hangs until we release it + let rejectFetch: (reason: Error) => void = () => {} + fetchSpy.mockImplementation( + () => + new Promise((_, reject) => { + rejectFetch = reject + }), + ) + + _internals.setConfig(mockConfig) + const runPromise = _internals.run() + + // Let run() proceed past backoff(0) and reach the hanging fetch + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + // Now stop — run() is suspended inside checkLatest + stopPipeline() + // Unblock the fetch so the catch fires and the loop sees stopped=true + rejectFetch(new Error("aborted")) + await runPromise + + expect(_internals.stopped).toBe(true) + }) +}) + +describe("run — ongoing guard", () => { + it("second run() is a no-op while first is in progress", async () => { + await db.events.add({ created: 100, name: "a" }) + + // First checkLatest hangs until we resolve it + let resolveFirst: (value: Response) => void = () => {} + fetchSpy.mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r + }), + ) + + _internals.setConfig(mockConfig) + const firstRun = _internals.run() + + // Second run should return immediately (ongoing guard) + await _internals.run() + + // Resolve first run's checkLatest, then let it complete + fetchSpy.mockResolvedValueOnce(mockSendResponse(200)) + resolveFirst(mockCheckLatestResponse(0)) + await firstRun + + // Only the first run made fetch calls + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(await db.events.count()).toBe(0) + }) +}) + +describe("run — no config", () => { + it("returns immediately without config", async () => { + await _internals.run() + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("returns immediately without config.id", async () => { + _internals.setConfig({ ...mockConfig, id: "" }) + await _internals.run() + expect(fetchSpy).not.toHaveBeenCalled() + }) +}) + +describe("run — max attempts", () => { + it("kills pipeline after MAX_RETRIES consecutive checkLatest failures", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy.mockRejectedValue(new Error("Network error")) + + _internals.setConfig(mockConfig) + await _internals.run() + + expect(fetchSpy).toHaveBeenCalledTimes(_internals.MAX_RETRIES + 1) + expect(_internals.stopped).toBe(true) + }) + + it("kills pipeline after MAX_RETRIES consecutive sendEntries failures", async () => { + await db.events.add({ created: 100, name: "a" }) + + fetchSpy.mockImplementation((input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url + if (url.includes("console-events-config")) { + return Promise.resolve( + new Response(JSON.stringify({ lastUpdated: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + } + return Promise.reject(new Error("send failed")) + }) + + _internals.setConfig(mockConfig) + await _internals.run() + + // Each iteration: checkLatest + sendEntries + expect(fetchSpy).toHaveBeenCalledTimes((_internals.MAX_RETRIES + 1) * 2) + expect(_internals.stopped).toBe(true) + }) +}) + +describe("getClientId", () => { + it("returns existing client ID from localStorage", () => { + vi.restoreAllMocks() + vi.spyOn(globalThis.localStorage, "getItem").mockReturnValue( + "existing-uuid", + ) + expect(_internals.getClientId()).toBe("existing-uuid") + }) + + it("generates and stores new UUID when absent", () => { + vi.restoreAllMocks() + vi.spyOn(globalThis.localStorage, "getItem").mockReturnValue(null) + const setItemSpy = vi.spyOn(globalThis.localStorage, "setItem") + + const clientId = _internals.getClientId() + + expect(clientId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + expect(setItemSpy).toHaveBeenCalledWith("client.id", clientId) + }) +}) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.ts b/src/modules/ConsoleEventTracker/sendPipeline.ts new file mode 100644 index 000000000..275404f4c --- /dev/null +++ b/src/modules/ConsoleEventTracker/sendPipeline.ts @@ -0,0 +1,223 @@ +import Bowser from "bowser" +import { API as DEFAULT_API } from "../../consts" + +const API: string = + (import.meta.env.VITE_TELEMETRY_DEV as string) || DEFAULT_API +import { StoreKey } from "../../utils/localStorage/types" +import * as telemetryDb from "./db" +import type { TelemetryConfigShape } from "../../store/Telemetry/types" + +const MAX_BATCH_SIZE = 1_000 +const MAX_RETRIES = 9 +const BASE_DELAY = 1_000 + +let config: TelemetryConfigShape | null = null +let intervalId: ReturnType | null = null + +const getClientId = (): string => { + try { + let clientId = localStorage.getItem(StoreKey.CLIENT_ID) + if (!clientId) { + clientId = crypto.randomUUID() + localStorage.setItem(StoreKey.CLIENT_ID, clientId) + } + return clientId + } catch { + // localStorage unavailable — session-only ID + return crypto.randomUUID() + } +} + +const checkLatest = async ( + id: string, + clientId: string, +): Promise<{ ok: boolean; status: number; cursor: number }> => { + const params = new URLSearchParams({ id, client_id: clientId }) + const response = await fetch(`${API}/console-events-config?${params}`) + + if (!response.ok) { + return { ok: false, status: response.status, cursor: 0 } + } + + const data = (await response.json()) as { lastUpdated?: string } + const cursor = data.lastUpdated ? new Date(data.lastUpdated).getTime() : 0 + if (isNaN(cursor)) { + return { ok: false, status: 422, cursor: 0 } + } + return { ok: true, status: response.status, cursor } +} + +const getBrowserInfo = (): { + browser?: string + browser_version?: string + client_os?: string +} => { + if (typeof navigator === "undefined") return {} + + const parsed = Bowser.parse(navigator.userAgent) + + return { + browser: parsed.browser.name, + browser_version: parsed.browser.version, + client_os: parsed.os.name, + } +} + +const sendEntries = async ( + entries: Array<{ created: number; name: string; props?: string }>, + clientId: string, +): Promise<{ ok: boolean; status: number }> => { + if (!config) return { ok: false, status: 0 } + + const { browser, browser_version, client_os } = getBrowserInfo() + const consoleVersion = String(import.meta.env.CONSOLE_VERSION ?? "") + + const response = await fetch(`${API}/add-console-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: config.id, + client_id: clientId, + version: config.version, + console_version: consoleVersion, + ...(client_os ? { client_os } : {}), + ...(browser ? { browser } : {}), + ...(browser_version ? { browser_version } : {}), + events: entries.map((e) => ({ + name: e.name, + ...(e.props ? { props: e.props } : {}), + created: e.created, + })), + }), + }) + + return { ok: response.ok, status: response.status } +} + +let backoff = async (attempt: number): Promise => { + if (attempt === 0) return + const delay = BASE_DELAY * 2 ** (attempt - 1) + await new Promise((r) => setTimeout(r, delay)) +} + +let stopped = false +let ongoing = false +let checkAttempt = 0 +let sendAttempt = 0 + +const run = async (): Promise => { + if (!config?.id || ongoing) return + ongoing = true + let sent = false + const clientId = getClientId() + + while (!sent && !stopped) { + const attempt = Math.max(checkAttempt, sendAttempt) + await backoff(attempt) + + let cursor: number | null = null + try { + const { ok, status, cursor: c } = await checkLatest(config.id, clientId) + if (!ok) { + throw new Error(`checkLatest failed with ${status}`) + } + cursor = c + checkAttempt = 0 + } catch (e) { + console.error("checkLatest failed", e) + checkAttempt++ + if (checkAttempt > MAX_RETRIES) break + continue + } + if (stopped) break + + const entries = await telemetryDb.getEntriesAfter(cursor, MAX_BATCH_SIZE) + if (entries.length === 0 || stopped) break + + try { + const { ok, status } = await sendEntries(entries, clientId) + if (!ok) { + throw new Error(`sendEntries failed with ${status}`) + } + sendAttempt = 0 + } catch (e) { + console.error("sendEntries failed", e) + sendAttempt++ + if (sendAttempt > MAX_RETRIES) break + continue + } + if (stopped) break + + const lastSentCreated = entries[entries.length - 1].created + await telemetryDb.deleteEntriesUpTo(lastSentCreated) + sent = true + } + if (checkAttempt > MAX_RETRIES || sendAttempt > MAX_RETRIES) { + stopPipeline() + } + ongoing = false +} + +export const startPipeline = (telemetryConfig: TelemetryConfigShape): void => { + config = telemetryConfig + stopped = false + ongoing = false + checkAttempt = 0 + sendAttempt = 0 + void run() + intervalId = setInterval(async () => { + const events = await telemetryDb.getEntryCount() + if (events === 0) return + void run() + }, 5_000) +} + +export const stopPipeline = (): void => { + stopped = true + config = null + ongoing = false + checkAttempt = 0 + sendAttempt = 0 + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } +} + +// Exported for testing +export const _internals = { + getClientId, + checkLatest, + sendEntries, + run, + get backoff() { + return backoff + }, + set backoff(fn: typeof backoff) { + backoff = fn + }, + MAX_RETRIES, + get checkAttempt() { + return checkAttempt + }, + get sendAttempt() { + return sendAttempt + }, + get stopped() { + return stopped + }, + resetState: () => { + stopped = false + ongoing = false + checkAttempt = 0 + sendAttempt = 0 + config = null + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + }, + setConfig: (c: TelemetryConfigShape) => { + config = c + }, +} diff --git a/src/providers/AIConversationProvider/index.tsx b/src/providers/AIConversationProvider/index.tsx index 6a203baa7..8c6e0277e 100644 --- a/src/providers/AIConversationProvider/index.tsx +++ b/src/providers/AIConversationProvider/index.tsx @@ -31,6 +31,8 @@ import { import { useEditor } from "../EditorProvider" import { normalizeSql } from "../../utils/aiAssistant" import { useDispatch, useSelector } from "react-redux" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" export type AcceptSuggestionParams = { conversationId: ConversationId @@ -597,6 +599,7 @@ export const AIConversationProvider: React.FC<{ activeConversationId: conversationId, })) dispatch(actions.console.pushSidebarHistory({ type: "aiChat" })) + void trackEvent(ConsoleEvent.AI_CHAT_OPEN) } finally { isOpeningChatWindowRef.current = false } @@ -611,6 +614,7 @@ export const AIConversationProvider: React.FC<{ ) const closeChatWindow = useCallback(() => { + void trackEvent(ConsoleEvent.AI_CHAT_CLOSE) dispatch(actions.console.closeSidebar()) if (chatWindowState.activeConversationId) { if (activeConversationMessages.length === 0) { @@ -689,6 +693,7 @@ export const AIConversationProvider: React.FC<{ }, [createConversation, openChatWindow]) const openHistoryView = useCallback(() => { + void trackEvent(ConsoleEvent.AI_HISTORY_OPEN) setChatWindowState((prev) => ({ ...prev, isHistoryOpen: true, @@ -707,6 +712,7 @@ export const AIConversationProvider: React.FC<{ const deleteConversation = useCallback( async (conversationId: ConversationId) => { + void trackEvent(ConsoleEvent.AI_CHAT_DELETE) await aiConversationStore.deleteConversation(conversationId) let fallbackId: ConversationId | null = null diff --git a/src/providers/QuestProvider/index.tsx b/src/providers/QuestProvider/index.tsx index 2bd3dc1e7..cbf24f5c7 100644 --- a/src/providers/QuestProvider/index.tsx +++ b/src/providers/QuestProvider/index.tsx @@ -32,8 +32,12 @@ import { formatCommitHash, formatVersion } from "./services" import { Versions } from "./types" import { hasUIAuth } from "../../modules/OAuth2/utils" import { useSettings } from "../SettingsProvider" -import { useDispatch } from "react-redux" -import { actions } from "../../store" +import { useDispatch, useSelector } from "react-redux" +import { actions, selectors } from "../../store" +import { + start as startConsoleEvents, + stop as stopConsoleEvents, +} from "../../modules/ConsoleEventTracker" const questClient = new QuestDB.Client() @@ -58,6 +62,7 @@ export const QuestProvider: React.FC = ({ children }) => { const dispatch = useDispatch() const { settings } = useSettings() const { sessionData, refreshAuthToken } = useAuth() + const telemetryConfig = useSelector(selectors.telemetry.getConfig) const [authCheckFinished, setAuthCheckFinished] = useState( !hasUIAuth(settings), ) @@ -131,6 +136,15 @@ export const QuestProvider: React.FC = ({ children }) => { } }, [authCheckFinished]) + useEffect(() => { + if (telemetryConfig?.enabled && telemetryConfig?.id) { + void startConsoleEvents(telemetryConfig) + return () => { + stopConsoleEvents() + } + } + }, [telemetryConfig?.enabled, telemetryConfig?.id]) + if (!authCheckFinished) return null return ( diff --git a/src/scenes/Console/index.tsx b/src/scenes/Console/index.tsx index 99d18b24a..851d001f6 100644 --- a/src/scenes/Console/index.tsx +++ b/src/scenes/Console/index.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from "react" import { useDispatch } from "react-redux" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" import styled from "styled-components" import { Tooltip } from "../../components" import Editor from "../Editor" @@ -171,11 +173,13 @@ const Console = () => { direction="left" onClick={() => { if (isDataSourcesPanelOpen) { + void trackEvent(ConsoleEvent.SCHEMA_OPEN) updateLeftPanelState({ type: null, width: leftPanelState.width, }) } else { + void trackEvent(ConsoleEvent.SCHEMA_CLOSE) updateLeftPanelState({ type: LeftPanelType.DATASOURCES, width: leftPanelState.width, @@ -199,7 +203,12 @@ const Console = () => { setSearchPanelOpen(!isSearchPanelOpen)} + onClick={() => { + if (!isSearchPanelOpen) { + void trackEvent(ConsoleEvent.SEARCH_OPEN) + } + setSearchPanelOpen(!isSearchPanelOpen) + }} selected={isSearchPanelOpen} > @@ -253,6 +262,12 @@ const Console = () => { data-hook={`${mode}-panel-button`} direction="left" onClick={() => { + void trackEvent( + ConsoleEvent.PANEL_BOTTOM_SWITCH, + { + panel: mode, + }, + ) dispatch( actions.console.setActiveBottomPanel("result"), ) @@ -279,6 +294,9 @@ const Console = () => { readOnly={consoleConfig.readOnly} {...(!consoleConfig.readOnly && { onClick: () => { + void trackEvent(ConsoleEvent.PANEL_BOTTOM_SWITCH, { + panel: "import", + }) dispatch( actions.console.setActiveBottomPanel("import"), ) diff --git a/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx index edda54c77..7d65da22f 100644 --- a/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx +++ b/src/scenes/Editor/AIChatWindow/ChatHistoryItem.tsx @@ -7,6 +7,8 @@ import { } from "@phosphor-icons/react" import { color } from "../../../utils" import type { ConversationMeta } from "../../../store/db" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const Container = styled.button<{ disabled?: boolean }>` display: flex; @@ -173,6 +175,7 @@ export const ChatHistoryItem: React.FC = ({ const handleSave = async () => { const trimmedValue = editValue.trim() if (trimmedValue && trimmedValue !== conversation.conversationName) { + void trackEvent(ConsoleEvent.AI_CHAT_RENAME) await onRename(conversation.id, trimmedValue) } setIsEditing(false) diff --git a/src/scenes/Editor/AIChatWindow/index.tsx b/src/scenes/Editor/AIChatWindow/index.tsx index c4a202b3d..9eed9b47d 100644 --- a/src/scenes/Editor/AIChatWindow/index.tsx +++ b/src/scenes/Editor/AIChatWindow/index.tsx @@ -48,6 +48,8 @@ import { getTableKindLabel } from "../../Schema/VirtualTables" import * as QuestDB from "../../../utils/questdb" import { QuestContext } from "../../../providers" import { useDispatch, useSelector } from "react-redux" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import { actions, selectors } from "../../../store" import { RunningType } from "../../../store/Query/types" import { eventBus } from "../../../modules/EventBus" @@ -366,6 +368,8 @@ const AIChatWindow: React.FC = () => { return } + void trackEvent(ConsoleEvent.AI_CHAT_SEND) + const conversationId = chatWindowState.activeConversationId if (hasUnactionedDiffParam) { @@ -405,6 +409,7 @@ const AIChatWindow: React.FC = () => { async (messageId: string) => { if (!chatWindowState.activeConversationId) return + void trackEvent(ConsoleEvent.AI_CHAT_ACCEPT) await acceptSuggestion({ conversationId: chatWindowState.activeConversationId, messageId, @@ -419,6 +424,7 @@ const AIChatWindow: React.FC = () => { async (messageId: string) => { if (!chatWindowState.activeConversationId) return + void trackEvent(ConsoleEvent.AI_CHAT_REJECT) await rejectSuggestion(chatWindowState.activeConversationId, messageId) setTimeout(() => { @@ -446,6 +452,9 @@ const AIChatWindow: React.FC = () => { ) const handleContextClick = useCallback(async () => { + void trackEvent(ConsoleEvent.AI_CONTEXT_BADGE_CLICK, { + type: conversation?.tableId ? "table" : "query", + }) if (conversation?.queryKey && conversation?.bufferId) { return await highlightQuery(conversation.queryKey, conversation.bufferId) } diff --git a/src/scenes/Editor/Metrics/add-metric-dialog.tsx b/src/scenes/Editor/Metrics/add-metric-dialog.tsx index 493a7d9e8..87febe596 100644 --- a/src/scenes/Editor/Metrics/add-metric-dialog.tsx +++ b/src/scenes/Editor/Metrics/add-metric-dialog.tsx @@ -13,6 +13,8 @@ import { MetricType } from "./utils" import { useEditor } from "../../../providers" import merge from "lodash.merge" import { defaultColor, getColorForNewMetric } from "./color-palette" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import { widgets } from "./widgets" const StyledDescription = styled(Dialog.Description)` @@ -65,6 +67,7 @@ export const AddMetricDialog = ({ open, onOpenChange }: Props) => { : defaultColor const handleSelectMetric = async (metricType: MetricType) => { + void trackEvent(ConsoleEvent.METRIC_ADD, { metricType }) if (buffer?.id) { const newBuffer = merge(buffer, { metricsViewState: { diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 91fa96b61..54370af78 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -72,6 +72,8 @@ import { getQueriesStartingFromLine, } from "./utils" import { toast } from "../../../components/Toast" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import ButtonBar from "../ButtonBar" import { QueryDropdown } from "./QueryDropdown" import { @@ -506,6 +508,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } const handleExplainQuery = (query: Request) => { + void trackEvent(ConsoleEvent.EDITOR_GLYPH_CONTEXT_QUERY_PLAN) setDropdownOpen(false) runQueryAction(query, RunningType.EXPLAIN) } @@ -559,6 +562,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const handleAskAI = async (query?: Request) => { setDropdownOpen(false) + void trackEvent(ConsoleEvent.AI_GLYPH_CLICK) if (!query || !editorRef.current) return const queryKey = createQueryKeyFromRequest(editorRef.current, query) @@ -737,6 +741,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { requestRef.current?.row + 1 === startLineNumber const handleRunClick = () => { + void trackEvent(ConsoleEvent.EDITOR_GLYPH_RUN) if (isRunningQuery) { toggleRunning(RunningType.NONE) } else { @@ -774,6 +779,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } const handleRunContextMenu = () => { + void trackEvent(ConsoleEvent.EDITOR_GLYPH_CONTEXT_OPEN) if (isBlockingAIStatusRef.current) return const dropdownQueries = getDropdownQueries(startLineNumber) if (dropdownQueries.length > 0) { @@ -1343,6 +1349,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { return } + if (runAll) { + void trackEvent(ConsoleEvent.EDITOR_RUN_ALL) + } + const triggerScript = () => { if (runAll) { setScriptConfirmationOpen(true) diff --git a/src/scenes/Editor/Monaco/query-in-notification.tsx b/src/scenes/Editor/Monaco/query-in-notification.tsx index ecd487e05..b88034ba9 100644 --- a/src/scenes/Editor/Monaco/query-in-notification.tsx +++ b/src/scenes/Editor/Monaco/query-in-notification.tsx @@ -2,6 +2,8 @@ import React from "react" import styled from "styled-components" import { Box, Text } from "../../../components" import { CopyButton } from "../../../components/CopyButton" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const StyledText = styled(Text)` white-space: nowrap; @@ -14,7 +16,11 @@ export const QueryInNotification = ({ query }: { query: string }) => { return ( - + void trackEvent(ConsoleEvent.QUERY_LOG_QUERY_COPY)} + /> {query} diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 18da420d1..e3f2465dc 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -28,6 +28,8 @@ import { import { fetchUserLocale, getLocaleFromLanguage } from "../../../utils" import { format, formatDistance } from "date-fns" import type { Buffer } from "../../../store/buffers" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" type Tab = { id: string @@ -101,6 +103,7 @@ export const Tabs = () => { const [skippedTabs, setSkippedTabs] = useState([]) const handleExportTabs = async () => { + void trackEvent(ConsoleEvent.TAB_EXPORT) try { const skipTables = db.tables .map((t) => t.name) @@ -128,6 +131,7 @@ export const Tabs = () => { } const handleImportTabs = () => { + void trackEvent(ConsoleEvent.TAB_IMPORT) const input = document.createElement("input") input.type = "file" input.accept = ".json" @@ -414,6 +418,7 @@ export const Tabs = () => { } const rename = async (id: string, title: string) => { + void trackEvent(ConsoleEvent.TAB_RENAME) await updateBuffer(parseInt(id), { label: title }) } @@ -485,7 +490,15 @@ export const Tabs = () => { } as Tab })} /> - + { + if (open) { + void trackEvent(ConsoleEvent.TAB_HISTORY_OPEN) + } + setHistoryOpen(open) + }} + > { data-hook="editor-tabs-history-item" key={buffer.id} onClick={async () => { + void trackEvent(ConsoleEvent.TAB_HISTORY_RESTORE) await updateBuffer(buffer.id as number, { archived: false, archivedAt: undefined, @@ -573,7 +587,10 @@ export const Tabs = () => { <> { + void trackEvent(ConsoleEvent.TAB_HISTORY_CLEAR) + void removeAllArchived() + }} data-hook="editor-tabs-history-clear" > diff --git a/src/scenes/Editor/index.tsx b/src/scenes/Editor/index.tsx index bf71542cb..e60cf8f74 100644 --- a/src/scenes/Editor/index.tsx +++ b/src/scenes/Editor/index.tsx @@ -49,6 +49,8 @@ import { getLastUnactionedDiff } from "../../providers/AIConversationProvider/ut import { useDispatch } from "react-redux" import { actions } from "../../store" import { QuestDBLanguageName, normalizeQueryText } from "./Monaco/utils" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" type Props = Readonly<{ style?: CSSProperties @@ -256,6 +258,8 @@ const Editor = ({ ) return + void trackEvent(ConsoleEvent.AI_EDITOR_SUGGESTION_ACCEPT) + const { conversationId } = pendingDiffInfo // Use unified acceptSuggestion from provider diff --git a/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx b/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx index a02ae4d00..29b249028 100644 --- a/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx +++ b/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx @@ -21,6 +21,8 @@ import { Dialog as TableSchemaDialog } from "../../../components/TableSchemaDial import { UploadResultDialog } from "./upload-result-dialog" import { shortenText, UploadResult } from "../../../utils" import { DropBox } from "./dropbox" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const Root = styled(Box).attrs({ flexDirection: "column", gap: "2rem" })` padding: 2rem; @@ -149,9 +151,10 @@ export const FilesToUpload = ({ } - onClick={() => + onClick={() => { + void trackEvent(ConsoleEvent.IMPORT_RESULTS_OPEN) onViewData(data.uploadResult as UploadResult) - } + }} > Result @@ -273,6 +276,7 @@ export const FilesToUpload = ({ setSchemaDialogOpen(name ? data.id : undefined) } onSchemaChange={(schema) => { + void trackEvent(ConsoleEvent.IMPORT_ADD_SCHEMA) onFilePropertyChange(data.id, { schema: schema.schemaColumns, partitionBy: schema.partitionBy, @@ -320,14 +324,17 @@ export const FilesToUpload = ({ } trigger={ - }> + } + onClick={() => void trackEvent(ConsoleEvent.IMPORT_DETAILS_OPEN)} + > {partialErrorsCount > 0 && } Details diff --git a/src/scenes/Import/ImportCSVFiles/upload-settings-dialog.tsx b/src/scenes/Import/ImportCSVFiles/upload-settings-dialog.tsx index f925073ed..34bb52e30 100644 --- a/src/scenes/Import/ImportCSVFiles/upload-settings-dialog.tsx +++ b/src/scenes/Import/ImportCSVFiles/upload-settings-dialog.tsx @@ -14,6 +14,8 @@ import { Settings4 } from "@styled-icons/remix-line" import { Undo } from "@styled-icons/boxicons-regular" import { UploadModeSettings } from "../../../utils" import { MAX_UNCOMMITTED_ROWS } from "./const" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const SettingsIcon = styled(Settings4)` color: ${({ theme }) => theme.color.foreground}; @@ -234,6 +236,7 @@ export const UploadSettingsDialog = ({ prefixIcon={} skin="success" onClick={() => { + void trackEvent(ConsoleEvent.IMPORT_SETTINGS_CHANGE) onSubmit(settings) onOpenChange(false) }} diff --git a/src/scenes/Layout/help.tsx b/src/scenes/Layout/help.tsx index 18dad275c..08cdef4c6 100644 --- a/src/scenes/Layout/help.tsx +++ b/src/scenes/Layout/help.tsx @@ -31,6 +31,8 @@ import { useSelector } from "react-redux" import { selectors } from "../../store" import styled from "styled-components" import { Shortcuts } from "../Editor/Shortcuts" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const HelpButton = styled(PrimaryToggleButton)` padding: 0; @@ -117,6 +119,7 @@ export const Help = () => { email?: string message: string }) => { + void trackEvent(ConsoleEvent.HELP_FEEDBACK_SUBMIT) try { await quest.sendFeedback({ email, @@ -132,7 +135,15 @@ export const Help = () => { } }} /> - + { + if (open) { + void trackEvent(ConsoleEvent.HELP_OPEN) + } + setOpen(open) + }} + > { icon={ setNewsOpened(!newsOpened)} + onClick={() => { + if (!newsOpened) { + void trackEvent(ConsoleEvent.NEWS_OPEN) + } + setNewsOpened(!newsOpened) + }} selected={newsOpened} > { + if (isMinimized) { + void trackEvent(ConsoleEvent.QUERY_LOG_OPEN) + } setIsMinimized(!isMinimized) }, [isMinimized]) @@ -217,7 +222,10 @@ const Notifications = ({ diff --git a/src/scenes/Result/index.tsx b/src/scenes/Result/index.tsx index 19fa7cccf..6a8e74639 100644 --- a/src/scenes/Result/index.tsx +++ b/src/scenes/Result/index.tsx @@ -59,6 +59,8 @@ import { NotificationType } from "../../store/Query/types" import { copyToClipboard } from "../../utils/copyToClipboard" import { toast } from "../../components" import { API_VERSION } from "../../consts" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" const Root = styled.div` display: flex; @@ -237,6 +239,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { trigger: ( { + void trackEvent(ConsoleEvent.GRID_MARKDOWN_COPY) void copyToClipboard( gridRef?.current?.getResultAsMarkdown() as string, ).then(() => { @@ -254,6 +257,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { trigger: ( { + void trackEvent(ConsoleEvent.GRID_COLUMN_FREEZE) gridRef?.current?.toggleFreezeLeft() gridRef?.current?.focus() }} @@ -279,7 +283,10 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => { trigger: ( @@ -291,6 +298,7 @@ const Result = ({ viewMode }: { viewMode: ResultViewMode }) => {