Skip to content

Commit 4bca7f1

Browse files
committed
improve alert emails: human-readable bucket times, drop count for single anomaly
1 parent 3a74db7 commit 4bca7f1

3 files changed

Lines changed: 112 additions & 8 deletions

File tree

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/email.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ const sendEmail = async (email: Email) => {
4545
return response;
4646
};
4747

48+
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
49+
const months = [
50+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
51+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
52+
];
53+
54+
const hourLabel = (h: number) =>
55+
h === 0 ? "12am" : h < 12 ? `${h}am` : h === 12 ? "12pm" : `${h - 12}pm`;
56+
57+
export const formatBucket = (bucket: string) => {
58+
const d = new Date(`${bucket}:00:00Z`);
59+
return `${days[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${hourLabel(d.getUTCHours())}`;
60+
};
61+
4862
const metricLabel = (metric: Anomaly["metric"]) =>
4963
metric === "userSpike"
5064
? "User Spike"
@@ -73,7 +87,7 @@ const formatAnomaly = (showUser: boolean) =>
7387
`<tr>
7488
<td>${eventName}</td>
7589
${showUser ? `<td>${userId ?? "-"}</td>` : ""}
76-
<td>${bucket}</td>
90+
<td>${formatBucket(bucket)}</td>
7791
<td>${expected}</td>
7892
<td>${actual}</td>
7993
<td>${zScore}</td>
@@ -99,10 +113,12 @@ const groupByMetric = (anomalies: Anomaly[]) =>
99113
anomalies.filter((a) => a.metric === metric),
100114
] as const);
101115

102-
const anomaliesHtml = (projectName: string, anomalies: Anomaly[]) =>
103-
`<h2>${projectName}: ${anomalies.length} Anomal${
104-
anomalies.length === 1 ? "y" : "ies"
105-
} Detected</h2>
116+
export const anomaliesHtml = (projectName: string, anomalies: Anomaly[]) =>
117+
`<h2>${projectName}: ${
118+
anomalies.length === 1
119+
? "Anomaly Detected"
120+
: `${anomalies.length} Anomalies Detected`
121+
}</h2>
106122
${
107123
groupByMetric(anomalies).map(([metric, group]) =>
108124
sectionHtml(metric, group)
@@ -115,9 +131,9 @@ const anomalyText = (
115131
) =>
116132
` ${eventName}${
117133
userId ? ` (user: ${userId})` : ""
118-
} in ${bucket} — expected ${expected}, got ${actual} (score=${zScore})`;
134+
} in ${formatBucket(bucket)} — expected ${expected}, got ${actual} (score=${zScore})`;
119135

120-
const anomaliesText = (anomalies: Anomaly[]) =>
136+
export const anomaliesText = (anomalies: Anomaly[]) =>
121137
groupByMetric(anomalies).map(([metric, group]) =>
122138
`${metricLabel(metric)}:\n${group.map(anomalyText).join("\n")}`
123139
).join("\n\n");
@@ -130,7 +146,7 @@ const subjectLine = (
130146
userId ? ` (${userId})` : ""
131147
}`;
132148

133-
const batchSubject = (projectName: string, anomalies: Anomaly[]) =>
149+
export const batchSubject = (projectName: string, anomalies: Anomaly[]) =>
134150
anomalies.length === 1
135151
? subjectLine(projectName, anomalies[0])
136152
: `[${projectName}] ${anomalies.length} anomalies detected`;

src/email_test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { assertEquals } from "jsr:@std/assert";
2+
import type { Anomaly } from "./anomaly.ts";
3+
import {
4+
anomaliesHtml,
5+
anomaliesText,
6+
batchSubject,
7+
formatBucket,
8+
} from "./email.ts";
9+
10+
const singleAnomaly: Anomaly = {
11+
projectId: "proj1",
12+
eventName: "login",
13+
bucket: "2026-04-06T23",
14+
expected: 10,
15+
actual: 30,
16+
zScore: 3.5,
17+
metric: "totalCount",
18+
detectedAt: "2026-04-06T23:30:00Z",
19+
};
20+
21+
const twoAnomalies: Anomaly[] = [
22+
singleAnomaly,
23+
{
24+
projectId: "proj1",
25+
eventName: "signup",
26+
bucket: "2026-04-07T09",
27+
expected: 5,
28+
actual: 20,
29+
zScore: 4.1,
30+
metric: "percentageSpike",
31+
detectedAt: "2026-04-07T09:15:00Z",
32+
userId: "user123",
33+
},
34+
];
35+
36+
Deno.test("batchSubject uses specific subject for single anomaly", () => {
37+
assertEquals(
38+
batchSubject("myapp", [singleAnomaly]),
39+
"[myapp] Total Count: login",
40+
);
41+
});
42+
43+
Deno.test("batchSubject shows count for multiple anomalies", () => {
44+
assertEquals(
45+
batchSubject("myapp", twoAnomalies),
46+
"[myapp] 2 anomalies detected",
47+
);
48+
});
49+
50+
Deno.test("anomaliesHtml says 'Anomaly Detected' without count for single anomaly", () => {
51+
const html = anomaliesHtml("myapp", [singleAnomaly]);
52+
assertEquals(html.includes("1 Anomaly"), false);
53+
assertEquals(html.includes("Anomaly Detected"), true);
54+
});
55+
56+
Deno.test("anomaliesHtml shows count for multiple anomalies", () => {
57+
const html = anomaliesHtml("myapp", twoAnomalies);
58+
assertEquals(html.includes("2 Anomalies Detected"), true);
59+
});
60+
61+
Deno.test("formatBucket renders human-readable date", () => {
62+
assertEquals(formatBucket("2026-04-06T23"), "Mon Apr 6, 11pm");
63+
});
64+
65+
Deno.test("formatBucket renders midnight as 12am", () => {
66+
assertEquals(formatBucket("2026-04-07T00"), "Tue Apr 7, 12am");
67+
});
68+
69+
Deno.test("formatBucket renders noon as 12pm", () => {
70+
assertEquals(formatBucket("2026-04-07T12"), "Tue Apr 7, 12pm");
71+
});
72+
73+
Deno.test("formatBucket renders morning hour", () => {
74+
assertEquals(formatBucket("2026-04-07T09"), "Tue Apr 7, 9am");
75+
});
76+
77+
Deno.test("anomaliesHtml uses formatted bucket not raw ISO", () => {
78+
const html = anomaliesHtml("myapp", [singleAnomaly]);
79+
assertEquals(html.includes("2026-04-06T23"), false);
80+
assertEquals(html.includes("Mon Apr 6, 11pm"), true);
81+
});
82+
83+
Deno.test("anomaliesText uses formatted bucket not raw ISO", () => {
84+
const text = anomaliesText([singleAnomaly]);
85+
assertEquals(text.includes("2026-04-06T23"), false);
86+
assertEquals(text.includes("Mon Apr 6, 11pm"), true);
87+
});

0 commit comments

Comments
 (0)