Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/query-stats-drawer.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions packages/app/src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
});
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/components/AppNav/AppNav.components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconActivity,
IconBook,
IconBrandDiscord,
IconBulb,
Expand Down Expand Up @@ -66,6 +67,7 @@ type AppNavUserMenuProps = {
teamName?: string;
logoutUrl?: string | null;
onClickUserPreferences?: () => void;
onClickQueryStats?: () => void;
};

const getUserInitials = (userName: string) => {
Expand All @@ -83,6 +85,7 @@ export const AppNavUserMenu = ({
teamName,
logoutUrl,
onClickUserPreferences,
onClickQueryStats,
}: AppNavUserMenuProps) => {
const { isCollapsed } = React.useContext(AppNavContext);
const resolvedUserName = userName.trim() || 'User';
Expand Down Expand Up @@ -159,6 +162,15 @@ export const AppNavUserMenu = ({
>
User Preferences
</Menu.Item>
{onClickQueryStats && (
<Menu.Item
data-testid="query-stats-menu-item"
leftSection={<IconActivity size={16} />}
onClick={onClickQueryStats}
>
Query Stats
</Menu.Item>
)}
{logoutUrl && (
<>
<Menu.Divider />
Expand Down
21 changes: 20 additions & 1 deletion packages/app/src/components/AppNav/AppNav.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 && (
Expand All @@ -521,6 +535,11 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
opened={UserPreferencesOpen}
onClose={closeUserPreferences}
/>
{queryStatsEverOpened && (
<QueryStatsErrorBoundary resetKey={queryStatsOpen}>
<QueryStatsDrawer opened={queryStatsOpen} onClose={closeQueryStats} />
</QueryStatsErrorBoundary>
)}
</AppNavContext.Provider>
);
}
116 changes: 116 additions & 0 deletions packages/app/src/queryStats/InstrumentedClickhouseClient.ts
Original file line number Diff line number Diff line change
@@ -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<Format extends DataFormat>(
props: QueryInputs<Format>,
): Promise<BaseResultSet<ReadableStream, Format>> {
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<Format>(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;
}
Loading
Loading