diff --git a/e2e/questdb b/e2e/questdb index f47808dec..0e12b8665 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit f47808dece2a00864d28cde4af03ff7670fe3f91 +Subproject commit 0e12b8665922b827bbcd4099e3d59193ed4a62d8 diff --git a/src/components/McpSetupCommand/index.tsx b/src/components/McpSetupCommand/index.tsx index 7e54cfdae..59e20f8ef 100644 --- a/src/components/McpSetupCommand/index.tsx +++ b/src/components/McpSetupCommand/index.tsx @@ -4,6 +4,8 @@ import { CopyButton } from "../CopyButton" import { CopyCommand } from "../icons/copy-command" import { EXPECTED_BRIDGE_VERSION } from "../../utils/mcp/protocolVersion" import { color } from "../../utils" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" export const SETUP_COMMAND = `npx @questdb/mcp-bridge@${EXPECTED_BRIDGE_VERSION} setup` @@ -46,7 +48,13 @@ const CopyCommandButton = styled(CopyButton)` } ` -export const McpSetupCommand = ({ iconSize = 16 }: { iconSize?: number }) => ( +export const McpSetupCommand = ({ + iconSize = 16, + source, +}: { + iconSize?: number + source: "promo" | "onboarding" +}) => ( <> npx @questdb/mcp-bridge@{EXPECTED_BRIDGE_VERSION} setup @@ -57,6 +65,9 @@ export const McpSetupCommand = ({ iconSize = 16 }: { iconSize?: number }) => ( size="sm" text={SETUP_COMMAND} icon={} + onCopy={() => + void trackEvent(ConsoleEvent.MCP_SETUP_COMMAND_COPY, { source }) + } /> ) diff --git a/src/components/NotebookOnboardingModal/Step2.tsx b/src/components/NotebookOnboardingModal/Step2.tsx index 94b7c2b58..f72af23ad 100644 --- a/src/components/NotebookOnboardingModal/Step2.tsx +++ b/src/components/NotebookOnboardingModal/Step2.tsx @@ -93,7 +93,7 @@ export const Step2 = ({ seamlessly - + diff --git a/src/components/NotebookOnboardingModal/index.tsx b/src/components/NotebookOnboardingModal/index.tsx index a424d4f7f..210114b02 100644 --- a/src/components/NotebookOnboardingModal/index.tsx +++ b/src/components/NotebookOnboardingModal/index.tsx @@ -9,6 +9,8 @@ import { useLocalStorage } from "../../providers/LocalStorageProvider" import { useEditor } from "../../providers/EditorProvider" import { createDefaultNotebookViewState } from "../../store/notebook" import { shouldShowNotebookModal } from "../../utils/notebookOnboarding" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" import { PRIMARY_BUTTON_HOOK } from "./shared" import { Step1 } from "./Step1" import { Step2 } from "./Step2" @@ -134,6 +136,7 @@ export const NotebookOnboardingModal = () => { useEffect(() => { if (open) { + void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_OPEN) updateNotebookOnboarding({ showNotebookPromo: false }) } }, [open, updateNotebookOnboarding]) @@ -147,6 +150,10 @@ export const NotebookOnboardingModal = () => { }, [open, step]) const createNotebook = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CREATE, { + source: "onboarding_modal", + step, + }) updateNotebookOnboarding({ showMcpPromo: false }) void addBuffer({ notebookViewState: createDefaultNotebookViewState() }) setOpen(false) @@ -156,7 +163,10 @@ export const NotebookOnboardingModal = () => { { - if (!isOpen) setOpen(false) + if (!isOpen) { + void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_CLOSE, { step }) + setOpen(false) + } }} > @@ -172,13 +182,24 @@ export const NotebookOnboardingModal = () => { {step === 0 ? "Introducing Notebooks" : "QuestDB MCP Connection"} - setOpen(false)}> + { + void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_CLOSE, { step }) + setOpen(false) + }} + > {step === 0 ? ( setStep(1)} + onNext={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_ONBOARDING_STEP, { + step: 1, + }) + setStep(1) + }} /> ) : ( diff --git a/src/modules/ConsoleEventTracker/events.ts b/src/modules/ConsoleEventTracker/events.ts index c42438266..c0300da9a 100644 --- a/src/modules/ConsoleEventTracker/events.ts +++ b/src/modules/ConsoleEventTracker/events.ts @@ -81,6 +81,7 @@ export enum ConsoleEvent { SIDEBAR_NAVIGATE = "sidebar.navigate", TAB_ADD = "tab.add", + TAB_ARCHIVE = "tab.archive", TAB_IMPORT = "tab.import", TAB_EXPORT = "tab.export", TAB_RENAME = "tab.rename", @@ -101,4 +102,91 @@ export enum ConsoleEvent { HELP_FEEDBACK_SUBMIT = "help.feedback_submit", NEWS_OPEN = "news.open", + + NOTEBOOK_CREATE = "notebook.create", + NOTEBOOK_DUPLICATE = "notebook.duplicate", + NOTEBOOK_BUILD_WITH_AI = "notebook.build_with_ai", + NOTEBOOK_LOAD_RETRY = "notebook.load_retry", + NOTEBOOK_LAYOUT_MODE_CHANGE = "notebook.layout_mode_change", + NOTEBOOK_CELL_ADD = "notebook.cell_add", + NOTEBOOK_CELL_LIMIT_REACHED = "notebook.cell_limit_reached", + NOTEBOOK_CELL_DELETE = "notebook.cell_delete", + NOTEBOOK_CELL_DUPLICATE = "notebook.cell_duplicate", + NOTEBOOK_CELL_MOVE = "notebook.cell_move", + NOTEBOOK_CELL_RENAME = "notebook.cell_rename", + NOTEBOOK_CELL_RUN = "notebook.cell_run", + NOTEBOOK_CELL_DRAW = "notebook.cell_draw", + NOTEBOOK_CELL_RUN_CANCEL = "notebook.cell_run_cancel", + NOTEBOOK_CELL_AUTOREFRESH_CHANGE = "notebook.cell_autorefresh_change", + NOTEBOOK_CELL_RESIZE = "notebook.cell_resize", + NOTEBOOK_CELL_EXPAND_WIDTH = "notebook.cell_expand_width", + NOTEBOOK_CELL_SIZE_RESET = "notebook.cell_size_reset", + NOTEBOOK_CELL_VIEW_CHANGE = "notebook.cell_view_change", + NOTEBOOK_CELL_VIEW_MAXIMIZE = "notebook.cell_view_maximize", + NOTEBOOK_CELL_MAXIMIZE = "notebook.cell_maximize", + NOTEBOOK_DRAW_TOGGLE = "notebook.draw_toggle", + NOTEBOOK_DRAW_REFUSED = "notebook.draw_refused", + NOTEBOOK_CHART_SETTINGS_OPEN = "notebook.chart_settings_open", + NOTEBOOK_CHART_SETTINGS_SAVE = "notebook.chart_settings_save", + NOTEBOOK_CHART_SETTINGS_CANCEL = "notebook.chart_settings_cancel", + NOTEBOOK_CHART_SAVE_BLOCKED = "notebook.chart_save_blocked", + NOTEBOOK_CHART_TYPE_CHANGE = "notebook.chart_type_change", + NOTEBOOK_CHART_RESET_AUTO = "notebook.chart_reset_auto", + NOTEBOOK_MARKDOWN_APPLY = "notebook.markdown_apply", + NOTEBOOK_VARIABLES_OPEN = "notebook.variables_open", + NOTEBOOK_VARIABLES_APPLY = "notebook.variables_apply", + NOTEBOOK_VARIABLES_IMPORT = "notebook.variables_import", + NOTEBOOK_RESULT_TAB_SWITCH = "notebook.result_tab_switch", + NOTEBOOK_ONBOARDING_OPEN = "notebook.onboarding_open", + NOTEBOOK_ONBOARDING_STEP = "notebook.onboarding_step", + NOTEBOOK_ONBOARDING_CLOSE = "notebook.onboarding_close", + + MCP_ADD_CELL = "mcp.add_cell", + MCP_UPDATE_CELL = "mcp.update_cell", + MCP_DELETE_CELL = "mcp.delete_cell", + MCP_RUN_CELL = "mcp.run_cell", + MCP_RUN_QUERY = "mcp.run_query", + MCP_VALIDATE_QUERY = "mcp.validate_query", + MCP_CREATE_NOTEBOOK = "mcp.create_notebook", + MCP_ACTIVATE_NOTEBOOK = "mcp.activate_notebook", + MCP_DUPLICATE_NOTEBOOK = "mcp.duplicate_notebook", + MCP_DELETE_NOTEBOOK = "mcp.delete_notebook", + MCP_LIST_CELLS = "mcp.list_cells", + MCP_GET_CELL = "mcp.get_cell", + MCP_GET_NOTEBOOK_STATE = "mcp.get_notebook_state", + MCP_MOVE_CELL_UP = "mcp.move_cell_up", + MCP_MOVE_CELL_DOWN = "mcp.move_cell_down", + MCP_DUPLICATE_CELL = "mcp.duplicate_cell", + MCP_SET_LAYOUT_MODE = "mcp.set_layout_mode", + MCP_SET_CELL_LAYOUT = "mcp.set_cell_layout", + MCP_SET_CELL_MODE = "mcp.set_cell_mode", + MCP_SET_CELL_CHART_CONFIG = "mcp.set_cell_chart_config", + MCP_SET_CELL_AUTOREFRESH = "mcp.set_cell_autorefresh", + MCP_SET_CELL_NAME = "mcp.set_cell_name", + MCP_SET_CELL_VIEW_MAXIMIZED = "mcp.set_cell_view_maximized", + MCP_SET_CELL_MAXIMIZED = "mcp.set_cell_maximized", + MCP_APPLY_NOTEBOOK_STATE = "mcp.apply_notebook_state", + MCP_GET_TABLES = "mcp.get_tables", + MCP_GET_TABLE_SCHEMA = "mcp.get_table_schema", + MCP_GET_TABLE_DETAILS = "mcp.get_table_details", + MCP_GET_QUESTDB_TOC = "mcp.get_questdb_toc", + MCP_GET_QUESTDB_DOCUMENTATION = "mcp.get_questdb_documentation", + MCP_GET_WORKSPACE_STATE = "mcp.get_workspace_state", + MCP_GET_RECENT_USER_ACTIONS = "mcp.get_recent_user_actions", + MCP_UNKNOWN_TOOL = "mcp.unknown_tool", + + MCP_PAIRING_PROMPTED = "mcp.pairing_prompted", + MCP_PAIRING_ACCEPTED = "mcp.pairing_accepted", + MCP_PAIRING_DECLINED = "mcp.pairing_declined", + MCP_MANUAL_PAIR_SUBMIT = "mcp.manual_pair_submit", + MCP_CONNECTED = "mcp.connected", + MCP_CONNECT_FAILED = "mcp.connect_failed", + MCP_DISCONNECTED = "mcp.disconnected", + MCP_DISCONNECT_CLICK = "mcp.disconnect_click", + MCP_PERMISSIONS_CHANGE = "mcp.permissions_change", + + MCP_SETUP_COMMAND_COPY = "mcp.setup_command_copy", + MCP_AGENT_CHANGES_VIEW = "mcp.agent_changes_view", + MCP_AGENT_CHANGES_DISMISS = "mcp.agent_changes_dismiss", + MCP_STATUS_POPOVER_OPEN = "mcp.status_popover_open", } diff --git a/src/modules/ConsoleEventTracker/mcpToolEvents.test.ts b/src/modules/ConsoleEventTracker/mcpToolEvents.test.ts new file mode 100644 index 000000000..d4ccff65c --- /dev/null +++ b/src/modules/ConsoleEventTracker/mcpToolEvents.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest" +import { ConsoleEvent } from "./events" +import { + applyNotebookStateLayoutMode, + classifyToolResult, + mcpToolCallEvent, + permissionLevelOf, +} from "./mcpToolEvents" +import { + INVALID_BUFFER_ID_MESSAGE, + STATE_NOT_FETCHED_MESSAGE, + STATE_STALE_MESSAGE, +} from "../../utils/notebooks/notebookToolMessages" +import { mcpTools } from "../../utils/tools/tools" + +const textResult = (text: string, isError = true) => ({ + content: [{ type: "text", text }], + isError, +}) + +describe("mcpToolCallEvent", () => { + it("maps known tool names to their enum member", () => { + expect(mcpToolCallEvent("add_cell")).toBe(ConsoleEvent.MCP_ADD_CELL) + expect(mcpToolCallEvent("apply_notebook_state")).toBe( + ConsoleEvent.MCP_APPLY_NOTEBOOK_STATE, + ) + expect(mcpToolCallEvent("get_workspace_state")).toBe( + ConsoleEvent.MCP_GET_WORKSPACE_STATE, + ) + expect(mcpToolCallEvent("get_recent_user_actions")).toBe( + ConsoleEvent.MCP_GET_RECENT_USER_ACTIONS, + ) + }) + + it("maps every advertised MCP tool", () => { + expect( + mcpTools + .map((tool) => tool.name) + .filter((name) => mcpToolCallEvent(name) === null), + ).toEqual([]) + }) + + it("does not treat MCP lifecycle events as tool calls", () => { + expect(mcpToolCallEvent("connected")).toBeNull() + expect(mcpToolCallEvent("pairing_accepted")).toBeNull() + expect(mcpToolCallEvent("unknown_tool")).toBeNull() + }) + + it("returns null for unmapped tool names", () => { + expect(mcpToolCallEvent("brand_new_tool")).toBeNull() + expect(mcpToolCallEvent("")).toBeNull() + }) +}) + +describe("classifyToolResult", () => { + it("classifies success", () => { + expect(classifyToolResult(textResult('{"ok":true}', false))).toEqual({ + outcome: "ok", + }) + }) + + it("classifies generic tool errors", () => { + expect(classifyToolResult(textResult("something broke"))).toEqual({ + outcome: "tool_error", + }) + }) + + it("classifies schema-access denials", () => { + const result = textResult( + "PERMISSION_DENIED: tool 'get_tables' requires the 'grantSchemaAccess' permission. " + + "Ask the user to grant it in the QuestDB console (footer → MCP popover or AI Assistant settings).", + ) + expect(classifyToolResult(result)).toEqual({ + outcome: "denied", + reasonCode: "schema_access", + }) + }) + + it("classifies write-SQL denials", () => { + const result = textResult( + "PERMISSION_DENIED: this SQL is 'INSERT' (write operation) and " + + "requires the 'write' permission. Ask the user to grant it in the " + + "QuestDB console (footer → MCP popover or AI Assistant settings).", + ) + expect(classifyToolResult(result)).toEqual({ + outcome: "denied", + reasonCode: "write_sql", + }) + }) + + it("classifies other PERMISSION_DENIED messages without a reasonCode", () => { + const result = textResult( + "PERMISSION_DENIED: this SQL needs the 'read' permission to execute.", + ) + expect(classifyToolResult(result)).toEqual({ outcome: "denied" }) + }) + + it("classifies freshness-gate denials, including with an appended digest block", () => { + expect(classifyToolResult(textResult(STATE_STALE_MESSAGE))).toEqual({ + outcome: "denied", + reasonCode: "stale", + }) + expect(classifyToolResult(textResult(STATE_NOT_FETCHED_MESSAGE))).toEqual({ + outcome: "denied", + reasonCode: "not_fetched", + }) + expect( + classifyToolResult( + textResult( + `${INVALID_BUFFER_ID_MESSAGE}\n\n\n active_buffer: { id: 3 }\n`, + ), + ), + ).toEqual({ outcome: "denied", reasonCode: "invalid_buffer_id" }) + }) + + it("never treats a stale JSON tool error as a gate denial", () => { + const result = textResult( + JSON.stringify({ + error_code: "stale", + message: "STATE_STALE: mid-apply", + }), + ) + expect(classifyToolResult(result)).toEqual({ outcome: "tool_error" }) + }) +}) + +describe("applyNotebookStateLayoutMode", () => { + it("extracts a valid layout_mode from the tool input", () => { + expect(applyNotebookStateLayoutMode({ layout_mode: "grid" })).toEqual({ + layoutMode: "grid", + }) + expect(applyNotebookStateLayoutMode({ layout_mode: "list" })).toEqual({ + layoutMode: "list", + }) + }) + + it("returns an empty payload when absent or invalid", () => { + expect(applyNotebookStateLayoutMode({ buffer_id: 1 })).toEqual({}) + expect(applyNotebookStateLayoutMode({ layout_mode: "weird" })).toEqual({}) + expect(applyNotebookStateLayoutMode(undefined)).toEqual({}) + expect(applyNotebookStateLayoutMode(null)).toEqual({}) + }) +}) + +describe("permissionLevelOf", () => { + it("encodes the permission hierarchy", () => { + expect( + permissionLevelOf({ grantSchemaAccess: true, read: true, write: true }), + ).toBe("write") + expect( + permissionLevelOf({ grantSchemaAccess: true, read: true, write: false }), + ).toBe("read") + expect( + permissionLevelOf({ grantSchemaAccess: true, read: false, write: false }), + ).toBe("schema") + expect( + permissionLevelOf({ + grantSchemaAccess: false, + read: false, + write: false, + }), + ).toBe("none") + }) +}) diff --git a/src/modules/ConsoleEventTracker/mcpToolEvents.ts b/src/modules/ConsoleEventTracker/mcpToolEvents.ts new file mode 100644 index 000000000..a6d87c1eb --- /dev/null +++ b/src/modules/ConsoleEventTracker/mcpToolEvents.ts @@ -0,0 +1,88 @@ +import { ConsoleEvent } from "./events" +import { + INVALID_BUFFER_ID_MESSAGE, + STATE_NOT_FETCHED_MESSAGE, + STATE_STALE_MESSAGE, +} from "../../utils/notebooks/notebookToolMessages" +import { mcpTools } from "../../utils/tools/tools" +import type { Permissions } from "../../utils/tools/permissions" + +export type McpDeniedReasonCode = + | "schema_access" + | "write_sql" + | "stale" + | "not_fetched" + | "invalid_buffer_id" + +export type McpToolResultLike = { + content: Array<{ type: string; text?: string }> + isError?: boolean +} + +const CONSOLE_EVENT_VALUES = new Set(Object.values(ConsoleEvent)) +const MCP_TOOL_NAMES = new Set(mcpTools.map((tool) => tool.name)) + +export const mcpToolCallEvent = (toolName: string): ConsoleEvent | null => { + if (!MCP_TOOL_NAMES.has(toolName)) return null + const candidate = `mcp.${toolName}` + return CONSOLE_EVENT_VALUES.has(candidate) + ? (candidate as ConsoleEvent) + : null +} + +const SCHEMA_ACCESS_DENY_MARKER = "requires the 'grantSchemaAccess' permission" +const WRITE_SQL_DENY_MARKER = "(write operation)" + +const firstText = (result: McpToolResultLike): string => { + for (const item of result.content) { + if (item.type === "text" && typeof item.text === "string") return item.text + } + return "" +} + +export const classifyToolResult = ( + result: McpToolResultLike, +): { + outcome: "ok" | "tool_error" | "denied" + reasonCode?: McpDeniedReasonCode +} => { + const text = firstText(result) + if (text.startsWith(STATE_STALE_MESSAGE)) { + return { outcome: "denied", reasonCode: "stale" } + } + if (text.startsWith(STATE_NOT_FETCHED_MESSAGE)) { + return { outcome: "denied", reasonCode: "not_fetched" } + } + if (text.startsWith(INVALID_BUFFER_ID_MESSAGE)) { + return { outcome: "denied", reasonCode: "invalid_buffer_id" } + } + if (text.startsWith("PERMISSION_DENIED")) { + if (text.includes(SCHEMA_ACCESS_DENY_MARKER)) { + return { outcome: "denied", reasonCode: "schema_access" } + } + if (text.includes(WRITE_SQL_DENY_MARKER)) { + return { outcome: "denied", reasonCode: "write_sql" } + } + return { outcome: "denied" } + } + return result.isError ? { outcome: "tool_error" } : { outcome: "ok" } +} + +export const applyNotebookStateLayoutMode = ( + input: unknown, +): Record => { + const layoutMode = (input as { layout_mode?: unknown } | null | undefined) + ?.layout_mode + return layoutMode === "list" || layoutMode === "grid" ? { layoutMode } : {} +} + +export const permissionLevelOf = ( + perms: Permissions, +): "write" | "read" | "schema" | "none" => + perms.write + ? "write" + : perms.read + ? "read" + : perms.grantSchemaAccess + ? "schema" + : "none" diff --git a/src/providers/MCPBridgeProvider/index.tsx b/src/providers/MCPBridgeProvider/index.tsx index 2faff5671..7379320f3 100644 --- a/src/providers/MCPBridgeProvider/index.tsx +++ b/src/providers/MCPBridgeProvider/index.tsx @@ -32,6 +32,14 @@ import { type Permissions, } from "../../utils/tools/permissions" import { consumePendingPairFromUrl } from "../../utils/mcp/consumePendingPair" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" +import { + applyNotebookStateLayoutMode, + classifyToolResult, + mcpToolCallEvent, + permissionLevelOf, +} from "../../modules/ConsoleEventTracker/mcpToolEvents" import { QuestContext } from "../QuestProvider" import { useLocalStorage } from "../LocalStorageProvider" import { on as onUserAction } from "../../utils/notebooks/notebookAIBridge" @@ -209,12 +217,20 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ setVersionMismatch(m), ) const offHelloAck = client.on("helloAck", () => { + void trackEvent(ConsoleEvent.MCP_CONNECTED) // Promote pending → consented so a same-tab refresh silently restores. markPendingPairConsented() setLastError(null) }) const offToolCall = client.on("toolCall", (call: ToolCallMessage) => { + const toolEvent = mcpToolCallEvent(call.name) + if (toolEvent === null) { + void trackEvent(ConsoleEvent.MCP_UNKNOWN_TOOL, { + tool: call.name.slice(0, 100), + }) + } + const startedAt = Date.now() const aborter = new AbortController() inflightAbortersRef.current.set(call.requestId, aborter) const deadlineTimer = @@ -248,7 +264,24 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ ...dispatchCtxRef.current, signal: aborter.signal, }) - if (sendRevokedErrorIfNeeded()) return + if (sendRevokedErrorIfNeeded()) { + if (toolEvent) { + void trackEvent(toolEvent, { + outcome: "aborted", + durationMs: Date.now() - startedAt, + }) + } + return + } + if (toolEvent) { + void trackEvent(toolEvent, { + ...classifyToolResult(result), + durationMs: Date.now() - startedAt, + ...(call.name === "apply_notebook_state" + ? applyNotebookStateLayoutMode(call.arguments) + : {}), + }) + } client.sendToolResult({ v: EXPECTED_BRIDGE_VERSION, type: "tool_result", @@ -257,7 +290,21 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ isError: result.isError ?? false, }) } catch (err) { - if (sendRevokedErrorIfNeeded()) return + if (sendRevokedErrorIfNeeded()) { + if (toolEvent) { + void trackEvent(toolEvent, { + outcome: "aborted", + durationMs: Date.now() - startedAt, + }) + } + return + } + if (toolEvent) { + void trackEvent(toolEvent, { + outcome: "dispatch_error", + durationMs: Date.now() - startedAt, + }) + } const message = err instanceof Error ? err.message : "tool dispatch failed" client.sendToolResult({ @@ -362,6 +409,7 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ setUrl(pair.url) setToken(pair.token) } else { + void trackEvent(ConsoleEvent.MCP_PAIRING_PROMPTED) setPendingConsent({ url: pair.url, token: pair.token }) } }, []) @@ -420,6 +468,11 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ const acceptPendingConsent = useCallback( (committedPermissions: Permissions) => { if (!pendingConsent || consentInFlight) return + void trackEvent(ConsoleEvent.MCP_PAIRING_ACCEPTED, { + permissionLevel: permissionLevelOf( + normalizePermissions(committedPermissions), + ), + }) // Order matters: the about-to-be-constructed client reads // permissionsRef.current in its initial hello. setPermissions(committedPermissions) @@ -444,6 +497,7 @@ export const MCPBridgeProvider: React.FC<{ children: React.ReactNode }> = ({ setConsentSucceeded(false) return } + void trackEvent(ConsoleEvent.MCP_PAIRING_DECLINED) setPendingConsent(null) setConsentInFlight(false) if (clientRef.current) { diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 4ed3be92d..9c639d7a0 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -31,7 +31,11 @@ import { } from "../../../components" import { fetchUserLocale, getLocaleFromLanguage } from "../../../utils" import { format, formatDistance } from "date-fns" -import { type Buffer, MAX_BUFFER_NAME_LENGTH } from "../../../store/buffers" +import { + type Buffer, + BufferType, + MAX_BUFFER_NAME_LENGTH, +} from "../../../store/buffers" import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" @@ -399,6 +403,14 @@ export const Tabs = () => { buffer.metricsViewState.metrics.length > 0) || buffer.notebookViewState != null ) { + void trackEvent(ConsoleEvent.TAB_ARCHIVE, { + type: + buffer.notebookViewState != null + ? BufferType.NOTEBOOK + : buffer.metricsViewState != null + ? BufferType.METRICS + : BufferType.SQL, + }) await archiveBuffer(parseInt(id)) } else { await deleteBuffer(parseInt(id)) @@ -687,6 +699,9 @@ export const Tabs = () => { { + void trackEvent(ConsoleEvent.NOTEBOOK_CREATE, { + source: "tab_menu", + }) void addBuffer({ notebookViewState: createDefaultNotebookViewState(), }) diff --git a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx index 604e64b9e..262f81c23 100644 --- a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx +++ b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx @@ -3,6 +3,8 @@ import styled, { keyframes } from "styled-components" import { XIcon } from "@phosphor-icons/react" import { Button, Input } from "../../../../components" import { Select } from "../../../../components/Select" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import type { ChartConfig, QueryChart } from "./chartTypes" import { groupColumns } from "./inferChartConfig" import type { QueryTab } from "../DrawCanvas/drawCanvasUtils" @@ -185,6 +187,9 @@ export const ChartSettingsDrawer: React.FC = ({ ) { return } + void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SETTINGS_CANCEL, { + method: "escape", + }) onClose() e.stopImmediatePropagation() } @@ -227,27 +232,43 @@ export const ChartSettingsDrawer: React.FC = ({ queries: d.queries.map((q, i) => (i === index ? next : q)), })) + const dismiss = (method: "backdrop" | "close" | "button") => { + void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SETTINGS_CANCEL, { method }) + onClose() + } + const commit = () => { const badIdx = draft.queries.findIndex(candlestickMissingOhlc) if (badIdx >= 0) { + void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SAVE_BLOCKED, { + reason: "ohlc_incomplete", + }) setSaveAttempted(true) setActiveIndex(badIdx) return } + const primary = anchorTab != null ? draft.queries[anchorTab.index] : null + void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SETTINGS_SAVE, { + chartType: primary?.type, + seriesCount: primary?.yColumns.length ?? 0, + queryCount: draft.queries.filter((q) => q != null).length, + hasRightAxis: hasRight, + partitioned: draft.queries.some((q) => q?.partitionByColumn != null), + }) onSave(draft) onClose() } return ( <> - + dismiss("backdrop")} aria-hidden />
Chart settings diff --git a/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx b/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx index 748deea08..d1a93ac36 100644 --- a/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx +++ b/src/scenes/Editor/Notebook/NotebookMcpPromo/index.tsx @@ -158,7 +158,7 @@ export const NotebookMcpPromo = () => {
- + Install the MCP using your terminal and use it in any coding agent diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx index c4f2aabad..a24e7cacc 100644 --- a/src/scenes/Editor/Notebook/NotebookToolbar.tsx +++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx @@ -161,7 +161,7 @@ const ToggleButton = styled.button<{ $active: boolean }>` ` export const NotebookToolbar: React.FC = () => { - const { settings } = useNotebookState() + const { cells, settings } = useNotebookState() const { updateSettings } = useNotebookActions() const { activeBuffer, buffers, duplicateNotebook, updateBuffer } = useEditor() const { openNotebookChat } = useAIConversationActions() @@ -229,7 +229,7 @@ export const NotebookToolbar: React.FC = () => { if (!target) return const trimmed = draftName.trim() if (!trimmed || trimmed === target.label) return - void trackEvent(ConsoleEvent.TAB_RENAME) + void trackEvent(ConsoleEvent.TAB_RENAME, { type: "notebook" }) void updateBuffer(target.id, { label: trimmed }) } @@ -245,6 +245,7 @@ export const NotebookToolbar: React.FC = () => { // User-origin only: tool-driven set_layout_mode bypasses this handler so it doesn't appear in the AI digest. const handleModeChange = (next: NotebookLayoutMode) => { if (next === mode) return + void trackEvent(ConsoleEvent.NOTEBOOK_LAYOUT_MODE_CHANGE, { to: next }) updateSettings({ layoutMode: next }) if (typeof activeBuffer.id === "number") { emitUserAction({ @@ -257,12 +258,19 @@ export const NotebookToolbar: React.FC = () => { const handleBuildWithAI = () => { if (typeof activeBuffer.id !== "number") return + void trackEvent(ConsoleEvent.NOTEBOOK_BUILD_WITH_AI, { + cellCount: cells.length, + }) void openNotebookChat(activeBuffer.id) } const handleDuplicate = async () => { if (typeof activeBuffer.id !== "number" || isArchived || isDuplicating) return + void trackEvent(ConsoleEvent.NOTEBOOK_DUPLICATE, { + cellCount: cells.length, + layoutMode: mode, + }) setIsDuplicating(true) try { await duplicateNotebook(activeBuffer.id) diff --git a/src/scenes/Editor/Notebook/cells/AddCellButton.tsx b/src/scenes/Editor/Notebook/cells/AddCellButton.tsx index 61e57b0cb..6d37bbe54 100644 --- a/src/scenes/Editor/Notebook/cells/AddCellButton.tsx +++ b/src/scenes/Editor/Notebook/cells/AddCellButton.tsx @@ -11,6 +11,8 @@ import { useNotebookState, } from "../NotebookProvider" import { emitUserAction } from "../../../../utils/notebooks/notebookAIBridge" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" type AddVariant = "primary" | "secondary" type AddTier = "between" | "bottom" @@ -137,6 +139,10 @@ const useUserAddCell = () => { } } const add = (afterCellId?: string, type?: CellType) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_ADD, { cellType: type ?? "sql" }) + if (cells.length + 1 >= MAX_NOTEBOOK_CELLS) { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_LIMIT_REACHED) + } emit(addCell(afterCellId, undefined, type)) } return { add, atLimit } diff --git a/src/scenes/Editor/Notebook/cells/Cell.tsx b/src/scenes/Editor/Notebook/cells/Cell.tsx index 2435ec159..b9a3f4efa 100644 --- a/src/scenes/Editor/Notebook/cells/Cell.tsx +++ b/src/scenes/Editor/Notebook/cells/Cell.tsx @@ -43,6 +43,8 @@ import { useCellResultStatus } from "../resultHydration/CellResultHydrationConte import { EditorShimmer } from "../cellVirtualization/EditorShimmer" import { useValidateWithGlobals } from "../globals/useValidateWithGlobals" import { useCellRunActions } from "./useCellRunActions" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { MIN_EDITOR_HEIGHT, useCellResizeOrchestration, @@ -419,6 +421,10 @@ const CellInner: React.FC = ({ { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RENAME, { + cellType: cell.type ?? "sql", + hadName: !!cell.name, + }) updateCell(cell.id, { name: name || undefined }) signalUserEdit(bufferIdForEvents) }} @@ -440,7 +446,12 @@ const CellInner: React.FC = ({ signalUserEdit(bufferIdForEvents) clearCellResult(cell.id) }} - onDraw={() => void handleDrawClick()} + onDraw={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_DRAW_TOGGLE, { + mode: isDrawMode ? "run" : "draw", + }) + void handleDrawClick() + }} /> ) : toolbarTier === "expanded" ? ( = ({ targetRef={editorContainerRef} onResize={middleResizeLive} onResizeEnd={middleResizeEnd} - onDoubleClick={resetToDefaults} + onDoubleClick={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_SIZE_RESET, { + region: "mid", + }) + resetToDefaults() + }} doubleView={doubleView} /> )} @@ -583,14 +599,19 @@ const CellInner: React.FC = ({ ? bottomResize.resizeLive : topResize.resizeLive } - onResizeEnd={ - isViewMaximized - ? maximizedChartResizeEnd - : showBottomSlot - ? bottomResize.resizeEnd - : topResize.resizeEnd - } - onDoubleClick={isViewMaximized ? resetToDefaults : resetBottomArea} + onResizeEnd={(height) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RESIZE, { region: "s" }) + if (isViewMaximized) maximizedChartResizeEnd(height) + else if (showBottomSlot) bottomResize.resizeEnd(height) + else topResize.resizeEnd(height) + }} + onDoubleClick={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_SIZE_RESET, { + region: "s", + }) + if (isViewMaximized) resetToDefaults() + else resetBottomArea() + }} minHeight={ isViewMaximized ? MIN_EDITOR_HEIGHT + MIN_BOTTOM_HEIGHT_PX diff --git a/src/scenes/Editor/Notebook/cells/CellDragHeader.tsx b/src/scenes/Editor/Notebook/cells/CellDragHeader.tsx index 800e7c370..b2906fdcf 100644 --- a/src/scenes/Editor/Notebook/cells/CellDragHeader.tsx +++ b/src/scenes/Editor/Notebook/cells/CellDragHeader.tsx @@ -3,6 +3,8 @@ import styled from "styled-components" import { CellToolbar } from "./CellToolbar" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import type { CellToolbarTier } from "../notebookUtils" import type { NotebookCell } from "../../../../store/notebook" @@ -94,6 +96,9 @@ export const CellDragHeader: React.FC = ({ ) ) return + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_EXPAND_WIDTH, { + kind: "full", + }) eventBus.publish(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, { cellId, kind: "full", diff --git a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx index 6c41cdb71..70ce7ef03 100644 --- a/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx +++ b/src/scenes/Editor/Notebook/cells/CellRefreshButton.tsx @@ -11,6 +11,8 @@ import type { AutoRefresh } from "../../../../store/notebook" import { signalUserEdit } from "../../../../utils/notebooks/notebookAIBridge" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" const Container = styled.div` display: flex; @@ -81,6 +83,7 @@ export const CellRefreshButton: React.FC = ({ const handleRefresh = (e: React.MouseEvent) => { e.stopPropagation() signalUserEdit(bufferId) + if (isChart) void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DRAW) eventBus.publish( isChart ? EventType.NOTEBOOK_CELL_REFRESH_CHART @@ -89,6 +92,11 @@ export const CellRefreshButton: React.FC = ({ ) } const handleSelect = (value: AutoRefresh) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_AUTOREFRESH_CHANGE, { + from: autoRefreshLabel(autoRefresh), + to: autoRefreshLabel(value), + trigger: "button", + }) signalUserEdit(bufferId) setCellRefresh(cellId, value) } diff --git a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx index a412669c9..79a0f2956 100644 --- a/src/scenes/Editor/Notebook/cells/CellToolbar.tsx +++ b/src/scenes/Editor/Notebook/cells/CellToolbar.tsx @@ -38,6 +38,8 @@ import { import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" import { clearChartZoom } from "../cellVirtualization/chartZoomStore" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" const ToolbarWrapper = styled.div<{ $inline?: boolean @@ -158,6 +160,10 @@ export const CellToolbar: React.FC = ({ // Minimize the chart/table to the editor, keeping the data on the cell. const handleViewSql = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_CHANGE, { + to: "sql", + method: "menu", + }) signalUserEdit(bufferId) setCellViewMaximized(cellId, false) } @@ -168,6 +174,10 @@ export const CellToolbar: React.FC = ({ eventBus.publish(EventType.NOTEBOOK_CELL_RUN, { cellId }) return } + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_CHANGE, { + to: "grid", + method: "menu", + }) // A chart transfers its data to the grid (no re-query); restore the data // pane in case the SQL was being shown. if (isChartView) setCellMode(cellId, "run") @@ -175,6 +185,10 @@ export const CellToolbar: React.FC = ({ } const handleViewChart = () => { if (isRunning) return + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_CHANGE, { + to: "chart", + method: "menu", + }) signalUserEdit(bufferId) if (isNoneView || isGridView) { // Entering draw can be refused (non-DQL SQL); maximize only once the @@ -185,42 +199,65 @@ export const CellToolbar: React.FC = ({ setCellViewMaximized(cellId, true) } const handleToggleMaximizeView = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_MAXIMIZE, { + isViewMaximized: !cell.isViewMaximized, + view, + }) signalUserEdit(bufferId) setCellViewMaximized(cellId, !cell.isViewMaximized) } const handleMaximizeCell = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_MAXIMIZE, { + action: isMaximized ? "restore" : "maximize", + cellType: cell.type ?? "sql", + }) signalUserEdit(bufferId) setMaximizedCellId(isMaximized ? null : cellId) } const handleRefreshNow = () => { signalUserEdit(bufferId) - eventBus.publish( - isChartView - ? EventType.NOTEBOOK_CELL_REFRESH_CHART - : EventType.NOTEBOOK_CELL_RUN, - { cellId }, - ) + if (isChartView) { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DRAW) + eventBus.publish(EventType.NOTEBOOK_CELL_REFRESH_CHART, { cellId }) + return + } + eventBus.publish(EventType.NOTEBOOK_CELL_RUN, { cellId }) } const handleResetZoom = () => { clearChartZoom(cellId) eventBus.publish(EventType.NOTEBOOK_CELL_RESET_ZOOM, { cellId }) } - const handleChartSettings = () => + const handleChartSettings = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CHART_SETTINGS_OPEN, { + chartType: cell.chartConfig?.queries.find((q) => q != null)?.type, + }) eventBus.publish(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, { cellId }) + } const handleRefreshSelect = (value: AutoRefresh) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_AUTOREFRESH_CHANGE, { + from: autoRefreshLabel(autoRefresh), + to: autoRefreshLabel(value), + trigger: "menu", + }) signalUserEdit(bufferId) setCellRefresh(cellId, value) } const handleMoveUp = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_MOVE, { method: "menu" }) moveCellUp(cellId) emitUserAction({ kind: "user_moved_cell", bufferId, cellId }) } const handleMoveDown = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_MOVE, { method: "menu" }) moveCellDown(cellId) emitUserAction({ kind: "user_moved_cell", bufferId, cellId }) } const handleDuplicate = async () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DUPLICATE, { + cellType: cell.type ?? "sql", + view, + }) const newCellId = await duplicateCell(cellId) if (newCellId) { setFocusedCell(newCellId) @@ -233,6 +270,9 @@ export const CellToolbar: React.FC = ({ } } const handleDelete = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DELETE, { + cellType: cell.type ?? "sql", + }) deleteCell(cellId) emitUserAction({ kind: "user_deleted_cell", bufferId, cellId }) } diff --git a/src/scenes/Editor/Notebook/cells/CellViewToggle.tsx b/src/scenes/Editor/Notebook/cells/CellViewToggle.tsx index aefaffecb..741793815 100644 --- a/src/scenes/Editor/Notebook/cells/CellViewToggle.tsx +++ b/src/scenes/Editor/Notebook/cells/CellViewToggle.tsx @@ -15,6 +15,8 @@ import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" import { clearChartZoom } from "../cellVirtualization/chartZoomStore" import type { CellView } from "../notebookUtils" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" const Container = styled.div` display: flex; @@ -122,12 +124,20 @@ export const CellViewToggle: React.FC = ({ const handleChart = (e: React.MouseEvent) => { e.stopPropagation() if (isRunning) return + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_CHANGE, { + to: "chart", + method: "toggle", + }) signalUserEdit(bufferId) eventBus.publish(EventType.NOTEBOOK_CELL_DRAW, { cellId }) } const handleTable = (e: React.MouseEvent) => { e.stopPropagation() if (isRunning) return + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_CHANGE, { + to: view === "grid" ? "none" : "grid", + method: "toggle", + }) signalUserEdit(bufferId) if (view === "grid") { clearCellResult(cellId) @@ -137,6 +147,10 @@ export const CellViewToggle: React.FC = ({ } const handleSplit = (e: React.MouseEvent) => { e.stopPropagation() + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_VIEW_MAXIMIZE, { + isViewMaximized: !isViewMaximized, + view, + }) signalUserEdit(bufferId) setCellViewMaximized(cellId, !isViewMaximized) } diff --git a/src/scenes/Editor/Notebook/cells/MarkdownCell.tsx b/src/scenes/Editor/Notebook/cells/MarkdownCell.tsx index 2d919695c..51d234e98 100644 --- a/src/scenes/Editor/Notebook/cells/MarkdownCell.tsx +++ b/src/scenes/Editor/Notebook/cells/MarkdownCell.tsx @@ -22,6 +22,8 @@ import { useCellResize } from "./useCellResize" import { ResizeHandle } from "../resize" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { ctrlCmd } from "../../../../utils/platform" import { hasAgentVisibleCellHeightChanged, @@ -283,14 +285,18 @@ const MarkdownCellInner: React.FC = ({ [cell.id, bufferIdForEvents, updateCell], ) - const apply = useCallback(() => { - setEditing(false) - emitUserAction({ - kind: "user_updated_cell", - bufferId: bufferIdForEvents, - cellId: cell.id, - }) - }, [bufferIdForEvents, cell.id]) + const apply = useCallback( + (method: "button" | "shortcut" | "blur") => { + void trackEvent(ConsoleEvent.NOTEBOOK_MARKDOWN_APPLY, { method }) + setEditing(false) + emitUserAction({ + kind: "user_updated_cell", + bufferId: bufferIdForEvents, + cellId: cell.id, + }) + }, + [bufferIdForEvents, cell.id], + ) const edit = useCallback(() => setEditing(true), []) @@ -309,7 +315,7 @@ const MarkdownCellInner: React.FC = ({ }, [editing]) useEffect(() => { - if (wasFocusedRef.current && !isFocused && editing) apply() + if (wasFocusedRef.current && !isFocused && editing) apply("blur") wasFocusedRef.current = isFocused }, [isFocused, editing, apply]) @@ -373,7 +379,11 @@ const MarkdownCellInner: React.FC = ({ right={ editing ? ( - @@ -403,7 +413,7 @@ const MarkdownCellInner: React.FC = ({ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault() e.stopPropagation() - apply() + apply("shortcut") } }} /> @@ -430,7 +440,12 @@ const MarkdownCellInner: React.FC = ({ targetRef={bodyRef} onResize={heightResize.resizeLive} onResizeEnd={heightResize.resizeEnd} - onDoubleClick={heightResize.resetHeight} + onDoubleClick={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_SIZE_RESET, { + region: "s", + }) + heightResize.resetHeight() + }} minHeight={MIN_MARKDOWN_HEIGHT_PX} /> diff --git a/src/scenes/Editor/Notebook/cells/useCellResizeOrchestration.ts b/src/scenes/Editor/Notebook/cells/useCellResizeOrchestration.ts index b0313f031..64e2cf6ce 100644 --- a/src/scenes/Editor/Notebook/cells/useCellResizeOrchestration.ts +++ b/src/scenes/Editor/Notebook/cells/useCellResizeOrchestration.ts @@ -5,6 +5,8 @@ import { useCellResize } from "./useCellResize" import { signalUserEdit } from "../../../../utils/notebooks/notebookAIBridge" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { computeCellHeights, hasAgentVisibleCellHeightChanged, @@ -136,6 +138,7 @@ export const useCellResizeOrchestration = ({ } const middleResizeEnd = (height: number) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RESIZE, { region: "mid" }) const { top, bottom } = partitionCellHeights( middleSum(), height, diff --git a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts index 72aee2346..f23da56e5 100644 --- a/src/scenes/Editor/Notebook/cells/useCellRunActions.ts +++ b/src/scenes/Editor/Notebook/cells/useCellRunActions.ts @@ -14,6 +14,8 @@ import { requireAllDQL } from "../../../../utils/tools/permissions" import { toast } from "../../../../components/Toast" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" type Options = { cell: NotebookCell @@ -77,6 +79,7 @@ export const useCellRunActions = ({ validateWithGlobals(s), ) if (!decision.granted) { + void trackEvent(ConsoleEvent.NOTEBOOK_DRAW_REFUSED) toast.error(decision.reason) return false } @@ -114,6 +117,7 @@ export const useCellRunActions = ({ if (!normalized) return false clearHighlight() + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN) const { ok } = await runCell(cell.id, normalized) applyHighlight(ok) return true @@ -121,6 +125,7 @@ export const useCellRunActions = ({ const emitRanEvent = useCallback( (status: RanStatus) => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN) emitUserAction({ kind: "user_ran_cell", bufferId: bufferIdForEvents, @@ -198,6 +203,7 @@ export const useCellRunActions = ({ ) if (plan.kind === "noop") return if (plan.kind === "chart") { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_DRAW) firstRunRef.current = false eventBus.publish(EventType.NOTEBOOK_CELL_REFRESH_CHART, { cellId: cell.id, diff --git a/src/scenes/Editor/Notebook/cells/useCellWrapperInteractions.ts b/src/scenes/Editor/Notebook/cells/useCellWrapperInteractions.ts index 7097770f8..43fb1f789 100644 --- a/src/scenes/Editor/Notebook/cells/useCellWrapperInteractions.ts +++ b/src/scenes/Editor/Notebook/cells/useCellWrapperInteractions.ts @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useRef } from "react" import { useNotebookActions, useNotebookBufferId } from "../NotebookProvider" import { emitUserAction } from "../../../../utils/notebooks/notebookAIBridge" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" // Wrapper-level interactions shared by every cell kind (SQL and markdown) so // they behave identically and can't drift: click-to-focus, arrow-key reorder @@ -47,6 +49,7 @@ export const useCellWrapperInteractions = ({ if (!canArrowMove || e.target !== e.currentTarget) return if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return e.preventDefault() + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_MOVE, { method: "keyboard" }) if (e.key === "ArrowUp") moveCellUp(cellId) else moveCellDown(cellId) emitUserAction({ diff --git a/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx b/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx index d55b48f18..7aae1a1d1 100644 --- a/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx +++ b/src/scenes/Editor/Notebook/globals/VariablesPopover.tsx @@ -20,6 +20,8 @@ import { CopyButton } from "../../../../components/CopyButton" import { Input } from "../../../../components/Input" import { toast } from "../../../../components/Toast" import { QuestContext } from "../../../../providers/QuestProvider" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { color } from "../../../../utils" import { canReadFromClipboard, @@ -459,6 +461,9 @@ export const VariablesPopover: React.FC = () => { if (!(await validateOnServer(list))) return if (cancelledRef.current) return if (!variablesEqual(normalizeVariables(settings.variables), list)) { + void trackEvent(ConsoleEvent.NOTEBOOK_VARIABLES_APPLY, { + variableCount: list.length, + }) signalUserEdit(bufferId) updateSettings({ variables: list }) } @@ -474,6 +479,7 @@ export const VariablesPopover: React.FC = () => { } const handleOpenChange = (next: boolean) => { + if (next) void trackEvent(ConsoleEvent.NOTEBOOK_VARIABLES_OPEN) if (!next) cancelledRef.current = true setOpen(next) } @@ -486,6 +492,9 @@ export const VariablesPopover: React.FC = () => { toast.error("No DECLARE block found in clipboard") return } + void trackEvent(ConsoleEvent.NOTEBOOK_VARIABLES_IMPORT, { + importedCount: parsed.length, + }) setDrafts((prev) => { const next = prev.filter((d) => d.name !== "" || d.value !== "") for (const { name, value } of parsed) { diff --git a/src/scenes/Editor/Notebook/index.tsx b/src/scenes/Editor/Notebook/index.tsx index 6ee31b25c..c7f501706 100644 --- a/src/scenes/Editor/Notebook/index.tsx +++ b/src/scenes/Editor/Notebook/index.tsx @@ -54,6 +54,8 @@ import { } from "../../../utils/notebooks/notebookAIBridge" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" import { consumeReveal, getPendingReveal } from "./cellReveal" import { useCellBandObservers } from "./useCellBandObservers" import { useChartRefresh } from "./chartRefresh/ChartRefreshContext" @@ -485,8 +487,15 @@ const GridLayout: React.FC = () => { ) // Drag-stop = move (x/y change). Just persist positions. - const handleDragStop = useCallback( - (newLayout: Layout, ..._args: unknown[]) => { + const handleDragStop = useCallback( + (newLayout, oldItem, newItem) => { + if ( + oldItem && + newItem && + (oldItem.x !== newItem.x || oldItem.y !== newItem.y) + ) { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_MOVE, { method: "drag" }) + } autoScroll.stop() updateSettings({ layout: mapLayoutXYW(newLayout) }) if (typeof activeBuffer.id === "number") { @@ -499,8 +508,19 @@ const GridLayout: React.FC = () => { [autoScroll, mapLayoutXYW, updateSettings, activeBuffer.id], ) - const handleResizeStop = useCallback( - (newLayout: Layout, ..._args: unknown[]) => { + const handleResizeStop = useCallback( + (newLayout, oldItem, newItem, _placeholder, _event, element) => { + const region = element?.dataset.axis + if ( + region && + oldItem && + newItem && + (oldItem.x !== newItem.x || + oldItem.w !== newItem.w || + oldItem.h !== newItem.h) + ) { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RESIZE, { region }) + } updateSettings({ layout: mapLayoutXYW(newLayout) }) const cellById = new Map(cells.map((c) => [c.id, c])) for (const item of newLayout) { @@ -857,6 +877,7 @@ export const Notebook: React.FC = () => { ) const retrySeed = () => { + void trackEvent(ConsoleEvent.NOTEBOOK_LOAD_RETRY) setSeedAttempt((attempt) => attempt + 1) } diff --git a/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx b/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx index b0db9890c..fcc42f627 100644 --- a/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx +++ b/src/scenes/Editor/Notebook/resize/EdgeHandle.tsx @@ -3,6 +3,8 @@ import styled from "styled-components" import { color } from "../../../../utils" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { CornerChip, SideChip } from "./chips" const isCorner = (axis: string): boolean => axis === "se" || axis === "sw" @@ -119,10 +121,14 @@ const dispatchAxisDoubleClick = (axis: string, target: HTMLElement): void => { const cellId = cellEl?.dataset.cellId if (!cellId) return if (axis === "s") { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_SIZE_RESET, { region: "s" }) eventBus.publish(EventType.NOTEBOOK_CELL_RESET_SIZE, { cellId }) return } if (axis === "e" || axis === "w") { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_EXPAND_WIDTH, { + kind: axis === "e" ? "right" : "left", + }) eventBus.publish(EventType.NOTEBOOK_CELL_EXPAND_WIDTH, { cellId, kind: axis === "e" ? "right" : "left", diff --git a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx index e7602c55a..2eb44f56a 100644 --- a/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx +++ b/src/scenes/Editor/Notebook/result-table/StatusNotification.tsx @@ -8,6 +8,8 @@ import type { CellResult, SingleQueryResult } from "../../../../store/notebook" import QueryResult from "../../QueryResult" import { QueryInNotification } from "../../Monaco/query-in-notification" import { CancelButton, LiveRegion, NotificationContainer } from "./styles" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" const liveRegionMessage = (result: SingleQueryResult): string => { switch (result.type) { @@ -67,7 +69,10 @@ export const StatusNotification: React.FC = ({ {onCancelQuery && ( onCancelQuery(activeIndex)} + onClick={() => { + void trackEvent(ConsoleEvent.NOTEBOOK_CELL_RUN_CANCEL) + onCancelQuery(activeIndex) + }} > diff --git a/src/scenes/Editor/Notebook/result-table/TabBar.tsx b/src/scenes/Editor/Notebook/result-table/TabBar.tsx index a5ecbbf64..10a438f26 100644 --- a/src/scenes/Editor/Notebook/result-table/TabBar.tsx +++ b/src/scenes/Editor/Notebook/result-table/TabBar.tsx @@ -2,6 +2,8 @@ import React from "react" import { CheckmarkOutline, CloseOutline } from "@styled-icons/evaicons-outline" import { Queue } from "@phosphor-icons/react" import type { CellResult, SingleQueryResult } from "../../../../store/notebook" +import { trackEvent } from "../../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { LoadingIconSvg } from "../../Monaco/icons" import { CancelledIcon, @@ -68,7 +70,16 @@ export const TabBar: React.FC = ({ result, onTabChange }) => ( // eslint-disable-next-line react/no-array-index-key key={i} $active={i === result.activeResultIndex} - onClick={() => onTabChange?.(i)} + onClick={() => { + if (i !== result.activeResultIndex) { + void trackEvent(ConsoleEvent.NOTEBOOK_RESULT_TAB_SWITCH, { + tabIndex: i, + tabCount: result.results.length, + resultType: r.type, + }) + } + onTabChange?.(i) + }} title={r.query} role="tab" aria-selected={i === result.activeResultIndex} diff --git a/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx b/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx index 99d4ad779..779d9d2b0 100644 --- a/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx +++ b/src/scenes/Footer/MCPBridgeStatus/PairPopover.tsx @@ -13,7 +13,13 @@ import { Input } from "../../../components/Input" import { useMCPBridge } from "../../../providers/MCPBridgeProvider" import { MAX_RECONNECT_ATTEMPTS } from "../../../utils/mcp/MCPBridgeClient" import { LOCALHOST_WS_RE, TOKEN_RE } from "../../../utils/mcp/pairValidation" -import type { Permissions } from "../../../utils/tools/permissions" +import { + normalizePermissions, + type Permissions, +} from "../../../utils/tools/permissions" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" +import { permissionLevelOf } from "../../../modules/ConsoleEventTracker/mcpToolEvents" import { Tone, accentColor, deriveTone } from "./tone" import { BridgeUpgradeNotice } from "./BridgeUpgradeNotice" import { BridgeSetupNotice } from "./BridgeSetupNotice" @@ -339,6 +345,9 @@ export const MCPBridgePairPopover = forwardRef( const isConnected = status === "connected" const needsConnect = credsDirty || !isConnected if (!needsConnect && permsDirty) { + void trackEvent(ConsoleEvent.MCP_PERMISSIONS_CHANGE, { + level: permissionLevelOf(normalizePermissions(draftPermissions)), + }) setPermissions(draftPermissions) onClose() return @@ -366,10 +375,12 @@ export const MCPBridgePairPopover = forwardRef( setSubmitted(true) // Order matters: the rebuilt client reads perms in its initial hello. if (permsDirty) setPermissions(draftPermissions) + void trackEvent(ConsoleEvent.MCP_MANUAL_PAIR_SUBMIT) connect(nextUrl, nextToken) } const onDisconnect = () => { + void trackEvent(ConsoleEvent.MCP_DISCONNECT_CLICK) disconnect() setDraftUrl(DEFAULT_WS_URL_PREFIX) setDraftToken("") diff --git a/src/scenes/Footer/MCPBridgeStatus/index.tsx b/src/scenes/Footer/MCPBridgeStatus/index.tsx index e211ab23b..f9ca8b0fa 100644 --- a/src/scenes/Footer/MCPBridgeStatus/index.tsx +++ b/src/scenes/Footer/MCPBridgeStatus/index.tsx @@ -8,6 +8,8 @@ import { MCPBridgePairPopover } from "./PairPopover" import { AgentChangesPopper } from "./AgentChangesPopper" import { useAgentChanges } from "./useAgentChanges" import { Tone, accentColor, deriveTone, hexToRgba } from "./tone" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const pulse = keyframes` 0%, 100% { opacity: 1; } @@ -160,6 +162,7 @@ export const MCPBridgeStatus: React.FC = () => { // Opening the popover is the user's "check": it shows the agent-changes // row, so the pill stops blinking until the next background edit. const handleToggle = (open: boolean) => { + if (open) void trackEvent(ConsoleEvent.MCP_STATUS_POPOVER_OPEN, { tone }) setPopoverOpen(open) if (open) agentChanges.markPopoverOpened() } diff --git a/src/scenes/Footer/MCPBridgeStatus/useAgentChanges.ts b/src/scenes/Footer/MCPBridgeStatus/useAgentChanges.ts index aa64084f0..db074ce0c 100644 --- a/src/scenes/Footer/MCPBridgeStatus/useAgentChanges.ts +++ b/src/scenes/Footer/MCPBridgeStatus/useAgentChanges.ts @@ -6,6 +6,8 @@ import { onAgentEdit, type AgentEdit, } from "../../../utils/notebooks/agentActivity" +import { trackEvent } from "../../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" const POPPER_AUTOHIDE_MS = 10_000 @@ -76,6 +78,7 @@ export const useAgentChanges = (): AgentChanges => { const view = useCallback(async () => { if (!latest) return + void trackEvent(ConsoleEvent.MCP_AGENT_CHANGES_VIEW) const workspace = getWorkspace() if (!workspace) { toast.error("The notebook workspace isn't ready yet. Try again shortly.") @@ -97,6 +100,7 @@ export const useAgentChanges = (): AgentChanges => { }, [latest]) const dismiss = useCallback(() => { + void trackEvent(ConsoleEvent.MCP_AGENT_CHANGES_DISMISS) dismissedRef.current = true setPopperVisible(false) setAutoHidePaused(false) diff --git a/src/utils/mcp/MCPBridgeClient.ts b/src/utils/mcp/MCPBridgeClient.ts index ee620b81d..05825c473 100644 --- a/src/utils/mcp/MCPBridgeClient.ts +++ b/src/utils/mcp/MCPBridgeClient.ts @@ -6,6 +6,8 @@ import { type BridgeVersionMismatch, } from "./protocolVersion" import type { Permissions } from "../tools/permissions" +import { trackEvent } from "../../modules/ConsoleEventTracker" +import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" import { WS_CLOSE_CODES, type AnyMessage, @@ -117,6 +119,7 @@ export class MCPBridgeClient { private pongTimer: ReturnType | null = null private outstandingPing: { nonce: string; sentAt: number } | null = null private reconnectTimer: ReturnType | null = null + private hadSession = false private explicitlyClosed = false private _consecutiveFailedAttempts = 0 private get consecutiveFailedAttempts(): number { @@ -321,6 +324,14 @@ export class MCPBridgeClient { reason, wasClean: (ev as { wasClean?: boolean } | undefined)?.wasClean, }) + const hadSession = this.hadSession + this.hadSession = false + if (hadSession && !this.explicitlyClosed) { + void trackEvent(ConsoleEvent.MCP_DISCONNECTED, { + closeCode: code, + terminal: TERMINAL_BRIDGE_CLOSE_CODES.has(code), + }) + } this.stopHeartbeat() this.ws = null if (this.explicitlyClosed) { @@ -331,6 +342,7 @@ export class MCPBridgeClient { // doesn't resurrect the loop on tab focus — only a fresh deep-link // re-pair can recover from these codes. if (TERMINAL_BRIDGE_CLOSE_CODES.has(code)) { + if (!hadSession) void trackEvent(ConsoleEvent.MCP_CONNECT_FAILED) this.cancelReconnect() this.explicitlyClosed = true if (code === WS_CLOSE_CODES.major_version_mismatch) { @@ -350,6 +362,7 @@ export class MCPBridgeClient { private handleHelloAck(msg: HelloAckMessage): void { this.sessionId = msg.sessionId + this.hadSession = true this.consecutiveFailedAttempts = 0 // Emit before `connected` so consumers see the drift in the same paint // the session goes live, never a frame of clean-connected first. @@ -435,6 +448,7 @@ export class MCPBridgeClient { if (this.reconnectTimer) clearTimeout(this.reconnectTimer) this.consecutiveFailedAttempts += 1 if (this.consecutiveFailedAttempts > MAX_CONSECUTIVE_FAILED_ATTEMPTS) { + void trackEvent(ConsoleEvent.MCP_CONNECT_FAILED) this.setStatus("disconnected") return }