feat: Store createdBy user ids on alert documents (#1058)

We now store `createdBy` in the `Alert` document when the alert
is created. This new field references the `User` document of the
currently logged in user. The property is optional to support
existing alerts in the database.

This information is also displayed in various places where alert
details are displayed. Older documents without the new field should
display nothing, resulting in the existing UX.
This commit is contained in:
Dan Hable 2025-08-12 10:21:17 -05:00 committed by GitHub
parent 345ff7e26f
commit c0b188c1c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 323 additions and 123 deletions

View file

@ -5,6 +5,6 @@
"fixed": [["@hyperdx/api", "@hyperdx/app"]],
"linked": [],
"access": "restricted",
"baseBranch": "v2",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
---
Track the user id who created alerts and display the information in the UI.

View file

@ -44,13 +44,14 @@ export type AlertInput = {
};
};
const makeAlert = (alert: AlertInput): Partial<IAlert> => {
const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
return {
channel: alert.channel,
interval: alert.interval,
source: alert.source,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
...(userId && { createdBy: userId }),
// Message template
// If they're undefined/null, set it to null so we clear out the field
@ -71,6 +72,7 @@ const makeAlert = (alert: AlertInput): Partial<IAlert> => {
export const createAlert = async (
teamId: ObjectId,
alertInput: z.infer<typeof alertSchema>,
userId: ObjectId,
) => {
if (alertInput.source === AlertSource.TILE) {
if ((await Dashboard.findById(alertInput.dashboardId)) == null) {
@ -85,7 +87,7 @@ export const createAlert = async (
}
return new Alert({
...makeAlert(alertInput),
...makeAlert(alertInput, userId),
team: teamId,
}).save();
};
@ -127,7 +129,7 @@ export const getTeamDashboardAlertsByTile = async (teamId: ObjectId) => {
const alerts = await Alert.find({
source: AlertSource.TILE,
team: teamId,
});
}).populate('createdBy', 'email name');
return groupBy(alerts, 'tileId');
};
@ -139,7 +141,7 @@ export const getDashboardAlertsByTile = async (
dashboard: dashboardId,
source: AlertSource.TILE,
team: teamId,
});
}).populate('createdBy', 'email name');
return groupBy(alerts, 'tileId');
};
@ -147,19 +149,26 @@ export const createOrUpdateDashboardAlerts = async (
dashboardId: ObjectId | string,
teamId: ObjectId,
alertsByTile: Record<string, AlertInput>,
userId?: ObjectId,
) => {
return Promise.all(
Object.entries(alertsByTile).map(async ([tileId, alert]) => {
return await Alert.findOneAndUpdate(
{
dashboard: dashboardId,
tileId,
source: AlertSource.TILE,
team: teamId,
},
alert,
{ new: true, upsert: true },
);
const filter = {
dashboard: dashboardId,
tileId,
source: AlertSource.TILE,
team: teamId,
};
const oldAlert = await Alert.findOne(filter);
const alertValues =
oldAlert && oldAlert.createdBy
? makeAlert(alert)
: makeAlert(alert, userId);
return await Alert.findOneAndUpdate(filter, alertValues, {
new: true,
upsert: true,
});
}),
);
};
@ -190,10 +199,11 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
return Alert.find({ team: teamId }).populate<{
savedSearch: ISavedSearch;
dashboard: IDashboard;
createdBy?: IUser;
silenced?: IAlert['silenced'] & {
by: IUser;
};
}>(['savedSearch', 'dashboard', 'silenced.by']);
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
};
export const deleteAlert = async (id: string, teamId: ObjectId) => {

View file

@ -60,6 +60,7 @@ export async function getDashboard(dashboardId: string, teamId: ObjectId) {
export async function createDashboard(
teamId: ObjectId,
dashboard: z.infer<typeof DashboardWithoutIdSchema>,
userId?: ObjectId,
) {
const newDashboard = await new Dashboard({
...dashboard,
@ -70,6 +71,7 @@ export async function createDashboard(
newDashboard._id,
teamId,
pickAlertsByTile(dashboard.tiles),
userId,
);
return newDashboard;
@ -89,6 +91,7 @@ export async function updateDashboard(
dashboardId: string,
teamId: ObjectId,
updates: Partial<z.infer<typeof DashboardWithoutIdSchema>>,
userId?: ObjectId,
) {
const oldDashboard = await getDashboard(dashboardId, teamId);
@ -114,13 +117,15 @@ export async function updateDashboard(
// Update related alerts
// - Delete
const newAlertIds = new Set(
updates.tiles?.map(t => t.config.alert?.id).filter(Boolean),
updates.tiles
?.map(t => (t.config.alert as any)?._id?.toString())
.filter(Boolean),
);
const deletedAlertIds: string[] = [];
const deletedAlertIds: string[] = [];
if (oldDashboard.tiles) {
for (const tile of oldDashboard.tiles) {
const alertId = tile.config.alert?.id;
const alertId = (tile.config.alert as any)?._id?.toString();
if (alertId && !newAlertIds.has(alertId)) {
deletedAlertIds.push(alertId);
}
@ -137,6 +142,7 @@ export async function updateDashboard(
dashboardId,
teamId,
pickAlertsByTile(updates.tiles),
userId,
);
}

View file

@ -1,10 +1,11 @@
import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types';
import { groupBy } from 'lodash';
import { groupBy, pick } from 'lodash';
import { z } from 'zod';
import { deleteSavedSearchAlerts } from '@/controllers/alerts';
import Alert from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
import type { IUser } from '@/models/user';
type SavedSearchWithoutId = Omit<z.infer<typeof SavedSearchSchema>, 'id'>;
@ -13,15 +14,16 @@ export async function getSavedSearches(teamId: string) {
const alerts = await Alert.find(
{ team: teamId, savedSearch: { $exists: true, $ne: null } },
{ __v: 0 },
);
).populate('createdBy', 'email name');
const alertsBySavedSearchId = groupBy(alerts, 'savedSearch');
return savedSearches.map(savedSearch => ({
...savedSearch.toJSON(),
alerts: alertsBySavedSearchId[savedSearch._id.toString()]
?.map(alert => alert.toJSON())
.map(({ _id, ...alert }) => ({ id: _id, ...alert })), // Remap _id to id
alerts: alertsBySavedSearchId[savedSearch._id.toString()]?.map(alert => {
const { _id, ...restAlert } = alert.toJSON();
return { id: _id, ...restAlert };
}),
}));
}

View file

@ -49,6 +49,7 @@ export interface IAlert {
team: ObjectId;
threshold: number;
thresholdType: AlertThresholdType;
createdBy?: ObjectId;
// Message template
name?: string | null;
@ -102,6 +103,11 @@ const AlertSchema = new Schema<IAlert>(
type: mongoose.Schema.Types.ObjectId,
ref: Team.modelName,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: false,
},
// Message template
name: {

View file

@ -5,6 +5,7 @@ import {
makeTile,
randomMongoId,
} from '@/fixtures';
import Alert from '@/models/alert';
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@ -97,6 +98,53 @@ describe('alerts router', () => {
expect(allAlerts.body.data[0].threshold).toBe(10);
});
it('preserves createdBy field during updates', async () => {
const { agent, user } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);
// Create an alert
const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
threshold: 5,
}),
)
.expect(200);
// Verify alert was created and contains the expected data
expect(alert.body.data.threshold).toBe(5);
// Get the alert directly from database to verify createdBy was set
const alertFromDb = await Alert.findById(alert.body.data._id);
expect(alertFromDb).toBeDefined();
expect(alertFromDb!.createdBy).toEqual(user._id);
expect(alertFromDb!.threshold).toBe(5);
// Update the alert with a different threshold
const updatedAlert = await agent
.put(`/alerts/${alert.body.data._id}`)
.send({
...alert.body.data,
dashboardId: dashboard.body.id, // because alert.body.data stores 'dashboard' instead of 'dashboardId'
threshold: 15, // Change threshold
})
.expect(200);
expect(updatedAlert.body.data.threshold).toBe(15);
// Get the alert from database again to verify createdBy is preserved
const alertFromDbAfterUpdate = await Alert.findById(alert.body.data._id);
expect(alertFromDbAfterUpdate).toBeDefined();
expect(alertFromDbAfterUpdate!.createdBy).toEqual(user._id); // ✅ createdBy should still be the original user
expect(alertFromDbAfterUpdate!.threshold).toBe(15); // ✅ threshold should be updated
});
it('has alerts attached to dashboards', async () => {
const { agent } = await getLoggedInAgent(server);

View file

@ -114,4 +114,38 @@ describe('savedSearch router', () => {
expect(savedSearches.body.length).toBe(0);
expect(await Alert.findById(alert.body.data._id)).toBeNull();
});
it('sets createdBy on alerts created from a saved search and populates it in list', async () => {
const { agent, user } = await getLoggedInAgent(server);
// Create a saved search
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
.expect(200);
// Create an alert associated to the saved search
const alert = await agent
.post('/alerts')
.send(
makeSavedSearchAlertInput({
savedSearchId: savedSearch.body._id,
}),
)
.expect(200);
// Verify createdBy was set on the alert document
const alertFromDb = await Alert.findById(alert.body.data._id);
expect(alertFromDb).toBeDefined();
expect(alertFromDb!.createdBy).toEqual(user._id);
// Verify GET /saved-search returns alerts with createdBy populated
const savedSearches = await agent.get('/saved-search').expect(200);
expect(savedSearches.body.length).toBe(1);
expect(savedSearches.body[0].alerts).toBeDefined();
expect(savedSearches.body[0].alerts.length).toBe(1);
expect(savedSearches.body[0].alerts[0].id).toBeDefined();
expect(savedSearches.body[0].alerts[0].createdBy).toBeDefined();
expect(savedSearches.body[0].alerts[0].createdBy.email).toBe(user.email);
});
});

View file

@ -48,6 +48,9 @@ router.get('/', async (req, res, next) => {
until: alert.silenced.until,
}
: undefined,
createdBy: alert.createdBy
? _.pick(alert.createdBy, ['email', 'name'])
: undefined,
channel: _.pick(alert.channel, ['type']),
...(alert.dashboard && {
dashboardId: alert.dashboard._id,
@ -95,13 +98,14 @@ router.post(
validateRequest({ body: alertSchema }),
async (req, res, next) => {
const teamId = req.user?.team;
if (teamId == null) {
const userId = req.user?._id;
if (teamId == null || userId == null) {
return res.sendStatus(403);
}
try {
const alertInput = req.body;
return res.json({
data: await createAlert(teamId, alertInput),
data: await createAlert(teamId, alertInput, userId),
});
} catch (e) {
next(e);

View file

@ -40,11 +40,11 @@ router.post(
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { teamId, userId } = getNonNullUserWithTeam(req);
const dashboard = req.body;
const newDashboard = await createDashboard(teamId, dashboard);
const newDashboard = await createDashboard(teamId, dashboard, userId);
res.json(newDashboard.toJSON());
} catch (e) {
@ -63,7 +63,7 @@ router.patch(
}),
async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { teamId, userId } = getNonNullUserWithTeam(req);
const { id: dashboardId } = req.params;
const dashboard = await getDashboard(dashboardId, teamId);
@ -78,6 +78,7 @@ router.patch(
dashboardId,
teamId,
updates,
userId,
);
res.json(updatedDashboard);

View file

@ -390,12 +390,13 @@ router.get('/', async (req, res, next) => {
*/
router.post('/', async (req, res, next) => {
const teamId = req.user?.team;
if (teamId == null) {
const userId = req.user?._id;
if (teamId == null || userId == null) {
return res.sendStatus(403);
}
try {
const alertInput = req.body;
const createdAlert = await createAlert(teamId, alertInput);
const createdAlert = await createAlert(teamId, alertInput, userId);
return res.json({
data: translateAlertDocumentToExternalAlert(createdAlert),

View file

@ -692,17 +692,22 @@ describe('checkAlerts', () => {
source: source.id,
tags: ['test'],
}).save();
const alert = await createAlert(team._id, {
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(
team._id,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
});
mockUserId,
);
const enhancedAlert: any = await Alert.findById(alert._id).populate([
'team',
@ -867,18 +872,23 @@ describe('checkAlerts', () => {
},
],
}).save();
const alert = await createAlert(team._id, {
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(
team._id,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
});
mockUserId,
);
const enhancedAlert: any = await Alert.findById(alert._id).populate([
'team',
@ -1035,18 +1045,23 @@ describe('checkAlerts', () => {
},
],
}).save();
const alert = await createAlert(team._id, {
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(
team._id,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
});
mockUserId,
);
const enhancedAlert: any = await Alert.findById(alert._id).populate([
'team',
@ -1185,18 +1200,23 @@ describe('checkAlerts', () => {
},
],
}).save();
const alert = await createAlert(team._id, {
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(
team._id,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: '17quud',
});
mockUserId,
);
const enhancedAlert: any = await Alert.findById(alert._id).populate([
'team',

View file

@ -1,4 +1,5 @@
import { createServer } from 'http';
import mongoose from 'mongoose';
import ms from 'ms';
import * as config from '@/config';
@ -130,18 +131,23 @@ describe('Single Invocation Alert Test', () => {
}).save();
// Create alert
const alert = await createAlert(team._id, {
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(
team._id,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
name: 'Test Alert Name',
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
name: 'Test Alert Name',
});
mockUserId,
);
// Insert test logs that will trigger the alert
const now = new Date('2023-11-16T22:12:00.000Z');

View file

@ -183,6 +183,14 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
<div className="text-slate-400 fs-8 d-flex gap-2">
{alertType}
{notificationMethod}
{alert.createdBy && (
<>
<span className="text-slate-400">&middot;</span>
<span>
Created by {alert.createdBy.name || alert.createdBy.email}
</span>
</>
)}
</div>
</Stack>
</Group>

View file

@ -43,7 +43,7 @@ import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import api from './api';
import { SearchConfig } from './types';
import { AlertWithCreatedBy, SearchConfig } from './types';
import { optionsToSelectData } from './utils';
const SavedSearchAlertFormSchema = z
@ -76,7 +76,7 @@ const AlertForm = ({
where?: SearchCondition | null;
whereLanguage?: SearchConditionLanguage | null;
select?: string | null;
defaultValues?: null | Alert;
defaultValues?: null | AlertWithCreatedBy;
loading?: boolean;
deleteLoading?: boolean;
hasSavedSearch?: boolean;
@ -185,6 +185,22 @@ const AlertForm = ({
</Accordion.Item>
</Accordion>
{defaultValues?.createdBy && (
<Paper px="md" py="sm" bg="dark.6" radius="xs" mt="sm">
<Text size="xxs" opacity={0.5} mb={4}>
Created by
</Text>
<Text size="sm" opacity={0.8}>
{defaultValues.createdBy.name || defaultValues.createdBy.email}
</Text>
{defaultValues.createdBy.name && (
<Text size="xs" opacity={0.6}>
{defaultValues.createdBy.email}
</Text>
)}
</Paper>
)}
<Group mt="lg" justify="space-between" gap="xs">
<div>
{defaultValues && (

View file

@ -768,39 +768,50 @@ export default function EditTimeChartForm({
<Paper my="sm">
<Stack gap="xs">
<Paper px="md" py="sm" bg="dark.6" radius="xs">
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
min={MINIMUM_THRESHOLD_VALUE}
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
<Group gap="xs" justify="space-between">
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Alert when the value
</Text>
<NativeSelect
data={optionsToSelectData(
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
)}
size="xs"
name={`alert.thresholdType`}
control={control}
/>
<NumberInput
min={MINIMUM_THRESHOLD_VALUE}
size="xs"
w={80}
control={control}
name={`alert.threshold`}
/>
over
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
size="xs"
name={`alert.interval`}
control={control}
/>
<Text size="sm" opacity={0.7}>
window via
</Text>
<NativeSelect
data={optionsToSelectData(ALERT_CHANNEL_OPTIONS)}
size="xs"
name={`alert.channel.type`}
control={control}
/>
</Group>
{(alert as any)?.createdBy && (
<Text size="xs" opacity={0.6}>
Created by{' '}
{(alert as any).createdBy?.name ||
(alert as any).createdBy?.email}
</Text>
)}
</Group>
<Text size="xxs" opacity={0.5} mb={4} mt="xs">
Send to

View file

@ -9,6 +9,7 @@ import {
import { hdxServer } from './api';
import { IS_LOCAL_MODE } from './config';
import { SavedSearchWithEnhancedAlerts } from './types';
export function useSavedSearches() {
return useQuery({
@ -17,7 +18,9 @@ export function useSavedSearches() {
if (IS_LOCAL_MODE) {
return [];
} else {
return hdxServer('saved-search').json<SavedSearch[]>();
return hdxServer('saved-search').json<
SavedSearchWithEnhancedAlerts[]
>();
}
},
});
@ -25,7 +28,10 @@ export function useSavedSearches() {
export function useSavedSearch(
{ id }: { id: string },
options: Omit<Partial<UseQueryOptions<SavedSearch[], Error>>, 'select'> = {},
options: Omit<
Partial<UseQueryOptions<SavedSearchWithEnhancedAlerts[], Error>>,
'select'
> = {},
) {
return useQuery({
queryKey: ['saved-search'],
@ -33,7 +39,7 @@ export function useSavedSearch(
if (IS_LOCAL_MODE) {
return [];
}
return hdxServer('saved-search').json<SavedSearch[]>();
return hdxServer('saved-search').json<SavedSearchWithEnhancedAlerts[]>();
},
select: data => data.find(s => s.id === id),
...options,

View file

@ -53,10 +53,25 @@ export type AlertsPageItem = Alert & {
history: AlertHistory[];
dashboard?: ServerDashboard;
savedSearch?: SavedSearch;
createdBy?: {
email: string;
name?: string;
};
};
export type AlertWithCreatedBy = Alert & {
createdBy?: {
email: string;
name?: string;
};
};
export type SavedSearch = z.infer<typeof SavedSearchSchema>;
export type SavedSearchWithEnhancedAlerts = Omit<SavedSearch, 'alerts'> & {
alerts?: AlertWithCreatedBy[];
};
export type SearchConfig = {
select?: string | null;
source?: string | null;