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();
+}