-
Notifications
You must be signed in to change notification settings - Fork 458
Expand file tree
/
Copy pathindex.ts
More file actions
2078 lines (1939 loc) · 69.5 KB
/
Copy pathindex.ts
File metadata and controls
2078 lines (1939 loc) · 69.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// --------------------------------------------------------
// -------------- EXECUTE EVERY MINUTE --------------------
// --------------------------------------------------------
import PQueue from '@esm2cjs/p-queue';
import * as clickhouse from '@hyperdx/common-utils/dist/clickhouse';
import {
chSqlToAliasMap,
ResponseJSON,
} from '@hyperdx/common-utils/dist/clickhouse';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { tryOptimizeConfigWithMaterializedView } from '@hyperdx/common-utils/dist/core/materializedViews';
import {
getMetadata,
Metadata,
} from '@hyperdx/common-utils/dist/core/metadata';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
ALERT_COUNT_DEFAULT_SELECT,
buildSearchChartConfig,
} from '@hyperdx/common-utils/dist/core/searchChartConfig';
import {
aliasMapToWithClauses,
displayTypeSupportsRawSqlAlerts,
isTimeSeriesDisplayType,
} from '@hyperdx/common-utils/dist/core/utils';
import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils';
import { getDashboardVariableDeclarations } from '@hyperdx/common-utils/dist/filters';
import {
isBuilderChartConfig,
isBuilderSavedChartConfig,
isPromqlSavedChartConfig,
isRawSqlChartConfig,
isRawSqlSavedChartConfig,
} from '@hyperdx/common-utils/dist/guards';
import {
ALERT_NOTIFICATION_TARGETS_LIMIT,
AlertErrorType,
AlertNotificationTargetTiming,
AlertThresholdType,
BuilderChartConfigWithOptDateRange,
ChartConfigWithOptDateRange,
DisplayType,
getSampleWeightExpression,
pickSampleWeightExpressionProps,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import * as fns from 'date-fns';
import { isString, pick } from 'lodash';
import { ObjectId } from 'mongoose';
import mongoose from 'mongoose';
import ms from 'ms';
import { performance } from 'perf_hooks';
import { serializeError } from 'serialize-error';
import { ALERT_HISTORY_QUERY_CONCURRENCY } from '@/controllers/alertHistory';
import { AlertState, IAlert, IAlertError } from '@/models/alert';
import AlertHistory, {
IAlertHistory,
IAlertHistoryAnalytics,
} from '@/models/alertHistory';
import { IDashboard } from '@/models/dashboard';
import { ISavedSearch } from '@/models/savedSearch';
import { ISource } from '@/models/source';
import { IWebhook } from '@/models/webhook';
import {
isClientTimeoutOrAbortError,
isQueryTimeoutError,
NotificationCapExceededError,
UnsupportedMentionError,
WEBHOOK_REDIRECT_ERROR_MESSAGE,
WebhookNotFoundError,
WebhookRedirectError,
} from '@/tasks/checkAlerts/errors';
import {
AlertDetails,
AlertProvider,
AlertTask,
AlertTaskType,
loadProvider,
} from '@/tasks/checkAlerts/providers';
import {
AlertMessageTemplateDefaultView,
buildAlertMessageTemplateTitle,
NotificationFailure,
NotificationTiming,
renderAlertTemplate,
RenderedAlert,
} from '@/tasks/checkAlerts/template';
import { handleSendGenericWebhook } from '@/tasks/checkAlerts/transports';
import { tasksTracer } from '@/tasks/tracer';
import { CheckAlertsTaskArgs, HdxTask } from '@/tasks/types';
import {
calcAlertDateRange,
roundDownToXMinutes,
unflattenObject,
} from '@/tasks/util';
import {
getCounter,
type OperationOutcome,
recordOperationOutcome,
setBusinessContext,
SpanStatusCode,
withSpan,
} from '@/utils/instrumentation';
import logger from '@/utils/logger';
// Outcome of a single alert evaluation. Kept low-cardinality (a fixed enum) so
// it is safe to use as a metric attribute (see agent_docs/observability.md).
const alertEvaluationsCounter = getCounter('hyperdx.alerts.evaluations', {
description:
'Count of alert evaluations, labeled by outcome (fired, resolved, or the reason it was skipped).',
});
const alertQueryFailuresCounter = getCounter('hyperdx.alerts.query_failures', {
description:
'Count of alert evaluations where the ClickHouse query failed, skipping the state/history update.',
});
const alertProcessFailuresCounter = getCounter(
'hyperdx.alerts.process_failures',
{
description:
'Count of alert evaluations that threw an unexpected error during processing.',
},
);
const alertBatchFailuresCounter = getCounter('hyperdx.alerts.batch_failures', {
description:
'Count of alert batches (one per connection) that failed before their alerts could be evaluated, e.g. a ClickHouse connection failure.',
});
/**
* Determine if an alert has group-by behavior.
* For saved search alerts, groupBy is on alert.groupBy.
* For tile alerts, groupBy is on tile.config.groupBy.
*/
export const alertHasGroupBy = (details: AlertDetails): boolean => {
const { alert } = details;
if (alert.groupBy && alert.groupBy.length > 0) {
return true;
}
if (
details.taskType === AlertTaskType.TILE &&
isBuilderSavedChartConfig(details.tile.config) &&
details.tile.config.groupBy &&
details.tile.config.groupBy.length > 0
) {
return true;
}
// Without a reliable parser, it's difficult to tell if the raw sql contains a
// group by (besides the group by on the interval), so we'll assume it might
// in the case of time series charts, and assume it will not in the case of number charts.
// Group name will just be blank if there are no group by values.
if (
details.taskType === AlertTaskType.TILE &&
isRawSqlSavedChartConfig(details.tile.config)
) {
return details.tile.config.displayType !== DisplayType.Number;
}
return false;
};
/**
* Render a saved search's SELECT to discover column aliases (e.g. `toString(Body) AS body`)
* and return them as WITH clauses that can be injected into alert/sample-log queries
* whose own SELECT doesn't include those aliases.
*/
export async function computeAliasWithClauses(
savedSearch: Pick<ISavedSearch, 'select' | 'where' | 'whereLanguage'>,
source: ISource,
metadata: Metadata,
): Promise<BuilderChartConfigWithOptDateRange['with']> {
const resolvedSelect =
savedSearch.select ||
((source.kind === SourceKind.Log || source.kind === SourceKind.Trace) &&
source.defaultTableSelectExpression) ||
'';
const config: BuilderChartConfigWithOptDateRange = {
connection: '',
displayType: DisplayType.Search,
from: source.from,
select: resolvedSelect,
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
implicitColumnExpression:
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
? source.implicitColumnExpression
: undefined,
useTextIndexForImplicitColumn:
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
? source.useTextIndexForImplicitColumn
: undefined,
...pickSampleWeightExpressionProps(source),
timestampValueExpression: source.timestampValueExpression,
};
const query = await renderChartConfig(config, metadata, source.querySettings);
const aliasMap = chSqlToAliasMap(query);
return aliasMapToWithClauses(aliasMap);
}
class InvalidAlertError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidAlertError';
}
}
// For security, we do not surface raw error messages for webhook or unknown
// failures — they may leak URLs, response bodies, or other sensitive detail
// from upstream systems. QUERY_ERROR and INVALID_ALERT messages are authored
// by us (ClickHouse errors or our own validation) and are safe to display.
const HARDCODED_ALERT_ERROR_MESSAGES: Partial<Record<AlertErrorType, string>> =
{
[AlertErrorType.WEBHOOK_ERROR]:
'Failed to send webhook notification. Check the webhook configuration and destination.',
[AlertErrorType.UNKNOWN]:
'An unknown error occurred while processing the alert.',
};
const makeAlertError = (
type: AlertErrorType,
message: string,
): IAlertError => ({
timestamp: new Date(),
type,
message: (HARDCODED_ALERT_ERROR_MESSAGES[type] ?? message).slice(0, 10000),
});
const getErrorMessage = (e: unknown): string => {
if (e instanceof Error) {
return e.message;
}
return String(e);
};
const QUERY_TIMEOUT_RETRY_NOTE =
'The evaluation is retried on every check, but the alert will not fire until the query completes in time.';
/**
* Build the IAlertError for a failed alert query, classifying timeouts
* (client request timeout/abort, server-side TIMEOUT_EXCEEDED, socket
* timeouts) separately from other query errors so the message is actionable.
*/
const makeQueryAlertError = (
e: unknown,
requestTimeoutMs: number,
): IAlertError => {
if (!isQueryTimeoutError(e)) {
return makeAlertError(AlertErrorType.QUERY_ERROR, getErrorMessage(e));
}
// For the client's own request timeout we know the configured limit; for
// server-side timeouts the original ClickHouse message carries the limit.
const message = isClientTimeoutOrAbortError(e)
? `Alert query did not complete within the ${Math.round(requestTimeoutMs / 1000)}s evaluation timeout. ${QUERY_TIMEOUT_RETRY_NOTE}`
: `Alert query timed out before completing: ${getErrorMessage(e)}. ${QUERY_TIMEOUT_RETRY_NOTE}`;
return makeAlertError(AlertErrorType.QUERY_TIMEOUT, message);
};
// Most webhook errors show a hardcoded message to avoid leaking sensitive request details in the UI.
// Redirect errors are a known class of errors which we want to surface to the user, so it has a specific message.
const makeWebhookAlertError = (error: unknown): IAlertError => {
if (error instanceof WebhookRedirectError) {
return {
timestamp: new Date(),
type: AlertErrorType.WEBHOOK_ERROR,
message: WEBHOOK_REDIRECT_ERROR_MESSAGE,
};
}
return makeAlertError(AlertErrorType.WEBHOOK_ERROR, getErrorMessage(error));
};
// Per-target variant: names the target so a multi-channel alert's errors are
// attributable. Two kinds of failure reach here (see renderAlertTemplate):
// pre-dispatch (unresolvable mention/webhook, the per-event cap) and, for the
// inline dispatcher, an actual delivery rejection. Raw upstream detail stays
// hidden (same policy as makeWebhookAlertError); timeout and not-found
// messages are authored by us.
const makeNotificationAlertError = (
failure: NotificationFailure,
): IAlertError => {
const target = `${failure.type} "${failure.target}"`;
const timestamp = new Date();
if (failure.error instanceof UnsupportedMentionError) {
return {
timestamp,
type: AlertErrorType.WEBHOOK_ERROR,
message: failure.error.message.slice(0, 10000),
};
}
if (failure.error instanceof NotificationCapExceededError) {
return {
timestamp,
type: AlertErrorType.WEBHOOK_ERROR,
message: `${failure.error.message} (${target})`.slice(0, 10000),
};
}
if (failure.error instanceof WebhookNotFoundError) {
// Name the target like every other branch — with several channels, "a
// webhook was deleted" is useless unless it says which one.
return {
timestamp,
type: AlertErrorType.WEBHOOK_ERROR,
message: `${failure.error.message} (${target})`.slice(0, 10000),
};
}
if (failure.error instanceof WebhookRedirectError) {
return {
timestamp,
type: AlertErrorType.WEBHOOK_ERROR,
message: `${WEBHOOK_REDIRECT_ERROR_MESSAGE} (${target})`.slice(0, 10000),
};
}
// A delivery rejection from the inline dispatcher — the only case left.
return {
timestamp,
type: AlertErrorType.WEBHOOK_ERROR,
message:
`Failed to send notification to ${target}. Check the webhook configuration and destination.`.slice(
0,
10000,
),
};
};
export const doesExceedThreshold = (
{
threshold,
thresholdType,
thresholdMax,
}: Pick<IAlert, 'thresholdType' | 'threshold' | 'thresholdMax'>,
value: number,
) => {
switch (thresholdType) {
case AlertThresholdType.ABOVE:
return value >= threshold;
case AlertThresholdType.BELOW:
return value < threshold;
case AlertThresholdType.ABOVE_EXCLUSIVE:
return value > threshold;
case AlertThresholdType.BELOW_OR_EQUAL:
return value <= threshold;
case AlertThresholdType.EQUAL:
return value === threshold;
case AlertThresholdType.NOT_EQUAL:
return value !== threshold;
case AlertThresholdType.BETWEEN:
case AlertThresholdType.NOT_BETWEEN:
if (thresholdMax == null) {
throw new InvalidAlertError(
`thresholdMax is required for threshold type "${thresholdType}"`,
);
}
return thresholdType === AlertThresholdType.BETWEEN
? value >= threshold && value <= thresholdMax
: value < threshold || value > thresholdMax;
}
};
const normalizeScheduleOffsetMinutes = ({
alertId,
scheduleOffsetMinutes,
windowSizeInMins,
}: {
alertId: string;
scheduleOffsetMinutes: number | undefined;
windowSizeInMins: number;
}) => {
if (scheduleOffsetMinutes == null) {
return 0;
}
if (!Number.isFinite(scheduleOffsetMinutes)) {
return 0;
}
const normalized = Math.max(0, Math.floor(scheduleOffsetMinutes));
if (normalized < windowSizeInMins) {
return normalized;
}
const scheduleOffsetInMins = normalized % windowSizeInMins;
logger.warn(
{
alertId,
scheduleOffsetMinutes,
normalizedScheduleOffsetMinutes: scheduleOffsetInMins,
windowSizeInMins,
},
'scheduleOffsetMinutes is greater than or equal to the interval and was normalized',
);
return scheduleOffsetInMins;
};
const normalizeScheduleStartAt = ({
alertId,
scheduleStartAt,
}: {
alertId: string;
scheduleStartAt: IAlert['scheduleStartAt'];
}) => {
if (scheduleStartAt == null) {
return undefined;
}
if (fns.isValid(scheduleStartAt)) {
return scheduleStartAt;
}
logger.warn(
{
alertId,
scheduleStartAt,
},
'Invalid scheduleStartAt value detected, ignoring start time schedule',
);
return undefined;
};
export const getScheduledWindowStart = (
now: Date,
windowSizeInMins: number,
scheduleOffsetMinutes = 0,
scheduleStartAt?: Date,
) => {
if (scheduleStartAt != null) {
const windowSizeMs = windowSizeInMins * 60 * 1000;
const elapsedMs = Math.max(0, now.getTime() - scheduleStartAt.getTime());
const windowCountSinceStart = Math.floor(elapsedMs / windowSizeMs);
return new Date(
scheduleStartAt.getTime() + windowCountSinceStart * windowSizeMs,
);
}
if (scheduleOffsetMinutes <= 0) {
return roundDownToXMinutes(windowSizeInMins)(now);
}
const shiftedNow = fns.subMinutes(now, scheduleOffsetMinutes);
const roundedShiftedNow = roundDownToXMinutes(windowSizeInMins)(shiftedNow);
return fns.addMinutes(roundedShiftedNow, scheduleOffsetMinutes);
};
/**
* Compute the scheduled window start ("now rounded down to the window") for an
* alert at the given time. This mirrors the computation inside processAlert so
* that history fetched up-front (see getConsecutiveWindowHistories) lines up
* exactly with the window processAlert evaluates.
*/
const getAlertWindowStart = (alert: IAlert, now: Date): Date => {
const windowSizeInMins = ms(alert.interval) / 60000;
const scheduleStartAt = normalizeScheduleStartAt({
alertId: alert.id,
scheduleStartAt: alert.scheduleStartAt,
});
const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({
alertId: alert.id,
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
windowSizeInMins,
});
return getScheduledWindowStart(
now,
windowSizeInMins,
scheduleOffsetMinutes,
scheduleStartAt,
);
};
const fireChannelEvent = async ({
alert,
alertProvider,
attributes,
clickhouseClient,
dashboard,
endTime,
group,
isGroupedAlert,
metadata,
savedSearch,
source,
startTime,
state,
totalCount,
windowSizeInMins,
teamWebhooksById,
}: {
alert: IAlert;
alertProvider: AlertProvider;
attributes: Record<string, string>; // TODO: support other types than string
clickhouseClient: ClickhouseClient;
dashboard?: IDashboard | null;
endTime: Date;
group?: string;
isGroupedAlert: boolean;
metadata: Metadata;
savedSearch?: ISavedSearch | null;
source?: ISource | null;
startTime: Date;
state: AlertState;
totalCount: number;
windowSizeInMins: number;
teamWebhooksById: Map<string, IWebhook>;
}): Promise<Pick<RenderedAlert, 'failures' | 'timings'>> => {
const team = alert.team;
if (team == null) {
throw new Error('Team not found');
}
// alert.team is typed as a bare ObjectId, but a caller that populated it
// (int-test setups do `.populate(['team', ...])`; the production path never
// does) hands us a full Team document instead — Mongoose documents don't
// override toString(), so calling it directly would silently stringify to
// "[object Object]" rather than the hex id. Prefer the populated
// document's own _id when present.
const isPopulatedWithId = (value: unknown): value is { _id: ObjectId } =>
typeof value === 'object' && value !== null && '_id' in value;
const teamId = (isPopulatedWithId(team) ? team._id : team).toString();
const attributesNested = unflattenObject(attributes);
const templateView: AlertMessageTemplateDefaultView = {
alert: {
id: alert.id,
channel: alert.channel,
channels: alert.channels,
dashboardId: dashboard?.id,
groupBy: alert.groupBy,
interval: alert.interval,
...(alert.scheduleOffsetMinutes != null && {
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
}),
...(alert.scheduleStartAt != null && {
scheduleStartAt: alert.scheduleStartAt.toISOString(),
}),
message: alert.message,
name: alert.name,
savedSearchId: savedSearch?.id,
silenced: alert.silenced,
source: alert.source,
threshold: alert.threshold,
thresholdMax: alert.thresholdMax,
thresholdType: alert.thresholdType,
tileId: alert.tileId,
},
attributes: attributesNested,
dashboard,
endTime,
granularity: `${windowSizeInMins} minute`,
group,
isGroupedAlert,
savedSearch,
source,
startTime,
value: totalCount,
};
const { failures, timings } = await renderAlertTemplate({
alertProvider,
clickhouseClient,
metadata,
state,
title: buildAlertMessageTemplateTitle({
template: alert.name,
view: templateView,
state,
}),
template: alert.message,
view: templateView,
teamId,
teamWebhooksById,
});
return { failures, timings };
};
// Use a delimiter that's unlikely to appear in alert IDs or group names
// MongoDB ObjectIds are hex strings (0-9, a-f), so pipes are safe
const ALERT_GROUP_DELIMITER = '||';
/**
* Get the alert key prefix for filtering grouped alert histories.
* Returns "alertId||" which is used to match all group keys for this alert.
*/
const getAlertKeyPrefix = (alertId: string): string => {
return `${alertId}${ALERT_GROUP_DELIMITER}`;
};
/**
* Compute a composite map key for tracking alert history per group.
* For non-grouped alerts, returns just the alertId.
* For grouped alerts, returns "alertId||groupKey" to track per-group state.
* Uses || as delimiter since it's unlikely to appear in alert IDs (MongoDB ObjectIds)
* or in typical group key values.
*/
const computeHistoryMapKey = (alertId: string, groupKey: string): string => {
return groupKey ? `${getAlertKeyPrefix(alertId)}${groupKey}` : alertId;
};
/**
* Extract the group key from a composite history map key.
* Safely handles group names that may contain colons or other special characters
* by using the alert ID prefix with the delimiter to identify the split point.
*/
const extractGroupKeyFromMapKey = (mapKey: string, alertId: string): string => {
const alertIdPrefix = getAlertKeyPrefix(alertId);
return mapKey.startsWith(alertIdPrefix)
? mapKey.substring(alertIdPrefix.length)
: '';
};
/** Determine if we should skip the alert check based on how recently it was last evaluated. */
const shouldSkipAlertCheck = (
details: AlertDetails,
hasGroupBy: boolean,
nowInMinsRoundDown: Date,
) => {
const { alert, previousMap } = details;
const alertKeyPrefix = getAlertKeyPrefix(alert.id);
// Skip if ANY previous history for this alert was created in the current window
return Array.from(previousMap.entries()).some(([key, history]) => {
// For grouped alerts, check any key that starts with alertId prefix
// or matches the bare alertId (empty group key case).
// For non-grouped alerts, check exact match with alertId.
const isMatchingKey = hasGroupBy
? key === alert.id || key.startsWith(alertKeyPrefix)
: key === alert.id;
return (
isMatchingKey &&
fns.getTime(history.createdAt) === fns.getTime(nowInMinsRoundDown)
);
});
};
/** Get the date range for evaluating the alert */
const getAlertEvaluationDateRange = (
{ alert, previousMap }: AlertDetails,
hasGroupBy: boolean,
nowInMinsRoundDown: Date,
windowSizeInMins: number,
scheduleStartAt?: Date,
) => {
// Calculate date range for the query
// Find the latest createdAt among all histories for this alert
let previousCreatedAt: Date | undefined;
if (hasGroupBy) {
// For grouped alerts, find the latest createdAt among all groups.
// Also check the bare alertId key for the empty group key case.
const alertKeyPrefix = getAlertKeyPrefix(alert.id);
for (const [key, history] of previousMap.entries()) {
if (key === alert.id || key.startsWith(alertKeyPrefix)) {
if (!previousCreatedAt || history.createdAt > previousCreatedAt) {
previousCreatedAt = history.createdAt;
}
}
}
} else {
// For non-grouped alerts, get the single history
const previous = previousMap.get(alert.id);
previousCreatedAt = previous?.createdAt;
}
const rawStartTime = previousCreatedAt
? previousCreatedAt.getTime()
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime();
const clampedStartTime =
scheduleStartAt == null
? rawStartTime
: Math.max(rawStartTime, scheduleStartAt.getTime());
return calcAlertDateRange(
clampedStartTime,
nowInMinsRoundDown.getTime(),
windowSizeInMins,
);
};
const getChartConfigFromAlert = (
details: AlertDetails,
connection: string,
dateRange: [Date, Date],
windowSizeInMins: number,
): ChartConfigWithOptDateRange | undefined => {
const { alert } = details;
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
const { source } = details;
const savedSearch = details.savedSearch;
// Delegate to the shared builder (in @hyperdx/common-utils) so the alert
// task, the alert preview chart, and the main app search page all
// assemble saved-search chart configs identically — keeping source-level
// fields like `tableFilterExpression` applied uniformly across paths.
return buildSearchChartConfig(source, {
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
filters: savedSearch.filters?.map(f => ({ ...f })),
groupBy: alert.groupBy,
select: ALERT_COUNT_DEFAULT_SELECT,
displayType: DisplayType.Line,
connection,
dateRange,
dateRangeStartInclusive: true,
dateRangeEndInclusive: false,
granularity: `${windowSizeInMins} minute`,
});
} else if (details.taskType === AlertTaskType.TILE) {
const tile = details.tile;
// Substitute empty selections for each variable the dashboard defines
const variables = getDashboardVariableDeclarations(
details.dashboard.filters,
).map(declaration => ({
...declaration,
values: [],
}));
// Raw SQL tiles: build a RawSqlChartConfig
if (isRawSqlSavedChartConfig(tile.config)) {
if (displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
return {
...pick(tile.config, [
'configType',
'sqlTemplate',
'displayType',
'source',
]),
connection,
dateRange,
variables,
// Only time-series charts use interval bucketing
...(isTimeSeriesDisplayType(tile.config.displayType) && {
granularity: `${windowSizeInMins} minute`,
}),
// Include source metadata for macro expansion ($__sourceTable)
...(details.source && {
from: details.source.from,
metricTables:
details.source.kind === SourceKind.Metric
? details.source.metricTables
: undefined,
}),
};
}
return undefined;
}
// PromQL tiles don't support alerts yet
if (isPromqlSavedChartConfig(tile.config)) {
return undefined;
}
const { source } = details;
if (!source) {
logger.error(
{ alertId: alert.id },
'Source not found for builder tile alert',
);
return undefined;
}
// Doesn't work for metric alerts yet
if (
tile.config.displayType === DisplayType.Line ||
tile.config.displayType === DisplayType.StackedBar ||
tile.config.displayType === DisplayType.Number
) {
// Tile alerts can use Log, Trace, or Metric sources.
// implicitColumnExpression+useTextIndexForImplicitColumn exist on Log and Trace sources;
// metricTables exists on Metric sources.
const implicitColumnExpression =
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
? source.implicitColumnExpression
: undefined;
const useTextIndexForImplicitColumn =
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
? source.useTextIndexForImplicitColumn
: undefined;
const sampleWeightExpression = getSampleWeightExpression(source);
const metricTables =
source.kind === SourceKind.Metric ? source.metricTables : undefined;
return {
connection,
dateRange,
dateRangeStartInclusive: true,
dateRangeEndInclusive: false,
displayType: tile.config.displayType,
from: source.from,
granularity: `${windowSizeInMins} minute`,
groupBy: tile.config.groupBy,
implicitColumnExpression,
useTextIndexForImplicitColumn,
sampleWeightExpression,
metricTables,
select: tile.config.select,
timestampValueExpression: source.timestampValueExpression,
where: tile.config.where,
whereLanguage: tile.config.whereLanguage,
seriesReturnType: tile.config.seriesReturnType,
// Grouped ratios can divide per-group or share-of-total; without this
// the alert would silently evaluate the default (per-group) mode.
ratioMode: tile.config.ratioMode,
// Metric formulas (HDX-5080): the alert must evaluate the derived
// formula column, not a raw operand series. Operand columns are
// always dropped from the alert query — regardless of the tile's
// "Show input series" display toggle — so the formula is the value
// column parseAlertData picks (the last one wins, consistent with
// the multi-series "last series drives the alert" semantics).
formulas: tile.config.formulas,
...(tile.config.formulas?.length ? { showOperandSeries: false } : {}),
variables,
};
}
}
logger.error(
{
alertId: alert.id,
},
`Unsupported alert source: ${alert.source}`,
);
return undefined;
};
type ResponseMetadata =
| {
type: 'time_series';
timestampColumnName: string;
valueColumnNames: Set<string>;
}
| {
type: 'single_value';
valueColumnNames: Set<string>;
};
const getResponseMetadata = (
chartConfig: ChartConfigWithOptDateRange,
data: ResponseJSON<Record<string, string | number>>,
): ResponseMetadata | undefined => {
if (!data?.meta) {
return undefined;
}
// attach JS type
const meta =
data.meta?.map(m => ({
...m,
jsType: clickhouse.convertCHDataTypeToJSType(m.type),
})) ?? [];
const valueColumnNames = new Set(
meta
.filter(m => m.jsType === clickhouse.JSDataType.Number)
.map(m => m.name),
);
if (valueColumnNames.size === 0) {
logger.error({ meta }, 'Failed to find value column');
return undefined;
}
// Raw SQL charts with Number display type don't use interval parameters, so they cannot be treated as timeseries.
// Number-type Builder Charts are rendered as time-series, to maintain legacy behavior for existing alerts.
if (
isRawSqlChartConfig(chartConfig) &&
chartConfig.displayType === DisplayType.Number
) {
return { type: 'single_value', valueColumnNames };
} else {
const timestampColumnName = meta.find(
m => m.jsType === clickhouse.JSDataType.Date,
)?.name;
if (timestampColumnName == null) {
logger.error({ meta }, 'Failed to find timestamp column');
return undefined;
}
return { type: 'time_series', timestampColumnName, valueColumnNames };
}
};
/**
* Parses the following from the given alert query result:
* - `value`: the numeric value to compare against the alert threshold, taken
* from the last column in the result which is included in valueColumnNames
* - `extraFields`: ordered `[columnName, value]` tuples for each column in the
* result which is neither the timestampColumnName nor a valueColumnName.
*/
export const parseAlertData = (
data: Record<string, string | number>,
meta: ResponseMetadata,
) => {
let value: number | null = null;
const extraFields: Array<[string, string]> = [];
for (const [k, v] of Object.entries(data)) {
if (meta.valueColumnNames.has(k)) {
// Due to output_format_json_quote_64bit_integers=1, 64-bit integers will be returned as strings.
// Parse them as integers to ensure correct threshold comparison.
// Floats are not returned as strings (unless output_format_json_quote_64bit_floats=1, which is not the default).
value = isString(v) ? parseInt(v) : v;
} else if (meta.type !== 'time_series' || k !== meta.timestampColumnName) {
extraFields.push([k, `${v}`]);
}
}
return { value, extraFields };
};
export const processAlert = async (
now: Date,
details: AlertDetails,
clickhouseClient: ClickhouseClient,
connectionId: string,
alertProvider: AlertProvider,
teamWebhooksById: Map<string, IWebhook>,
) => {
const { alert, previousMap, recentHistoryMap } = details;
const source = 'source' in details ? details.source : undefined;
// Errors collected during this execution. Webhook errors accumulate here; query
// and validation errors are recorded via recordAlertErrors before returning.
const executionErrors: IAlertError[] = [];
// SLO signal for "alerts triggering". Defaults to success; flipped to
// 'skipped' on scheduling no-ops (excluded from the SLI) and to 'error' on
// any failure path. Recorded once in the finally below so the latency/
// availability SLIs cover every real evaluation regardless of exit point.
const evalStartedAt = performance.now();
let evalOutcome: OperationOutcome | 'skipped' = 'success';
// Scheduled start of the window being evaluated. Hoisted so the catch
// blocks can attribute error history records to the correct window.
let evaluationWindowStart: Date | undefined;
// Diagnostics persisted on every history record this evaluation writes
// (query duration, webhook delivery time, backfilled buckets). Populated
// progressively; hoisted so the catch blocks can attach what was measured.
const evaluationAnalytics: IAlertHistoryAnalytics = {};
// Per-target notification timings, keyed by webhook id so the same target
// notified for several groups (and again on resolve) aggregates into one
// entry rather than one per dispatch.
const notificationTimings = new Map<
string,
AlertNotificationTargetTiming & { key: string }
>();
const recordNotificationTimings = (timings: NotificationTiming[]) => {
for (const timing of timings) {
const existing = notificationTimings.get(timing.key);
if (existing == null) {
notificationTimings.set(timing.key, {
key: timing.key,
target: timing.target,
durationMs: timing.durationMs,
dispatches: 1,
failures: timing.ok ? 0 : 1,
});
continue;
}
existing.durationMs += timing.durationMs;
existing.dispatches += 1;
existing.failures += timing.ok ? 0 : 1;
}
};
/**
* Fold the aggregated timings onto the analytics object. Called before the
* records are written, from both the success and the error path, so a
* failed evaluation still reports what it managed to deliver.
*/
const flushNotificationTimings = () => {
if (notificationTimings.size === 0) {
return;
}
evaluationAnalytics.notificationTargets = Array.from(
notificationTimings.values(),
)
// Slowest first: the point of the breakdown is finding what dominated
// the total, and the cap below should drop the least interesting rows.
.sort((a, b) => b.durationMs - a.durationMs)
.slice(0, ALERT_NOTIFICATION_TARGETS_LIMIT)
.map(({ key: _key, ...timing }) => timing);
};
try {
const windowSizeInMins = ms(alert.interval) / 60000;
const scheduleStartAt = normalizeScheduleStartAt({
alertId: alert.id,
scheduleStartAt: alert.scheduleStartAt,
});
if (scheduleStartAt != null && now < scheduleStartAt) {
evalOutcome = 'skipped';
alertEvaluationsCounter.add(1, { outcome: 'skipped_schedule' });
logger.info(
{
alertId: alert.id,
now,
scheduleStartAt,
},
'Skipped alert check because scheduleStartAt is in the future',
);
return;
}
const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({
alertId: alert.id,
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
windowSizeInMins,
});
if (scheduleStartAt != null && scheduleOffsetMinutes > 0) {