mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
66f56cb1d0
commit
d07e30d5fb
9 changed files with 289 additions and 62 deletions
6
.changeset/swift-hornets-reply.md
Normal file
6
.changeset/swift-hornets-reply.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue