Skip to content

Commit c5b123d

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
feat(alerts): query alerts
Alerts can now watch the result of a query: raise when a value crosses a threshold, when matching rows stop appearing, or when new matching rows appear — with the observed values charted on the alert detail page. Mono-RevId: 6fef942e47bcf26a8a9d707149d99033858687d2
1 parent d5e9e26 commit c5b123d

108 files changed

Lines changed: 11124 additions & 275 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.schema‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ POSTHOG_PROJECT_KEY=
205205
# @sensitive=false
206206
ALERTS_ENABLED=0
207207
# @sensitive=false
208-
ALERT_QUERY_CONCURRENCY=1
208+
ALERTS_QUERY_CONCURRENCY_LIMIT=2
209209
# @sensitive=false
210210
ALERT_FAILURE_RATE_MIN_VOLUME=10
211211
# @sensitive=false

‎.server-changes/query-alerts.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Alerts can now watch the result of any supported query: raise when a value crosses a threshold, when matching rows stop appearing, or when new matching rows appear. Create them from the Query page, the Dashboard Agent or the API, and see the alert's values charted on its page.
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// @vitest-environment jsdom
2+
import { act, createElement, type ReactNode } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { renderToStaticMarkup } from "react-dom/server";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
7+
import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server";
8+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
9+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
10+
import { AlertDetailChart } from "./AlertDetailChart";
11+
import { AREA_OPTIONS } from "./AlertsList";
12+
import { WARNING_COLOR } from "./ChartLegend";
13+
import { AREA_ICON, AREA_LABEL, isAlertArea } from "./domainIcons";
14+
import type { MetricSeriesPoint } from "./alertSeriesTypes";
15+
import { defaultGroupSelection } from "./queryGroupSelection";
16+
import type * as AlertThresholdChartModule from "./AlertThresholdChart";
17+
18+
const { assignGroupColors, AlertMultiSeriesChart, SERIES_COLORS, thresholdChartLegend } =
19+
await vi.importActual<typeof AlertThresholdChartModule>("./AlertThresholdChart");
20+
21+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
22+
globalThis.ResizeObserver ??= class {
23+
observe() {}
24+
unobserve() {}
25+
disconnect() {}
26+
};
27+
28+
vi.mock("~/components/primitives/charts/ChartCompound", () => ({
29+
Chart: {
30+
Root: ({
31+
children,
32+
series,
33+
config,
34+
}: {
35+
children: ReactNode;
36+
series: string[];
37+
config: Record<string, { color?: string }>;
38+
}) =>
39+
createElement(
40+
"div",
41+
{
42+
"data-series": series.join(","),
43+
"data-colors": Object.values(config)
44+
.map((c) => c.color)
45+
.join(","),
46+
},
47+
children
48+
),
49+
Line: ({ warningOverlay }: { warningOverlay?: unknown }) =>
50+
createElement("i", { "data-overlay": JSON.stringify(warningOverlay) }),
51+
},
52+
}));
53+
vi.mock("~/components/primitives/DateTime", () => ({ useUserTimeZone: () => "UTC" }));
54+
vi.mock("~/components/runs/v3/SharedFilters", () => ({ TimeFilter: () => null }));
55+
vi.mock("~/components/primitives/DetailChartBlock", () => ({
56+
DetailChartBlock: ({ children }: { children: ReactNode }) => children,
57+
}));
58+
vi.mock("./PreviewChartCard", () => ({
59+
PreviewChartCard: ({ accessory, children }: { accessory?: ReactNode; children: ReactNode }) =>
60+
createElement("div", null, accessory, children),
61+
}));
62+
vi.mock("./ActivityBars", async (importOriginal) => ({
63+
...(await importOriginal<object>()),
64+
ActivityBars: ({ data }: { data: unknown[] }) =>
65+
createElement("i", { "data-chart": "bars", "data-points": data.length }),
66+
}));
67+
vi.mock("./AlertThresholdChart", async (importOriginal) => ({
68+
...(await importOriginal<object>()),
69+
AlertMultiSeriesChart: ({ seriesMap }: { seriesMap: Map<string, unknown> }) =>
70+
createElement("i", { "data-chart": "multi", "data-keys": [...seriesMap.keys()].join(",") }),
71+
}));
72+
73+
const HOUR = 60 * 60 * 1000;
74+
const T0 = Date.UTC(2026, 0, 1);
75+
76+
const alert = {
77+
kind: "threshold",
78+
capabilityTitle: "Custom",
79+
capabilityKey: "custom.query",
80+
operator: ">",
81+
thresholdValue: 10,
82+
scope: { type: "all" },
83+
status: { status: "active" },
84+
episode: { raisedAt: new Date(T0), outcome: "raised" },
85+
events: [],
86+
currentReading: { status: "unavailable" },
87+
} as unknown as AlertDetail;
88+
89+
const points = (...values: number[]): MetricSeriesPoint[] =>
90+
values.map((value, i) => ({ bucket: T0 + i * HOUR, value }));
91+
92+
// g8 and g9 cross the threshold; the others stay below and rise with their index
93+
const groups = Array.from({ length: 10 }, (_, i) => `g${i}`);
94+
const seriesMap = new Map(groups.map((g, i) => [g, points(i >= 8 ? 50 + i : i, 1)]));
95+
96+
let root: Root | undefined;
97+
let container: HTMLElement | undefined;
98+
99+
afterEach(() => {
100+
act(() => root?.unmount());
101+
container?.remove();
102+
document.body.innerHTML = "";
103+
});
104+
105+
function mount(props: Record<string, unknown> = {}) {
106+
container = document.createElement("div");
107+
document.body.appendChild(container);
108+
root = createRoot(container);
109+
render(props);
110+
return container;
111+
}
112+
113+
function render(props: Record<string, unknown> = {}) {
114+
act(() =>
115+
root!.render(
116+
createElement(
117+
OperatingSystemContextProvider,
118+
{ platform: "mac" },
119+
createElement(
120+
ShortcutsProvider,
121+
null,
122+
createElement(AlertDetailChart, {
123+
alert,
124+
metricSeriesMap: seriesMap,
125+
healthBands: [],
126+
window: { since: new Date(T0), until: new Date(T0 + 4 * HOUR) },
127+
now: new Date(T0 + 4 * HOUR),
128+
queryGroups: groups,
129+
querySeriesMode: "threshold",
130+
...props,
131+
} as never)
132+
)
133+
)
134+
)
135+
);
136+
}
137+
138+
const keys = (c: HTMLElement) =>
139+
c.querySelector("[data-chart=multi]")!.getAttribute("data-keys")!.split(",");
140+
141+
describe("defaultGroupSelection", () => {
142+
it("puts crossed groups first, then closest to the threshold, capped at 8", () => {
143+
const picked = defaultGroupSelection({ groups, seriesMap, operator: ">" });
144+
expect(picked).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]);
145+
});
146+
147+
it("orders toward the threshold for a below-threshold operator", () => {
148+
const picked = defaultGroupSelection({
149+
groups: ["a", "b", "c"],
150+
seriesMap: new Map([
151+
["a", points(5)],
152+
["b", points(1)],
153+
["c", points(3)],
154+
]),
155+
operator: "<",
156+
});
157+
expect(picked).toEqual(["b", "c", "a"]);
158+
});
159+
});
160+
161+
describe("AlertDetailChart query groups", () => {
162+
it("renders at most 8 series by default, crossed groups first", () => {
163+
const c = mount();
164+
expect(keys(c)).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]);
165+
expect(c.textContent).toContain("Groups 8/10");
166+
});
167+
168+
it("hides a series when its group is toggled off and searches the list", () => {
169+
const c = mount();
170+
act(() => c.querySelector<HTMLElement>("button")!.click());
171+
172+
const search = document.querySelector<HTMLInputElement>("input")!;
173+
expect(search.placeholder).toContain("Up to 8 visible");
174+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
175+
const type = (value: string) =>
176+
act(() => {
177+
setter.call(search, value);
178+
search.dispatchEvent(new Event("input", { bubbles: true }));
179+
});
180+
const rows = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent);
181+
const g0 = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(
182+
(o) => o.textContent === "g0"
183+
)!;
184+
expect(g0.getAttribute("aria-disabled")).toBe("true");
185+
type("g1");
186+
expect(rows()).toEqual(["g1"]);
187+
188+
type("");
189+
const row = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(
190+
(o) => o.textContent === "g9"
191+
)!;
192+
act(() => row.click());
193+
expect(keys(c)).not.toContain("g9");
194+
expect(c.textContent).toContain("Groups 7/10");
195+
});
196+
197+
it("shows the picker only with at least two groups", () => {
198+
expect(mount({ queryGroups: ["g9"] }).textContent).not.toContain("Groups");
199+
act(() => root?.unmount());
200+
container?.remove();
201+
expect(mount({ queryGroups: ["g9", "g8"] }).textContent).toContain("Groups 2/2");
202+
act(() => container!.querySelector<HTMLElement>("button")!.click());
203+
expect(document.querySelector<HTMLInputElement>("input")!.placeholder).not.toContain("Up to");
204+
});
205+
206+
it("shows the only remaining group even after it was deselected", () => {
207+
const c = mount({ queryGroups: ["g9", "g8"] });
208+
act(() => c.querySelector<HTMLElement>("button")!.click());
209+
const g9 = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(
210+
(o) => o.textContent === "g9"
211+
)!;
212+
act(() => g9.click());
213+
expect(keys(c)).toEqual(["g8"]);
214+
215+
render({ queryGroups: ["g9"] });
216+
expect(keys(c)).toEqual(["g9"]);
217+
expect(c.textContent).not.toContain("Groups");
218+
});
219+
220+
it("renders the occurrence bar chart in match mode", () => {
221+
const c = mount({
222+
querySeriesMode: "match",
223+
metricSeriesMap: new Map([["__agg__", points(2, 3, 4)]]),
224+
});
225+
expect(c.querySelector("[data-chart=bars]")!.getAttribute("data-points")).toBe("3");
226+
expect(c.querySelector("[data-chart=multi]")).toBeNull();
227+
});
228+
});
229+
230+
describe("Watching area for custom queries", () => {
231+
it("shows Custom with the Query page icon", () => {
232+
expect(AREA_LABEL.query).toBe("Custom");
233+
expect(AREA_ICON.query).toBe(CodeSquareIcon);
234+
expect(isAlertArea("query")).toBe(true);
235+
expect(AREA_OPTIONS).toContainEqual({ value: "query", label: "Custom" });
236+
});
237+
});
238+
239+
function hue(hex: string): number {
240+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [
241+
number,
242+
number,
243+
number,
244+
];
245+
const max = Math.max(r, g, b);
246+
const d = max - Math.min(r, g, b);
247+
if (d === 0) return 0;
248+
const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
249+
return (h * 60 + 360) % 360;
250+
}
251+
252+
describe("grouped chart threshold highlight", () => {
253+
it("keeps yellow and amber out of the series colours", () => {
254+
expect(SERIES_COLORS).toHaveLength(8);
255+
for (const color of SERIES_COLORS) {
256+
const h = hue(color);
257+
expect(h < 20 || h > 75, color).toBe(true);
258+
}
259+
});
260+
261+
it("applies the warning overlay to the chart holding every group", () => {
262+
const html = renderToStaticMarkup(
263+
createElement(AlertMultiSeriesChart, {
264+
seriesMap: new Map([
265+
["a", points(1, 20, 25)],
266+
["b", points(2, 30, 15)],
267+
]),
268+
threshold: 10,
269+
operator: ">",
270+
})
271+
);
272+
expect(html).toContain('data-series="a,b"');
273+
expect(html).toContain("&quot;direction&quot;:&quot;above&quot;");
274+
expect(html).toContain("var(--color-warning)");
275+
});
276+
});
277+
278+
describe("assignGroupColors", () => {
279+
const keys = Array.from({ length: 12 }, (_, i) => `g${i}`);
280+
281+
it("gives 8 visible groups 8 distinct colours", () => {
282+
const colors = assignGroupColors(new Map(), keys.slice(0, 8));
283+
expect(new Set(colors.values()).size).toBe(8);
284+
});
285+
286+
it("stays distinct for 12 groups with 8 selected", () => {
287+
const colors = assignGroupColors(new Map(), keys.slice(2, 10));
288+
expect(colors.size).toBe(8);
289+
expect(new Set(colors.values()).size).toBe(8);
290+
});
291+
292+
it("keeps untouched colours when one is toggled off and another on", () => {
293+
const first = assignGroupColors(new Map(), keys.slice(0, 8));
294+
const next = assignGroupColors(first, [...keys.slice(1, 8), "g10"]);
295+
expect(new Set(next.values()).size).toBe(8);
296+
for (const key of keys.slice(1, 8)) expect(next.get(key)).toBe(first.get(key));
297+
expect(next.get("g10")).toBe(first.get("g0"));
298+
});
299+
});
300+
301+
describe("grouped chart legend", () => {
302+
const seriesMap = new Map([
303+
["a", points(1, 20)],
304+
["b", points(2, 30)],
305+
]);
306+
307+
it("lists every colour it draws, warning entry included", () => {
308+
const input = { seriesMap, label: "x", threshold: 10, operator: ">" as const };
309+
const legend = thresholdChartLegend(input);
310+
expect(legend.map((i) => i.label)).toEqual(["a", "b", "Above threshold"]);
311+
expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[0], SERIES_COLORS[1], WARNING_COLOR]);
312+
});
313+
314+
it("omits it without a threshold", () => {
315+
const labels = thresholdChartLegend({ seriesMap, label: "x", threshold: null }).map(
316+
(i) => i.label
317+
);
318+
expect(labels).toEqual(["a", "b"]);
319+
});
320+
321+
it("keeps a lone group's assigned colour in the line and the legend", () => {
322+
const colors = new Map([["b", SERIES_COLORS[1]!]]);
323+
const single = new Map([["b", points(2, 30)]]);
324+
const html = renderToStaticMarkup(
325+
createElement(AlertMultiSeriesChart, { seriesMap: single, threshold: 10, colors })
326+
);
327+
expect(html).toContain(`data-colors="${SERIES_COLORS[1]}"`);
328+
const legend = thresholdChartLegend({ seriesMap: single, label: "x", threshold: null, colors });
329+
expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[1]]);
330+
});
331+
});

0 commit comments

Comments
 (0)