From ac1fda2878e4d965500d9bdf57c04f843c1d8f14 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 18 Mar 2026 22:50:29 +0300 Subject: [PATCH 01/12] feat: add web console usage telemetry --- e2e/questdb | 2 +- package.json | 1 + src/modules/ConsoleEventTracker/db.test.ts | 174 +++++++ src/modules/ConsoleEventTracker/db.ts | 67 +++ src/modules/ConsoleEventTracker/events.ts | 9 + src/modules/ConsoleEventTracker/index.test.ts | 177 +++++++ src/modules/ConsoleEventTracker/index.ts | 39 ++ .../ConsoleEventTracker/sendPipeline.test.ts | 451 ++++++++++++++++++ .../ConsoleEventTracker/sendPipeline.ts | 263 ++++++++++ src/providers/QuestProvider/index.tsx | 18 +- src/store/Telemetry/types.ts | 2 +- src/store/db.ts | 7 + src/utils/localStorage/types.ts | 1 + src/utils/telemetry.test.ts | 4 +- src/vite-env.d.ts | 1 + vite.config.mts | 4 + yarn.lock | 8 + 17 files changed, 1222 insertions(+), 6 deletions(-) create mode 100644 src/modules/ConsoleEventTracker/db.test.ts create mode 100644 src/modules/ConsoleEventTracker/db.ts create mode 100644 src/modules/ConsoleEventTracker/events.ts create mode 100644 src/modules/ConsoleEventTracker/index.test.ts create mode 100644 src/modules/ConsoleEventTracker/index.ts create mode 100644 src/modules/ConsoleEventTracker/sendPipeline.test.ts create mode 100644 src/modules/ConsoleEventTracker/sendPipeline.ts diff --git a/e2e/questdb b/e2e/questdb index cc1e5555b..da34c7738 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit cc1e5555bab53e65ff28b1c0948e44496db51d12 +Subproject commit da34c773843cd6d8451c1ef57cd763aab529b34c diff --git a/package.json b/package.json index 778044fb1..25671f0ec 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,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/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..3a7c3a171 --- /dev/null +++ b/src/modules/ConsoleEventTracker/db.ts @@ -0,0 +1,67 @@ +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..3faba6c6a --- /dev/null +++ b/src/modules/ConsoleEventTracker/events.ts @@ -0,0 +1,9 @@ +export enum ConsoleEvent { + PANEL_LEFT_OPEN = "panel.left.open", + PANEL_LEFT_CLOSE = "panel.left.close", + SCHEMA_COPY = "schema.copy", + SCHEMA_COPY_NAME = "schema.copy_name", + SCHEMA_EXPLAIN = "schema.explain", + SCHEMA_FILTER = "schema.filter", + SEARCH_EXECUTE = "search.execute", +} 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..f1f598176 --- /dev/null +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -0,0 +1,451 @@ +import "fake-indexeddb/auto" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import type { MockInstance } 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: () => {}, + } +}) + +vi.mock("../../consts", () => ({ + API: "https://test.questdb.io", +})) + +import { db } from "../../store/db" +import { _internals, startPipeline, stopPipeline } from "./sendPipeline" + +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 () => { + await db.events.clear() + _internals.resetState() + fetchSpy = vi.spyOn(global, "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) + }) +}) + +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("startPipeline / stopPipeline", () => { + it("stopPipeline resets state", () => { + startPipeline(mockConfig) + stopPipeline() + expect(_internals.checkAttempt).toBe(0) + expect(_internals.sendAttempt).toBe(0) + 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 as Request).url ?? String(input) + 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..9ca6efffd --- /dev/null +++ b/src/modules/ConsoleEventTracker/sendPipeline.ts @@ -0,0 +1,263 @@ +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 response = await fetch(`${API}/console-events-config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, client_id: clientId }), + }) + + 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 getClientOs = (): string => { + const uaData = (navigator as any).userAgentData as + | { platform?: string } + | undefined + if (uaData?.platform) return uaData.platform + + const ua = navigator.userAgent + if (ua.includes("Win")) return "Windows" + if (ua.includes("Mac")) return "macOS" + if (ua.includes("Linux")) return "Linux" + if (ua.includes("Android")) return "Android" + if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS" + return "unknown" +} + +const getBrowserInfo = (): { + browser: string + browser_version: string + client_os: string +} => { + const client_os = getClientOs() + + // Prefer User-Agent Client Hints (Chromium-only, but more reliable) + const uaData = (navigator as any).userAgentData as + | { brands?: Array<{ brand: string; version: string }> } + | undefined + if (uaData?.brands?.length) { + const match = uaData.brands.find( + (b) => b.brand !== "Chromium" && !b.brand.startsWith("Not"), + ) + if (match) { + return { browser: match.brand, browser_version: match.version, client_os } + } + } + + // Fallback: UA string parsing (Firefox, Safari, older browsers) + const ua = navigator.userAgent + if (ua.includes("Firefox/")) { + return { + browser: "Firefox", + browser_version: ua.match(/Firefox\/([\d.]+)/)?.[1] ?? "", + client_os, + } + } + if (ua.includes("Safari/") && ua.includes("Version/")) { + return { + browser: "Safari", + browser_version: ua.match(/Version\/([\d.]+)/)?.[1] ?? "", + client_os, + } + } + + return { browser: "unknown", browser_version: "", client_os } +} + +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 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: import.meta.env.CONSOLE_VERSION, + client_os, + browser, + 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/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/store/Telemetry/types.ts b/src/store/Telemetry/types.ts index 5d2198d79..235b49858 100644 --- a/src/store/Telemetry/types.ts +++ b/src/store/Telemetry/types.ts @@ -23,7 +23,7 @@ ******************************************************************************/ export type TelemetryConfigShape = Readonly<{ - enabled: string + enabled: boolean id: string version: string os: string diff --git a/src/store/db.ts b/src/store/db.ts index e36e35963..16a575bc8 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -52,6 +52,10 @@ export class Storage extends Dexie { read_notifications!: Table<{ newsId: string }, number> ai_conversations!: Table ai_conversation_messages!: Table + events!: Table< + { id?: number; created: number; name: string; props?: string }, + number + > ready: boolean = false constructor() { @@ -87,6 +91,9 @@ export class Storage extends Dexie { .filter((buffer) => buffer.isDiffBuffer === true) .delete(), ) + this.version(6).stores({ + events: "++id, created", + }) // ────────────────────────────────────────────────────────────────── // ⚠️ IMPORTANT — Import/Export compatibility (https://github.com/dexie/Dexie.js/issues/1337) // If you add a new version here that changes the "buffers" table: diff --git a/src/utils/localStorage/types.ts b/src/utils/localStorage/types.ts index 3777aa6a6..ccf117c45 100644 --- a/src/utils/localStorage/types.ts +++ b/src/utils/localStorage/types.ts @@ -41,4 +41,5 @@ export enum StoreKey { LEFT_PANEL_STATE = "left.panel.state", AI_ASSISTANT_SETTINGS = "ai.assistant.settings", AI_CHAT_PANEL_WIDTH = "ai.chat.panel.width", + CLIENT_ID = "client.id", } diff --git a/src/utils/telemetry.test.ts b/src/utils/telemetry.test.ts index ae747edd7..50f358e85 100644 --- a/src/utils/telemetry.test.ts +++ b/src/utils/telemetry.test.ts @@ -44,7 +44,7 @@ describe("sendServerInfoTelemetry", () => { version: "1.0.0", os: "linux", package: "test", - enabled: "true", + enabled: true, instance_name: "", instance_type: "", instance_desc: "", @@ -53,7 +53,7 @@ describe("sendServerInfoTelemetry", () => { it("should not send telemetry when releaseType is not EE and telemetry is disabled", async () => { mockGetValue.mockReturnValue("OSS") - const disabledServerInfo = { ...mockServerInfo, enabled: "" } + const disabledServerInfo = { ...mockServerInfo, enabled: false } const promise = sendServerInfoTelemetry(disabledServerInfo) await vi.runAllTimersAsync() diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 92c319133..57dfd4408 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,7 @@ type ImportMetaEnv = { readonly COMMIT_HASH: string + readonly CONSOLE_VERSION: string readonly MODE: string } diff --git a/vite.config.mts b/vite.config.mts index ad373cb0d..7ceaa8bae 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -6,6 +6,9 @@ import { viteStaticCopy } from 'vite-plugin-static-copy' import wasm from 'vite-plugin-wasm' import topLevelAwait from 'vite-plugin-top-level-await' import path from 'path' +import { readFileSync } from 'fs' + +const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) as { version: string } export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') @@ -136,6 +139,7 @@ export default defineConfig(({ mode }) => { define: { 'import.meta.env.COMMIT_HASH': JSON.stringify(env.COMMIT_HASH || ''), + 'import.meta.env.CONSOLE_VERSION': JSON.stringify(pkg.version), }, test: { diff --git a/yarn.lock b/yarn.lock index 5e869060a..07f783091 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2535,6 +2535,7 @@ __metadata: eslint-plugin-react: "npm:^7.37.5" eslint-plugin-react-hooks: "npm:^5.1.0" eventemitter3: "npm:^5.0.1" + fake-indexeddb: "npm:^6.2.5" fflate: "npm:^0.8.2" globals: "npm:^15.0.0" husky: "npm:^9.1.7" @@ -6723,6 +6724,13 @@ __metadata: languageName: node linkType: hard +"fake-indexeddb@npm:^6.2.5": + version: 6.2.5 + resolution: "fake-indexeddb@npm:6.2.5" + checksum: 10/a0b6a81413a8dd40d3ae31d6158ffa8a3d113981b11644e206874847c454d5a1519a9fe2b6008d8f66d5630ba96b3da43789a20b6462a0e7cadc008caea15ae8 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" From 057510a067c3cc1c4ea9e6141b377ae6b06d6689 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 01:48:33 +0300 Subject: [PATCH 02/12] catch unmocked requests --- src/modules/ConsoleEventTracker/sendPipeline.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts index f1f598176..84cd91230 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.test.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -77,7 +77,9 @@ let originalBackoff: typeof _internals.backoff beforeEach(async () => { await db.events.clear() _internals.resetState() - fetchSpy = vi.spyOn(global, "fetch") + fetchSpy = vi + .spyOn(global, "fetch") + .mockRejectedValue(new Error("unmocked fetch")) // Replace backoff with a no-op so tests run instantly originalBackoff = _internals.backoff From 25cc9b51a54a2b2520c205ad1fe3f950c90a87f5 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 02:04:55 +0300 Subject: [PATCH 03/12] remove reset test --- src/modules/ConsoleEventTracker/sendPipeline.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts index 84cd91230..5a5cb21e1 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.test.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -335,16 +335,6 @@ describe("run — stopPipeline", () => { }) }) -describe("startPipeline / stopPipeline", () => { - it("stopPipeline resets state", () => { - startPipeline(mockConfig) - stopPipeline() - expect(_internals.checkAttempt).toBe(0) - expect(_internals.sendAttempt).toBe(0) - 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" }) @@ -408,7 +398,7 @@ describe("run — max attempts", () => { const url = typeof input === "string" ? input - : (input as Request).url ?? String(input) + : ((input as Request).url ?? String(input)) if (url.includes("console-events-config")) { return Promise.resolve( new Response(JSON.stringify({ lastUpdated: null }), { From 767a042b2860b3f76c902284183de1953128d29b Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 02:19:40 +0300 Subject: [PATCH 04/12] reset module for each test --- .../ConsoleEventTracker/sendPipeline.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts index 5a5cb21e1..05d0fe19b 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.test.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -1,6 +1,8 @@ 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(() => { @@ -35,8 +37,9 @@ vi.mock("../../consts", () => ({ API: "https://test.questdb.io", })) -import { db } from "../../store/db" -import { _internals, startPipeline, stopPipeline } from "./sendPipeline" +let _internals: typeof InternalsType +let stopPipeline: () => void +let db: typeof DbType let fetchSpy: MockInstance< [input: RequestInfo | URL, init?: RequestInit], @@ -75,6 +78,10 @@ const mockFullCycle = () => { let originalBackoff: typeof _internals.backoff beforeEach(async () => { + vi.resetModules() + ;({ db } = await import("../../store/db")) + ;({ _internals, stopPipeline } = await import("./sendPipeline")) + await db.events.clear() _internals.resetState() fetchSpy = vi @@ -90,7 +97,7 @@ beforeEach(async () => { }) afterEach(() => { - stopPipeline() + stopPipeline?.() _internals.backoff = originalBackoff vi.restoreAllMocks() }) @@ -398,7 +405,9 @@ describe("run — max attempts", () => { const url = typeof input === "string" ? input - : ((input as Request).url ?? String(input)) + : input instanceof URL + ? input.toString() + : input.url if (url.includes("console-events-config")) { return Promise.resolve( new Response(JSON.stringify({ lastUpdated: null }), { From 3b2a29bfb6b3cda82dde96dca62186fc750ece69 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 02:28:44 +0300 Subject: [PATCH 05/12] restore mocks --- src/modules/ConsoleEventTracker/sendPipeline.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts index 05d0fe19b..2e2925c00 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.test.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -78,6 +78,7 @@ const mockFullCycle = () => { let originalBackoff: typeof _internals.backoff beforeEach(async () => { + vi.restoreAllMocks() vi.resetModules() ;({ db } = await import("../../store/db")) ;({ _internals, stopPipeline } = await import("./sendPipeline")) From 94caa586cd64cb7cd7f8937d134f3ff54e34e408 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 02:48:01 +0300 Subject: [PATCH 06/12] lint errors & user agent polyfill on ci --- src/modules/ConsoleEventTracker/db.ts | 5 +- .../ConsoleEventTracker/sendPipeline.test.ts | 46 +++++++++++++++++++ .../ConsoleEventTracker/sendPipeline.ts | 42 +++++++++++------ 3 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/modules/ConsoleEventTracker/db.ts b/src/modules/ConsoleEventTracker/db.ts index 3a7c3a171..d89c54c22 100644 --- a/src/modules/ConsoleEventTracker/db.ts +++ b/src/modules/ConsoleEventTracker/db.ts @@ -43,7 +43,10 @@ export const getEntriesAfter = async ( export const deleteEntriesUpTo = async (created: number): Promise => { try { - const deleteCount = await db.events.where("created").belowOrEqual(created).delete() + const deleteCount = await db.events + .where("created") + .belowOrEqual(created) + .delete() return deleteCount } catch (e) { console.error("Failed to delete sent telemetry events", e) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.test.ts b/src/modules/ConsoleEventTracker/sendPipeline.test.ts index 2e2925c00..38a08457f 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.test.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.test.ts @@ -25,6 +25,17 @@ vi.hoisted(() => { } 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/", } @@ -157,6 +168,41 @@ describe("run — happy path", () => { 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", () => { diff --git a/src/modules/ConsoleEventTracker/sendPipeline.ts b/src/modules/ConsoleEventTracker/sendPipeline.ts index 9ca6efffd..cd3285b44 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.ts @@ -13,6 +13,17 @@ const BASE_DELAY = 1_000 let config: TelemetryConfigShape | null = null let intervalId: ReturnType | null = null +type NavigatorUAData = { + platform?: string + brands?: Array<{ brand: string; version: string }> +} + +const getNavigatorUAData = (): NavigatorUAData | undefined => { + if (typeof navigator === "undefined") return undefined + return (navigator as Navigator & { userAgentData?: NavigatorUAData }) + .userAgentData +} + const getClientId = (): string => { try { let clientId = localStorage.getItem(StoreKey.CLIENT_ID) @@ -49,10 +60,10 @@ const checkLatest = async ( return { ok: true, status: response.status, cursor } } -const getClientOs = (): string => { - const uaData = (navigator as any).userAgentData as - | { platform?: string } - | undefined +const getClientOs = (): string | undefined => { + if (typeof navigator === "undefined") return undefined + + const uaData = getNavigatorUAData() if (uaData?.platform) return uaData.platform const ua = navigator.userAgent @@ -65,16 +76,16 @@ const getClientOs = (): string => { } const getBrowserInfo = (): { - browser: string - browser_version: string - client_os: string + browser?: string + browser_version?: string + client_os?: string } => { + if (typeof navigator === "undefined") return {} + const client_os = getClientOs() // Prefer User-Agent Client Hints (Chromium-only, but more reliable) - const uaData = (navigator as any).userAgentData as - | { brands?: Array<{ brand: string; version: string }> } - | undefined + const uaData = getNavigatorUAData() if (uaData?.brands?.length) { const match = uaData.brands.find( (b) => b.brand !== "Chromium" && !b.brand.startsWith("Not"), @@ -111,6 +122,7 @@ const sendEntries = async ( 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", @@ -119,10 +131,10 @@ const sendEntries = async ( id: config.id, client_id: clientId, version: config.version, - console_version: import.meta.env.CONSOLE_VERSION, - client_os, - browser, - browser_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 } : {}), @@ -136,7 +148,7 @@ const sendEntries = async ( let backoff = async (attempt: number): Promise => { if (attempt === 0) return - const delay = BASE_DELAY * (2 ** (attempt - 1)) + const delay = BASE_DELAY * 2 ** (attempt - 1) await new Promise((r) => setTimeout(r, delay)) } From 358f410762af79aaf6d79090235a3559fd98e61b Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 11:37:47 +0300 Subject: [PATCH 07/12] no manual ua parsing --- package.json | 1 + .../ConsoleEventTracker/sendPipeline.ts | 61 ++----------------- yarn.lock | 8 +++ 3 files changed, 15 insertions(+), 55 deletions(-) diff --git a/package.json b/package.json index 25671f0ec..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", diff --git a/src/modules/ConsoleEventTracker/sendPipeline.ts b/src/modules/ConsoleEventTracker/sendPipeline.ts index cd3285b44..e5045cae1 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.ts @@ -1,3 +1,4 @@ +import Bowser from "bowser" import { API as DEFAULT_API } from "../../consts" const API: string = @@ -13,17 +14,6 @@ const BASE_DELAY = 1_000 let config: TelemetryConfigShape | null = null let intervalId: ReturnType | null = null -type NavigatorUAData = { - platform?: string - brands?: Array<{ brand: string; version: string }> -} - -const getNavigatorUAData = (): NavigatorUAData | undefined => { - if (typeof navigator === "undefined") return undefined - return (navigator as Navigator & { userAgentData?: NavigatorUAData }) - .userAgentData -} - const getClientId = (): string => { try { let clientId = localStorage.getItem(StoreKey.CLIENT_ID) @@ -60,21 +50,6 @@ const checkLatest = async ( return { ok: true, status: response.status, cursor } } -const getClientOs = (): string | undefined => { - if (typeof navigator === "undefined") return undefined - - const uaData = getNavigatorUAData() - if (uaData?.platform) return uaData.platform - - const ua = navigator.userAgent - if (ua.includes("Win")) return "Windows" - if (ua.includes("Mac")) return "macOS" - if (ua.includes("Linux")) return "Linux" - if (ua.includes("Android")) return "Android" - if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS" - return "unknown" -} - const getBrowserInfo = (): { browser?: string browser_version?: string @@ -82,37 +57,13 @@ const getBrowserInfo = (): { } => { if (typeof navigator === "undefined") return {} - const client_os = getClientOs() + const parsed = Bowser.parse(navigator.userAgent) - // Prefer User-Agent Client Hints (Chromium-only, but more reliable) - const uaData = getNavigatorUAData() - if (uaData?.brands?.length) { - const match = uaData.brands.find( - (b) => b.brand !== "Chromium" && !b.brand.startsWith("Not"), - ) - if (match) { - return { browser: match.brand, browser_version: match.version, client_os } - } - } - - // Fallback: UA string parsing (Firefox, Safari, older browsers) - const ua = navigator.userAgent - if (ua.includes("Firefox/")) { - return { - browser: "Firefox", - browser_version: ua.match(/Firefox\/([\d.]+)/)?.[1] ?? "", - client_os, - } + return { + browser: parsed.browser.name, + browser_version: parsed.browser.version, + client_os: parsed.os.name, } - if (ua.includes("Safari/") && ua.includes("Version/")) { - return { - browser: "Safari", - browser_version: ua.match(/Version\/([\d.]+)/)?.[1] ?? "", - client_os, - } - } - - return { browser: "unknown", browser_version: "", client_os } } const sendEntries = async ( diff --git a/yarn.lock b/yarn.lock index 07f783091..fa40b70ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2515,6 +2515,7 @@ __metadata: animate.css: "npm:3.7.2" babel-plugin-import: "npm:^1.13.8" babel-plugin-styled-components: "npm:^2.0.7" + bowser: "npm:^2.14.1" bundlewatch: "npm:^0.3.3" compare-versions: "npm:^5.0.1" core-js: "npm:^3.22.8" @@ -4944,6 +4945,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.14.1": + version: 2.14.1 + resolution: "bowser@npm:2.14.1" + checksum: 10/a002f0795ef360314c75552b94daa42f74473f38b34255cfa959779e875806ef8e41b24ec63a533717798c8ef70bb991aef3037a2bb5dd32e8f507b39a509163 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.12 resolution: "brace-expansion@npm:1.1.12" From 32e8f04e5401177b0c5c31b83a9e8c58f6952b40 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 11:43:28 +0300 Subject: [PATCH 08/12] get endpoint console-event-config --- src/modules/ConsoleEventTracker/sendPipeline.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/modules/ConsoleEventTracker/sendPipeline.ts b/src/modules/ConsoleEventTracker/sendPipeline.ts index e5045cae1..275404f4c 100644 --- a/src/modules/ConsoleEventTracker/sendPipeline.ts +++ b/src/modules/ConsoleEventTracker/sendPipeline.ts @@ -32,11 +32,8 @@ const checkLatest = async ( id: string, clientId: string, ): Promise<{ ok: boolean; status: number; cursor: number }> => { - const response = await fetch(`${API}/console-events-config`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, client_id: clientId }), - }) + 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 } From c26e02392c288dc1e2ad24a37b5cdd17fbbb9276 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 11:58:35 +0300 Subject: [PATCH 09/12] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index da34c7738..1352e8f88 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit da34c773843cd6d8451c1ef57cd763aab529b34c +Subproject commit 1352e8f88da38d8c4f980409902d5b1b30b57dbb From ccb042d14644be4fdfdc938842bcf4d8126f7ac1 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 19 Mar 2026 16:56:15 +0300 Subject: [PATCH 10/12] add actions to store --- e2e/questdb | 2 +- src/components/CopyButton/index.tsx | 3 + src/components/Drawer/index.tsx | 4 + src/components/ExplainQueryButton/index.tsx | 3 + src/components/FixQueryButton/index.tsx | 3 + .../SetupAIAssistant/ConfigurationModal.tsx | 7 ++ .../SetupAIAssistant/ModelDropdown.tsx | 3 + .../SetupAIAssistant/SettingsModal.tsx | 10 +++ src/components/SetupAIAssistant/index.tsx | 4 + src/js/console/grid.js | 3 + src/modules/ConsoleEventTracker/events.ts | 87 +++++++++++++++++-- .../AIConversationProvider/index.tsx | 6 ++ src/scenes/Console/index.tsx | 20 ++++- .../Editor/AIChatWindow/ChatHistoryItem.tsx | 3 + src/scenes/Editor/AIChatWindow/index.tsx | 9 ++ .../Editor/Metrics/add-metric-dialog.tsx | 3 + src/scenes/Editor/Monaco/index.tsx | 10 +++ .../Editor/Monaco/query-in-notification.tsx | 8 +- src/scenes/Editor/Monaco/tabs.tsx | 21 ++++- src/scenes/Editor/index.tsx | 4 + .../Import/ImportCSVFiles/files-to-upload.tsx | 15 +++- src/scenes/Import/ImportCSVFiles/index.tsx | 3 + .../ImportCSVFiles/upload-result-dialog.tsx | 8 +- .../ImportCSVFiles/upload-settings-dialog.tsx | 3 + src/scenes/Layout/help.tsx | 13 ++- src/scenes/News/index.tsx | 9 +- src/scenes/Notifications/index.tsx | 10 ++- src/scenes/Result/index.tsx | 15 +++- src/scenes/Schema/Row/index.tsx | 5 ++ .../Schema/TableDetailsDrawer/DetailsTab.tsx | 5 ++ .../Schema/TableDetailsDrawer/index.tsx | 4 + src/scenes/Schema/Toolbar/toolbar.tsx | 10 ++- src/scenes/Schema/VirtualTables/index.tsx | 28 ++++-- src/scenes/Schema/index.tsx | 4 + src/scenes/Search/SearchPanel.tsx | 7 ++ 35 files changed, 326 insertions(+), 26 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 1352e8f88..c8ab4062f 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 1352e8f88da38d8c4f980409902d5b1b30b57dbb +Subproject commit c8ab4062f2a7fc749ce37151ff172d86f682db16 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..1301f8650 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,6 +28,7 @@ export const SetupAIAssistant = () => { const handleSettingsClick = () => { if (isConfigured) { + void trackEvent(ConsoleEvent.AI_SETTINGS_OPEN) setSettingsModalOpen(true) } else { if (showPromo) { @@ -60,6 +63,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/events.ts b/src/modules/ConsoleEventTracker/events.ts index 3faba6c6a..34df54ff7 100644 --- a/src/modules/ConsoleEventTracker/events.ts +++ b/src/modules/ConsoleEventTracker/events.ts @@ -1,9 +1,86 @@ export enum ConsoleEvent { - PANEL_LEFT_OPEN = "panel.left.open", - PANEL_LEFT_CLOSE = "panel.left.close", - SCHEMA_COPY = "schema.copy", - SCHEMA_COPY_NAME = "schema.copy_name", - SCHEMA_EXPLAIN = "schema.explain", + 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_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/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/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 }) => {