diff --git a/.changeset/query-stats-drawer.md b/.changeset/query-stats-drawer.md new file mode 100644 index 0000000000..a109da8e0b --- /dev/null +++ b/.changeset/query-stats-drawer.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/app': minor +--- + +feat: Query Stats drawer for inspecting ClickHouse query activity + +- New "Query Stats" debug drawer available from the user menu, capturing every ClickHouse query the app dispatches +- Per-row status, duration (color-coded at 2s / 10s thresholds), and SQL preview with params interpolated client-side for readability +- Expandable row reveals interpolated SQL, raw parameterized SQL as sent to ClickHouse, params, query_id, connection, and a one-click "Run EXPLAIN" +- Filter by current page (reactive on client-side nav) and toggle visibility of EXPLAIN events +- Drawer state is transient (closed on every page load); opens via the user menu +- Capture is wrapped in defensive error handling and an error boundary so instrumentation can never break a production query diff --git a/packages/app/src/clickhouse.ts b/packages/app/src/clickhouse.ts index 6d5def56d8..c2cc2a8cb6 100644 --- a/packages/app/src/clickhouse.ts +++ b/packages/app/src/clickhouse.ts @@ -16,6 +16,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { IS_LOCAL_MODE } from '@/config'; import { getLocalConnections } from '@/connection'; +import { InstrumentedClickhouseClient } from '@/queryStats/InstrumentedClickhouseClient'; import api from './api'; import { DEFAULT_QUERY_TIMEOUT } from './defaults'; @@ -29,19 +30,19 @@ export const getClickhouseClient = ( const localConnections = getLocalConnections(); if (localConnections.length === 0) { console.warn('No local connection found'); - return new ClickhouseClient({ + return new InstrumentedClickhouseClient({ host: '', ...options, }); } - return new ClickhouseClient({ + return new InstrumentedClickhouseClient({ host: localConnections[0].host, username: localConnections[0].username, password: localConnections[0].password, ...options, }); } - return new ClickhouseClient({ + return new InstrumentedClickhouseClient({ host: PROXY_CLICKHOUSE_HOST, ...options, }); diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx index 93485c39ab..0df068eece 100644 --- a/packages/app/src/components/AppNav/AppNav.components.tsx +++ b/packages/app/src/components/AppNav/AppNav.components.tsx @@ -14,6 +14,7 @@ import { } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { + IconActivity, IconBook, IconBrandDiscord, IconBulb, @@ -66,6 +67,7 @@ type AppNavUserMenuProps = { teamName?: string; logoutUrl?: string | null; onClickUserPreferences?: () => void; + onClickQueryStats?: () => void; }; const getUserInitials = (userName: string) => { @@ -83,6 +85,7 @@ export const AppNavUserMenu = ({ teamName, logoutUrl, onClickUserPreferences, + onClickQueryStats, }: AppNavUserMenuProps) => { const { isCollapsed } = React.useContext(AppNavContext); const resolvedUserName = userName.trim() || 'User'; @@ -159,6 +162,15 @@ export const AppNavUserMenu = ({ > User Preferences + {onClickQueryStats && ( + } + onClick={onClickQueryStats} + > + Query Stats + + )} {logoutUrl && ( <> diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index f1f53b4797..dfbcf431d5 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import Router, { useRouter } from 'next/router'; import cx from 'classnames'; @@ -34,6 +34,8 @@ import { Dashboard, useDashboards } from '@/dashboard'; import { useFavorites } from '@/favorites'; import InstallInstructionModal from '@/InstallInstructionsModal'; import OnboardingChecklist from '@/OnboardingChecklist'; +import { QueryStatsDrawer } from '@/queryStats/QueryStatsDrawer'; +import { QueryStatsErrorBoundary } from '@/queryStats/QueryStatsErrorBoundary'; import { useSavedSearches } from '@/savedSearch'; import { useLogomark, useWordmark } from '@/theme/ThemeProvider'; import { UserPreferencesModal } from '@/UserPreferencesModal'; @@ -262,6 +264,17 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { userPreferences: { isUTC }, } = useUserPreferences(); + const [queryStatsOpen, { toggle: toggleQueryStats, close: closeQueryStats }] = + useDisclosure(false); + // The drawer subscribes to a global query-event store; mount it only after + // the user opens it for the first time so app-wide renders don't pay for + // an unmounted debug panel. Stays mounted after that so close animations + // still play. + const [queryStatsEverOpened, setQueryStatsEverOpened] = useState(false); + useEffect(() => { + if (queryStatsOpen) setQueryStatsEverOpened(true); + }, [queryStatsOpen]); + const [ showInstallInstructions, { open: openInstallInstructions, close: closeInstallInstructions }, @@ -507,6 +520,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { userName={meData?.name} teamName={meData?.team?.name} onClickUserPreferences={openUserPreferences} + onClickQueryStats={toggleQueryStats} logoutUrl={IS_LOCAL_MODE ? null : `/api/logout`} /> {meData && meData.usageStatsEnabled && ( @@ -521,6 +535,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { opened={UserPreferencesOpen} onClose={closeUserPreferences} /> + {queryStatsEverOpened && ( + + + + )} ); } diff --git a/packages/app/src/queryStats/InstrumentedClickhouseClient.ts b/packages/app/src/queryStats/InstrumentedClickhouseClient.ts new file mode 100644 index 0000000000..72a66a72f1 --- /dev/null +++ b/packages/app/src/queryStats/InstrumentedClickhouseClient.ts @@ -0,0 +1,116 @@ +import type { + BaseResultSet, + DataFormat, +} from '@hyperdx/common-utils/dist/clickhouse'; +import { QueryInputs } from '@hyperdx/common-utils/dist/clickhouse'; +import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser'; + +import { + appendQueryEvent, + QueryEventKind, + updateQueryEvent, +} from './queryStatsStore'; + +let warnedOnce = false; +function safeWarn(message: string, error: unknown) { + if (warnedOnce) return; + warnedOnce = true; + + console.warn(`[QueryStats] ${message}`, error); +} + +// RFC 4122 v4-shaped UUID built with Math.random (no crypto dep). +function generateQueryId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +function detectKind(sql: string): QueryEventKind { + return /^\s*EXPLAIN\b/i.test(sql) ? 'explain' : 'query'; +} + +// Single source of truth for the captured pathname — also used by the +// drawer's filter so the two never drift apart during client-side nav. +export function currentPathname(): string { + if (typeof window === 'undefined') return ''; + try { + return window.location?.pathname ?? ''; + } catch { + return ''; + } +} + +export class InstrumentedClickhouseClient extends ClickhouseClient { + async query( + props: QueryInputs, + ): Promise> { + let queryId = props.queryId; + let eventId: string | undefined; + let startedAt = 0; + let captured = false; + + try { + queryId = queryId ?? generateQueryId(); + // Internal event correlation key — independent of ClickHouse's query_id + // so duplicate caller-supplied query_ids don't conflate events in the store. + eventId = generateQueryId(); + startedAt = performance.now(); + appendQueryEvent({ + id: eventId, + queryId, + sql: props.query, + params: { ...(props.query_params ?? {}) }, + status: 'pending', + startedAt: startedAt, + pathname: currentPathname(), + connectionId: props.connectionId, + kind: detectKind(props.query), + }); + captured = true; + } catch (error) { + safeWarn('failed to capture pending event', error); + } + + const nextProps = captured ? { ...props, queryId } : props; + + try { + const result = await super.query(nextProps); + if (captured && eventId) { + try { + updateQueryEvent(eventId, { + status: 'done', + durationMs: performance.now() - startedAt, + }); + } catch (error) { + safeWarn('failed to update done event', error); + } + } + return result; + } catch (error) { + if (captured && eventId) { + try { + const aborted = props.abort_signal?.aborted === true; + updateQueryEvent(eventId, { + status: aborted ? 'cancelled' : 'error', + durationMs: performance.now() - startedAt, + error: + error instanceof Error ? error.message : String(error ?? 'error'), + }); + } catch (innerError) { + safeWarn('failed to update error event', innerError); + } + } + throw error; + } + } +} + +// Test-only: reset the once-only warn flag between specs. Guarded so an +// accidental import from production code is a no-op. +export function __resetInstrumentationWarnForTests(): void { + if (process.env.NODE_ENV === 'production') return; + warnedOnce = false; +} diff --git a/packages/app/src/queryStats/QueryStatsDrawer.tsx b/packages/app/src/queryStats/QueryStatsDrawer.tsx new file mode 100644 index 0000000000..b8680758a3 --- /dev/null +++ b/packages/app/src/queryStats/QueryStatsDrawer.tsx @@ -0,0 +1,415 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { parameterizedQueryToSql } from '@hyperdx/common-utils/dist/clickhouse'; +import { + Badge, + Box, + Button, + Chip, + CloseButton, + Code, + Collapse, + Drawer, + Group, + ScrollArea, + Stack, + Text, +} from '@mantine/core'; +import { + IconAlertTriangle, + IconCircleCheck, + IconLoader2, + IconPlayerStop, +} from '@tabler/icons-react'; + +import { useClickhouseClient } from '@/clickhouse'; + +import { currentPathname } from './InstrumentedClickhouseClient'; +import { + clearQueryEvents, + QueryEvent, + useQueryEvents, +} from './queryStatsStore'; + +type Props = { + opened: boolean; + onClose: () => void; +}; + +function StatusIcon({ status }: { status: QueryEvent['status'] }) { + if (status === 'pending') { + return ( + + ); + } + if (status === 'done') { + return ( + + ); + } + if (status === 'cancelled') { + return ( + + ); + } + return ( + + ); +} + +function formatDuration(ms?: number): string { + if (ms == null) return '—'; + if (ms < 1) return '<1ms'; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function durationColor(ms?: number): string { + if (ms == null) return 'var(--mantine-color-dimmed)'; + if (ms < 2000) return 'var(--mantine-color-teal-4)'; + if (ms < 10000) return 'var(--mantine-color-yellow-4)'; + return 'var(--mantine-color-orange-4)'; +} + +function collapseWhitespace(s: string): string { + return s.replace(/\s+/g, ' ').trim(); +} + +function hydrateSql(sql: string, params: Record): string { + try { + return parameterizedQueryToSql({ sql, params }); + } catch { + return sql; + } +} + +// EXPLAIN PLAN only parses against read-only statements; for everything else +// (SHOW/INSERT/ALTER/DROP/etc.) the button would just produce a parse error. +function canExplain(sql: string): boolean { + return /^\s*(SELECT|WITH)\b/i.test(sql); +} + +function QueryRow({ event }: { event: QueryEvent }) { + const [expanded, setExpanded] = useState(false); + const hydratedSql = useMemo( + () => hydrateSql(event.sql, event.params), + [event.sql, event.params], + ); + const collapsedSql = useMemo( + () => collapseWhitespace(hydratedSql), + [hydratedSql], + ); + const [explainState, setExplainState] = useState< + | { status: 'idle' } + | { status: 'loading' } + | { status: 'done'; text: string } + | { status: 'error'; error: string } + >({ status: 'idle' }); + const clickhouseClient = useClickhouseClient(); + + const runExplain = async () => { + setExplainState({ status: 'loading' }); + try { + const response = await clickhouseClient.query<'TabSeparatedRaw'>({ + query: `EXPLAIN PLAN indexes = 1 ${event.sql}`, + query_params: event.params, + format: 'TabSeparatedRaw', + connectionId: event.connectionId, + }); + const text = await response.text(); + setExplainState({ status: 'done', text }); + } catch (error) { + setExplainState({ + status: 'error', + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const isError = event.status === 'error'; + + return ( + + setExpanded(v => !v)} + > + + + + + {formatDuration(event.durationMs)} + + {event.kind === 'explain' && ( + + EXPLAIN + + )} + + {collapsedSql} + + + + + + + + SQL{' '} + {Object.keys(event.params).length > 0 && ( + + (params interpolated client-side for readability — not safe + to re-run) + + )} + + + {hydratedSql} + + + {Object.keys(event.params).length > 0 && ( + + + Parameterized SQL (as sent to ClickHouse) + + + {event.sql} + + + )} + {Object.keys(event.params).length > 0 && ( + + + Params + + + {JSON.stringify(event.params, null, 2)} + + + )} + {event.error && ( + + + Error + + + {event.error} + + + )} + + + query_id: {event.queryId} + + {event.connectionId && ( + + conn:{' '} + {event.connectionId} + + )} + + {event.kind !== 'explain' && canExplain(event.sql) && ( + e.stopPropagation()}> + + + + {explainState.status === 'done' && ( + + {explainState.text} + + )} + {explainState.status === 'error' && ( + + {explainState.error} + + )} + + )} + + + + + ); +} + +export function QueryStatsDrawer({ opened, onClose }: Props) { + const events = useQueryEvents(); + const [pathFilter, setPathFilter] = useState(true); + const [showExplain, setShowExplain] = useState(false); + const router = useRouter(); + + // Read from the same source the capture side uses so the strings always + // match exactly. Re-render on Next.js client-side nav. + const [currentPath, setCurrentPath] = useState(() => currentPathname()); + useEffect(() => { + const update = () => setCurrentPath(currentPathname()); + update(); + router.events.on('routeChangeComplete', update); + return () => router.events.off('routeChangeComplete', update); + }, [router.events]); + + const visible = useMemo(() => { + return events + .filter(e => (showExplain ? true : e.kind !== 'explain')) + .filter(e => (pathFilter ? e.pathname === currentPath : true)) + .slice() + .reverse(); + }, [events, pathFilter, showExplain, currentPath]); + + const errorCount = visible.filter(e => e.status === 'error').length; + const explainCount = events.filter(e => e.kind === 'explain').length; + + return ( + + + + + Query Stats + + + {visible.length} + {errorCount > 0 + ? ` · ${errorCount} error${errorCount === 1 ? '' : 's'}` + : ''} + + + This page only + + + EXPLAINs{explainCount > 0 ? ` (${explainCount})` : ''} + + + + + + + + + {visible.length === 0 ? ( + + + No queries captured yet. Interact with the page and they'll show + up here. + + + ) : ( + visible.map(e => ) + )} + + + ); +} diff --git a/packages/app/src/queryStats/QueryStatsErrorBoundary.tsx b/packages/app/src/queryStats/QueryStatsErrorBoundary.tsx new file mode 100644 index 0000000000..dc2de0c04c --- /dev/null +++ b/packages/app/src/queryStats/QueryStatsErrorBoundary.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +type Props = { + children: React.ReactNode; + // When this value changes, the boundary clears the error state so a + // subsequent open can re-mount the panel after a crash. + resetKey?: unknown; +}; +type State = { hasError: boolean }; + +export class QueryStatsErrorBoundary extends React.Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: unknown, info: unknown) { + console.error('[QueryStats] panel crashed; hiding it', error, info); + } + + componentDidUpdate(prevProps: Props) { + if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) return null; + return this.props.children; + } +} diff --git a/packages/app/src/queryStats/__tests__/InstrumentedClickhouseClient.test.ts b/packages/app/src/queryStats/__tests__/InstrumentedClickhouseClient.test.ts new file mode 100644 index 0000000000..7774305b78 --- /dev/null +++ b/packages/app/src/queryStats/__tests__/InstrumentedClickhouseClient.test.ts @@ -0,0 +1,143 @@ +import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser'; + +import { + __resetInstrumentationWarnForTests, + InstrumentedClickhouseClient, +} from '../InstrumentedClickhouseClient'; +import { __resetQueryStatsForTests, getSnapshot } from '../queryStatsStore'; +import * as storeModule from '../queryStatsStore'; + +// The base class's query() lives on the prototype chain above ClickhouseClient. +// Walk up so the spy intercepts what `super.query()` actually resolves to. +const baseQueryProto = Object.getPrototypeOf(ClickhouseClient.prototype); + +// Suppress the deduped console.warn from the wrapper during tests. +const originalWarn = console.warn; +beforeAll(() => { + console.warn = jest.fn(); +}); +afterAll(() => { + console.warn = originalWarn; +}); + +describe('InstrumentedClickhouseClient', () => { + let superQuerySpy: jest.SpyInstance; + + beforeEach(() => { + __resetQueryStatsForTests(); + __resetInstrumentationWarnForTests(); + superQuerySpy = jest + .spyOn(baseQueryProto as any, 'query') + .mockResolvedValue('result' as any); + }); + + afterEach(() => { + superQuerySpy.mockRestore(); + }); + + function makeClient() { + return new InstrumentedClickhouseClient({ host: 'http://localhost' }); + } + + it('passes through the resolved value from super.query', async () => { + const client = makeClient(); + const result = await client.query({ query: 'SELECT 1' }); + expect(result).toBe('result'); + expect(superQuerySpy).toHaveBeenCalledTimes(1); + }); + + it('auto-injects a queryId when none is provided', async () => { + const client = makeClient(); + await client.query({ query: 'SELECT 1' }); + const call = superQuerySpy.mock.calls[0]?.[0]; + expect(typeof call.queryId).toBe('string'); + expect(call.queryId.length).toBeGreaterThan(0); + expect(call.query).toBe('SELECT 1'); + }); + + it('respects a caller-provided queryId', async () => { + const client = makeClient(); + await client.query({ query: 'SELECT 1', queryId: 'caller-id' }); + const call = superQuerySpy.mock.calls[0]?.[0]; + expect(call.queryId).toBe('caller-id'); + }); + + it('stores an internal event id distinct from queryId', async () => { + const client = makeClient(); + await client.query({ query: 'SELECT 1', queryId: 'caller-id' }); + const [event] = getSnapshot(); + expect(event.queryId).toBe('caller-id'); + expect(event.id).not.toBe('caller-id'); + expect(event.id.length).toBeGreaterThan(0); + }); + + it('does not conflate events that share a caller-supplied queryId', async () => { + const client = makeClient(); + await client.query({ query: 'SELECT 1', queryId: 'dup' }); + await client.query({ query: 'SELECT 2', queryId: 'dup' }); + const events = getSnapshot(); + expect(events).toHaveLength(2); + expect(events[0].id).not.toBe(events[1].id); + expect(events[0].status).toBe('done'); + expect(events[1].status).toBe('done'); + }); + + it('shallow-clones params so caller mutation does not rewrite the stored event', async () => { + const client = makeClient(); + const params: Record = { a: 1 }; + await client.query({ query: 'SELECT {a:Int32}', query_params: params }); + params.a = 999; + const [event] = getSnapshot(); + expect(event.params).toEqual({ a: 1 }); + }); + + it('records a done event after a successful query', async () => { + const client = makeClient(); + await client.query({ query: 'SELECT 1' }); + const events = getSnapshot(); + expect(events).toHaveLength(1); + expect(events[0].status).toBe('done'); + expect(events[0].sql).toBe('SELECT 1'); + expect(typeof events[0].durationMs).toBe('number'); + }); + + it('marks the event as error when super.query throws', async () => { + superQuerySpy.mockRejectedValueOnce(new Error('boom')); + const client = makeClient(); + await expect(client.query({ query: 'SELECT 1' })).rejects.toThrow('boom'); + const events = getSnapshot(); + expect(events[0].status).toBe('error'); + expect(events[0].error).toBe('boom'); + }); + + it('marks the event as cancelled when the abort signal fired', async () => { + const controller = new AbortController(); + controller.abort(); + superQuerySpy.mockRejectedValueOnce(new Error('aborted')); + const client = makeClient(); + await expect( + client.query({ query: 'SELECT 1', abort_signal: controller.signal }), + ).rejects.toThrow('aborted'); + const events = getSnapshot(); + expect(events[0].status).toBe('cancelled'); + }); + + it('detects EXPLAIN queries and tags the event kind', async () => { + const client = makeClient(); + await client.query({ query: ' EXPLAIN PLAN SELECT 1' }); + expect(getSnapshot()[0].kind).toBe('explain'); + }); + + it('still resolves the query when the store throws on append', async () => { + const spy = jest + .spyOn(storeModule, 'appendQueryEvent') + .mockImplementation(() => { + throw new Error('store down'); + }); + const client = makeClient(); + const result = await client.query({ query: 'SELECT 1' }); + expect(result).toBe('result'); + expect(superQuerySpy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); +}); diff --git a/packages/app/src/queryStats/__tests__/queryStatsStore.test.ts b/packages/app/src/queryStats/__tests__/queryStatsStore.test.ts new file mode 100644 index 0000000000..5aedf35918 --- /dev/null +++ b/packages/app/src/queryStats/__tests__/queryStatsStore.test.ts @@ -0,0 +1,100 @@ +import { + __resetQueryStatsForTests, + appendQueryEvent, + clearQueryEvents, + getSnapshot, + QueryEvent, + subscribe, + updateQueryEvent, +} from '../queryStatsStore'; + +function makeEvent( + id: string, + overrides: Partial = {}, +): QueryEvent { + return { + id, + queryId: id, + sql: 'SELECT 1', + params: {}, + status: 'pending', + startedAt: Date.now(), + pathname: '/test', + kind: 'query', + ...overrides, + }; +} + +describe('queryStatsStore', () => { + beforeEach(() => { + __resetQueryStatsForTests(); + }); + + it('appends events in order', () => { + appendQueryEvent(makeEvent('a')); + appendQueryEvent(makeEvent('b')); + expect(getSnapshot().map(e => e.id)).toEqual(['a', 'b']); + }); + + it('caps the buffer at 200 events, dropping the oldest', () => { + for (let i = 0; i < 250; i++) appendQueryEvent(makeEvent(`e${i}`)); + const snap = getSnapshot(); + expect(snap).toHaveLength(200); + expect(snap[0].id).toBe('e50'); + expect(snap[snap.length - 1].id).toBe('e249'); + }); + + it('updateQueryEvent merges patch by id', () => { + appendQueryEvent(makeEvent('a')); + updateQueryEvent('a', { status: 'done', durationMs: 42 }); + const [event] = getSnapshot(); + expect(event.status).toBe('done'); + expect(event.durationMs).toBe(42); + }); + + it('updateQueryEvent on unknown id is a no-op', () => { + appendQueryEvent(makeEvent('a')); + const before = getSnapshot(); + updateQueryEvent('ghost', { status: 'done' }); + expect(getSnapshot()).toBe(before); + }); + + it('clearQueryEvents empties the buffer', () => { + appendQueryEvent(makeEvent('a')); + clearQueryEvents(); + expect(getSnapshot()).toEqual([]); + }); + + it('subscribe notifies listeners on changes', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + appendQueryEvent(makeEvent('a')); + updateQueryEvent('a', { status: 'done' }); + clearQueryEvents(); + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + appendQueryEvent(makeEvent('b')); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('a throwing listener does not break other listeners', () => { + const bad = jest.fn(() => { + throw new Error('boom'); + }); + const good = jest.fn(); + subscribe(bad); + subscribe(good); + appendQueryEvent(makeEvent('a')); + expect(bad).toHaveBeenCalled(); + expect(good).toHaveBeenCalled(); + }); + + it('getSnapshot returns a stable reference until a change happens', () => { + appendQueryEvent(makeEvent('a')); + const first = getSnapshot(); + const second = getSnapshot(); + expect(first).toBe(second); + appendQueryEvent(makeEvent('b')); + expect(getSnapshot()).not.toBe(first); + }); +}); diff --git a/packages/app/src/queryStats/queryStatsStore.ts b/packages/app/src/queryStats/queryStatsStore.ts new file mode 100644 index 0000000000..b8aa090f2b --- /dev/null +++ b/packages/app/src/queryStats/queryStatsStore.ts @@ -0,0 +1,125 @@ +import { useSyncExternalStore } from 'react'; + +export type QueryStatus = 'pending' | 'done' | 'error' | 'cancelled'; + +export type QueryEventKind = 'query' | 'explain'; + +export type QueryEvent = { + id: string; + queryId: string; + sql: string; + params: Record; + status: QueryStatus; + startedAt: number; + durationMs?: number; + pathname: string; + error?: string; + connectionId?: string; + kind: QueryEventKind; +}; + +const MAX_EVENTS = 200; +// Truncate large captured strings so a runaway chart config can't pin +// tens of MB in the buffer for the tab's lifetime. +const MAX_STRING_LEN = 32_000; +const TRUNCATION_MARK = '\n…[truncated]'; + +let events: QueryEvent[] = []; +const listeners = new Set<() => void>(); + +function truncate(s: string | undefined): string | undefined { + if (s == null) return s; + if (s.length <= MAX_STRING_LEN) return s; + return s.slice(0, MAX_STRING_LEN) + TRUNCATION_MARK; +} + +function isBrowser(): boolean { + return typeof window !== 'undefined'; +} + +function emit() { + for (const listener of listeners) { + try { + listener(); + } catch { + // never let a bad listener take down the producer + } + } +} + +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getSnapshot(): readonly QueryEvent[] { + return events; +} + +export function appendQueryEvent(event: QueryEvent): void { + if (!isBrowser()) return; + try { + const truncated: QueryEvent = { + ...event, + sql: truncate(event.sql) ?? '', + error: truncate(event.error), + }; + const next = + events.length >= MAX_EVENTS + ? [...events.slice(events.length - MAX_EVENTS + 1), truncated] + : [...events, truncated]; + events = next; + emit(); + } catch { + // swallow — instrumentation must never throw into the wrapper + } +} + +export function updateQueryEvent( + id: string, + patch: Partial>, +): void { + if (!isBrowser()) return; + try { + const truncatedPatch: Partial> = { + ...patch, + ...(patch.sql !== undefined ? { sql: truncate(patch.sql) ?? '' } : {}), + ...(patch.error !== undefined ? { error: truncate(patch.error) } : {}), + }; + let changed = false; + const next = events.map(e => { + if (e.id !== id) return e; + changed = true; + return { ...e, ...truncatedPatch }; + }); + if (!changed) return; + events = next; + emit(); + } catch { + // swallow + } +} + +export function clearQueryEvents(): void { + if (!isBrowser()) return; + try { + events = []; + emit(); + } catch { + // swallow + } +} + +export function useQueryEvents(): readonly QueryEvent[] { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +// Test-only: reset the singleton between specs. Guarded so an accidental +// import from production code is a no-op. +export function __resetQueryStatsForTests(): void { + if (process.env.NODE_ENV === 'production') return; + events = []; + listeners.clear(); +}