feat: associate logged in user to clickhouse query (#1636)

Allows setting a custom setting prefix on a connection. When set in HyperDX and the ClickHouse settings, the HyperDX app will set a custom setting for each query. These are recorded in the query log and can be used to identify which user issues the query.

## Testing

The commit also updates the local dev ClickHouse instance to support a custom setting prefix of `hyeprdx`. After running `make dev-up`, you should be able to edit the connection and set the the prefix to `hyperdx`. 

<img width="955" height="197" alt="Screenshot 2026-01-21 at 1 23 14 PM" src="https://github.com/user-attachments/assets/607fc945-d93f-4976-9862-3118b420c077" />

After saving, just allow the app to live tail a source like logs. If you connect to the ClickHouse database, you should then be able to run

```
SELECT query, Settings
FROM system.query_log
WHERE has(mapKeys(Settings), 'hyperdx_user')
FORMAT Vertical
```

and then see a bunch of queries with the user set to your logged in user.

```
Row 46:
───────
query:    SELECT Timestamp, ServiceName, SeverityText, Body, TimestampTime FROM default.otel_logs WHERE (TimestampTime >= fromUnixTimestamp64Milli(_CAST(1769022372269, 'Int64'))) AND (TimestampTime <= fromUnixTimestamp64Milli(_CAST(1769023272269, 'Int64'))) ORDER BY (TimestampTime, Timestamp) DESC LIMIT _CAST(0, 'Int32'), _CAST(200, 'Int32') FORMAT JSONCompactEachRowWithNamesAndTypes
Settings: {'use_uncompressed_cache':'0','load_balancing':'in_order','log_queries':'1','max_memory_usage':'10000000000','cancel_http_readonly_queries_on_client_close':'1','parallel_replicas_for_cluster_engines':'0','date_time_output_format':'iso','hyperdx_user':'\'dan@hyperdx.io\''}
```
This commit is contained in:
Dan Hable 2026-01-28 08:58:05 -06:00 committed by GitHub
parent 66f56cb1d0
commit d07e30d5fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 289 additions and 62 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
---
Associates a logged in HyperDX user to the ClickHouse query recorded in the query log.

View file

@ -172,4 +172,6 @@
</distributed_ddl>
<format_schema_path>/var/lib/clickhouse/format_schemas/</format_schema_path>
<custom_settings_prefixes>hyperdx</custom_settings_prefixes>
</clickhouse>

View file

@ -27,10 +27,27 @@ export function updateConnection(
team: string,
connectionId: string,
connection: Omit<IConnection, 'id' | '_id'>,
unsetFields: string[] = [],
) {
return Connection.findOneAndUpdate({ _id: connectionId, team }, connection, {
new: true,
});
const updateOperation: Record<string, unknown> = { $set: connection };
if (unsetFields.length > 0) {
updateOperation.$unset = unsetFields.reduce(
(acc, field) => {
acc[field] = '';
return acc;
},
{} as Record<string, string>,
);
}
return Connection.findOneAndUpdate(
{ _id: connectionId, team },
updateOperation,
{
new: true,
},
);
}
export function deleteConnection(team: string, connectionId: string) {

View file

@ -11,6 +11,7 @@ export interface IConnection {
password: string;
username: string;
team: ObjectId;
hyperdxSettingPrefix?: string;
}
export default mongoose.model<IConnection>(
@ -29,6 +30,7 @@ export default mongoose.model<IConnection>(
type: String,
select: false,
},
hyperdxSettingPrefix: String,
},
{
timestamps: true,

View file

@ -7,10 +7,14 @@ import { CODE_VERSION } from '@/config';
import { getConnectionById } from '@/controllers/connection';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import { validateRequestHeaders } from '@/middleware/validation';
import logger from '@/utils/logger';
import { objectIdSchema } from '@/utils/zod';
const router = express.Router();
const CUSTOM_SETTING_KEY_SEP = '_';
const CUSTOM_SETTING_KEY_USER_SUFFIX = 'user';
router.post(
'/test',
validateRequest({
@ -94,6 +98,7 @@ const getConnection: RequestHandler =
name: connection.name,
password: connection.password,
username: connection.username,
hyperdxSettingPrefix: connection.hyperdxSettingPrefix,
};
next();
} catch (e) {
@ -110,8 +115,24 @@ const proxyMiddleware: RequestHandler =
pathFilter: (path, _req) => {
return _req.method === 'GET' || _req.method === 'POST';
},
pathRewrite: {
'^/clickhouse-proxy': '',
pathRewrite: function (path, req) {
// @ts-expect-error _req.query is type ParamQs, which doesn't play nicely with URLSearchParams. TODO: Replace with getting query params from _req.url eventually
const qparams = new URLSearchParams(req.query);
// Append user email as custom ClickHouse setting for query log annotation if the prefix was set
const hyperdxSettingPrefix = req._hdx_connection?.hyperdxSettingPrefix;
if (hyperdxSettingPrefix) {
const userEmail = req.user?.email;
if (userEmail) {
const userSettingKey = `${hyperdxSettingPrefix}${CUSTOM_SETTING_KEY_SEP}${CUSTOM_SETTING_KEY_USER_SUFFIX}`;
qparams.set(userSettingKey, userEmail);
} else {
logger.debug('hyperdxSettingPrefix set, no session user found');
}
}
const newPath = req.path.replace('^/clickhouse-proxy', '');
return `/${newPath}?${qparams.toString()}`;
},
router: _req => {
if (!_req._hdx_connection?.host) {
@ -124,9 +145,6 @@ const proxyMiddleware: RequestHandler =
// set user-agent to the hyperdx version identifier
proxyReq.setHeader('user-agent', `hyperdx ${CODE_VERSION}`);
// @ts-expect-error _req.query is type ParamQs, which doesn't play nicely with URLSearchParams. TODO: Replace with getting query params from _req.url eventually
const qparams = new URLSearchParams(_req.query);
if (_req._hdx_connection?.username) {
proxyReq.setHeader(
'X-ClickHouse-User',
@ -142,8 +160,6 @@ const proxyMiddleware: RequestHandler =
// TODO: Use fixRequestBody after this issue is resolved: https://github.com/chimurai/http-proxy-middleware/issues/1102
proxyReq.write(_req.body);
}
const newPath = _req.params[0];
proxyReq.path = `/${newPath}?${qparams}`;
},
proxyRes: (proxyRes, _req, res) => {
// since clickhouse v24, the cors headers * will be attached to the response by default

View file

@ -36,6 +36,7 @@ router.post(
...req.body,
password: req.body.password ?? '',
team: teamId,
hyperdxSettingPrefix: req.body.hyperdxSettingPrefix ?? undefined,
});
res.status(200).send({ id: connection._id.toString() });
@ -64,20 +65,32 @@ router.put(
return;
}
// Build the base connection update
const shouldUnsetPrefix =
req.body.hyperdxSettingPrefix === null ||
req.body.hyperdxSettingPrefix === '';
const { hyperdxSettingPrefix, ...restBody } = req.body;
const newConnection = {
...req.body,
...restBody,
team: teamId,
...(req.body.password
? { password: req.body.password }
: {
password: connection.password,
}),
// Only include hyperdxSettingPrefix if it's a valid string
...(!shouldUnsetPrefix && hyperdxSettingPrefix
? { hyperdxSettingPrefix }
: {}),
};
const updatedConnection = await updateConnection(
teamId.toString(),
req.params.id,
newConnection,
shouldUnsetPrefix ? ['hyperdxSettingPrefix'] : [],
);
if (!updatedConnection) {

View file

@ -2,8 +2,18 @@ import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { testLocalConnection } from '@hyperdx/common-utils/dist/clickhouse/browser';
import { Connection } from '@hyperdx/common-utils/dist/types';
import { Box, Button, Flex, Group, Stack, Text, Tooltip } from '@mantine/core';
import {
Anchor,
Box,
Button,
Flex,
Group,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconHelpCircle, IconSettings } from '@tabler/icons-react';
import api from '@/api';
import {
@ -131,6 +141,7 @@ export function ConnectionForm({
host: connection.host,
username: connection.username,
password: connection.password,
hyperdxSettingPrefix: connection.hyperdxSettingPrefix,
},
});
@ -140,9 +151,12 @@ export function ConnectionForm({
const onSubmit = (data: Connection) => {
// Make sure we don't save a trailing slash in the host
// Convert empty hyperdxSettingPrefix to null to signal clearing the field
// (undefined gets stripped from JSON, null is preserved and handled by API)
const normalizedData = {
...data,
host: stripTrailingSlash(data.host),
hyperdxSettingPrefix: data.hyperdxSettingPrefix || null,
};
if (isNew) {
@ -192,6 +206,7 @@ export function ConnectionForm({
};
const [showUpdatePassword, setShowUpdatePassword] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const { testConnectionState, handleTestConnection } = useTestConnection({
getValues,
@ -282,8 +297,100 @@ export function ConnectionForm({
</Flex>
)}
</Box>
<Box>
{!showAdvancedSettings && (
<Anchor
underline="always"
onClick={() => setShowAdvancedSettings(true)}
size="xs"
>
<Group gap="xs">
<IconSettings size={14} />
Advanced Settings
</Group>
</Anchor>
)}
{showAdvancedSettings && (
<Button
onClick={() => setShowAdvancedSettings(false)}
size="xs"
variant="subtle"
>
Hide Advanced Settings
</Button>
)}
</Box>
<Box
style={{
display: showAdvancedSettings ? 'block' : 'none',
}}
>
<Group gap="xs" mb="xs">
<Text size="xs">Query Log Setting Prefix</Text>
<Tooltip
label="Tracks query origins by adding the current user's email to ClickHouse queries (as {prefix}_user in system.query_log). Requires 'custom_settings_prefixes' in your ClickHouse config.xml to include this exact value, otherwise queries will be rejected."
color="dark"
c="white"
multiline
maw={400}
>
<IconHelpCircle size={16} className="cursor-pointer" />
</Tooltip>
</Group>
<InputControlled
data-testid="connection-setting-prefix-input"
name="hyperdxSettingPrefix"
control={control}
placeholder="hyperdx"
/>
</Box>
<Group justify="space-between">
<Group gap="xs" justify="flex-start">
<Tooltip
label="🔒 Password re-entry required for security"
position="right"
disabled={isNew}
withArrow
>
<Button
disabled={!formState.isValid}
variant={
testConnectionState === TestConnectionState.Invalid
? 'danger'
: 'secondary'
}
type="button"
onClick={handleTestConnection}
loading={testConnectionState === TestConnectionState.Loading}
>
{testConnectionState === TestConnectionState.Valid ? (
<>Connection successful</>
) : testConnectionState === TestConnectionState.Invalid ? (
<>Unable to connect</>
) : (
'Test Connection'
)}
</Button>
</Tooltip>
<Group gap="xs">
{onClose && showCancelButton && (
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
)}
{!isNew && showDeleteButton !== false && (
<ConfirmDeleteMenu
onDelete={() =>
deleteConnection.mutate(
{ id: connection.id },
{
onSuccess: () => {
onClose?.();
},
},
)
}
/>
)}
<Button
data-testid="connection-save-button"
variant="primary"
@ -292,54 +399,9 @@ export function ConnectionForm({
isNew ? createConnection.isPending : updateConnection.isPending
}
>
{isNew ? 'Create' : 'Save'}
{isNew ? 'Create' : 'Save'} Connection
</Button>
<Tooltip
label="🔒 Password re-entry required for security"
position="right"
disabled={isNew}
withArrow
>
<Button
disabled={!formState.isValid}
variant={
testConnectionState === TestConnectionState.Invalid
? 'danger'
: 'secondary'
}
type="button"
onClick={handleTestConnection}
loading={testConnectionState === TestConnectionState.Loading}
>
{testConnectionState === TestConnectionState.Valid ? (
<>Connection successful</>
) : testConnectionState === TestConnectionState.Invalid ? (
<>Unable to connect</>
) : (
'Test Connection'
)}
</Button>
</Tooltip>
</Group>
{!isNew && showDeleteButton !== false && (
<ConfirmDeleteMenu
onDelete={() =>
deleteConnection.mutate(
{ id: connection.id },
{
onSuccess: () => {
onClose?.();
},
},
)
}
/>
)}
{onClose && showCancelButton && (
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
)}
</Group>
</Stack>
</form>

View file

@ -67,9 +67,16 @@ describe('ConnectionForm', () => {
<ConnectionForm connection={baseConnection} isNew={true} />,
);
// Wait for form validation to complete
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Create/i }),
).toBeInTheDocument();
});
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
const nameInput = screen.getByPlaceholderText('My Clickhouse Server');
const submitButton = screen.getByRole('button', { name: 'Create' });
const submitButton = screen.getByRole('button', { name: /Create/i });
await fireEvent.change(nameInput, { target: { value: 'Test Name' } });
await fireEvent.change(hostInput, {
@ -104,8 +111,13 @@ describe('ConnectionForm', () => {
<ConnectionForm connection={existingConnection} isNew={false} />,
);
// Wait for form validation to complete
await waitFor(() => {
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
});
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
const submitButton = screen.getByRole('button', { name: 'Save' });
const submitButton = screen.getByRole('button', { name: /Save/i });
// Update host
await fireEvent.change(hostInput, {
@ -135,8 +147,15 @@ describe('ConnectionForm', () => {
renderWithMantine(
<ConnectionForm connection={baseConnection} isNew={true} />,
);
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
// Wait for form validation to complete
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Test Connection' }),
).toBeInTheDocument();
});
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
const nameInput = screen.getByPlaceholderText('My Clickhouse Server');
const testButton = screen.getByRole('button', { name: 'Test Connection' });
@ -162,4 +181,89 @@ describe('ConnectionForm', () => {
}),
);
});
it('should include hyperdxSettingPrefix when creating connection', async () => {
renderWithMantine(
<ConnectionForm connection={baseConnection} isNew={true} />,
);
// Wait for form validation to complete
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Create/i }),
).toBeInTheDocument();
});
const nameInput = screen.getByPlaceholderText('My Clickhouse Server');
const hostInput = screen.getByPlaceholderText('http://localhost:8123');
const submitButton = screen.getByRole('button', { name: /Create/i });
// Click "Advanced Settings" to reveal the setting prefix input
const advancedSettingsLink = screen.getByText('Advanced Settings');
fireEvent.click(advancedSettingsLink);
const settingPrefixInput = screen.getByPlaceholderText('hyperdx');
await fireEvent.change(nameInput, { target: { value: 'Test Name' } });
await fireEvent.change(hostInput, {
target: { value: 'http://example.com:8123' },
});
await fireEvent.change(settingPrefixInput, {
target: { value: 'myprefix' },
});
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockCreateMutate).toHaveBeenCalledWith(
expect.objectContaining({
connection: expect.objectContaining({
hyperdxSettingPrefix: 'myprefix',
}),
}),
expect.anything(),
);
});
});
it('should convert empty hyperdxSettingPrefix to null when updating', async () => {
const existingConnection = {
...baseConnection,
id: 'existing-id',
hyperdxSettingPrefix: 'oldprefix',
};
renderWithMantine(
<ConnectionForm connection={existingConnection} isNew={false} />,
);
// Wait for form validation to complete
await waitFor(() => {
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
});
// Click "Advanced Settings" to reveal the setting prefix input
const advancedSettingsLink = screen.getByText('Advanced Settings');
fireEvent.click(advancedSettingsLink);
const settingPrefixInput = screen.getByPlaceholderText('hyperdx');
const submitButton = screen.getByRole('button', { name: /Save/i });
// Clear the setting prefix
await fireEvent.change(settingPrefixInput, {
target: { value: '' },
});
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
connection: expect.objectContaining({
hyperdxSettingPrefix: null,
}),
}),
expect.anything(),
);
});
});
});

View file

@ -574,6 +574,11 @@ export const ConnectionSchema = z.object({
host: z.string(),
username: z.string(),
password: z.string().optional(),
hyperdxSettingPrefix: z
.string()
.regex(/^[a-z0-9_]+$/i)
.optional()
.nullable(),
});
export type Connection = z.infer<typeof ConnectionSchema>;