Skip to content

Commit 8889351

Browse files
committed
feat: add anomaly detection for events
Z-score analysis on hourly event counts against a 7-day rolling baseline. Detects spikes and drops across pageviews, errors, and custom events with percentage-change fallback for zero-variance traffic.
1 parent 4a4b23e commit 8889351

9 files changed

Lines changed: 1234 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"use client";
2+
3+
import {
4+
ArrowClockwiseIcon,
5+
CheckCircleIcon,
6+
WarningIcon,
7+
} from "@phosphor-icons/react";
8+
import { useQuery } from "@tanstack/react-query";
9+
import { use } from "react";
10+
import { EmptyState } from "@/components/empty-state";
11+
import { RightSidebar } from "@/components/right-sidebar";
12+
import { Badge } from "@/components/ui/badge";
13+
import { Button } from "@/components/ui/button";
14+
import { Skeleton } from "@/components/ui/skeleton";
15+
import { orpc } from "@/lib/orpc";
16+
import { AnomalyCard } from "./anomaly-card";
17+
18+
interface AnomaliesPageContentProps {
19+
params: Promise<{ id: string }>;
20+
}
21+
22+
interface Anomaly {
23+
metric: "pageviews" | "custom_events" | "errors";
24+
type: "spike" | "drop";
25+
severity: "warning" | "critical";
26+
currentValue: number;
27+
baselineMean: number;
28+
baselineStdDev: number;
29+
zScore: number;
30+
percentChange: number;
31+
detectedAt: string;
32+
periodStart: string;
33+
periodEnd: string;
34+
eventName?: string;
35+
}
36+
37+
export function AnomaliesPageContent({ params }: AnomaliesPageContentProps) {
38+
const { id: websiteId } = use(params);
39+
40+
const {
41+
data: anomalies,
42+
isLoading,
43+
isFetching,
44+
refetch,
45+
} = useQuery({
46+
...orpc.anomalies.detect.queryOptions({
47+
input: { websiteId },
48+
}),
49+
refetchInterval: 300_000,
50+
});
51+
52+
const items = (anomalies ?? []) as Anomaly[];
53+
const criticalCount = items.filter((a) => a.severity === "critical").length;
54+
const warningCount = items.filter((a) => a.severity === "warning").length;
55+
const spikeCount = items.filter((a) => a.type === "spike").length;
56+
const dropCount = items.filter((a) => a.type === "drop").length;
57+
58+
const metricCounts = new Map<string, number>();
59+
for (const item of items) {
60+
const key = item.eventName ?? item.metric;
61+
metricCounts.set(key, (metricCounts.get(key) ?? 0) + 1);
62+
}
63+
64+
return (
65+
<div className="h-full lg:grid lg:grid-cols-[1fr_18rem]">
66+
<div className="flex flex-col overflow-y-auto">
67+
{/* Header */}
68+
<div className="flex shrink-0 flex-col justify-between gap-3 border-b p-4 sm:flex-row sm:items-center sm:p-5">
69+
<div className="flex items-center gap-3">
70+
<div className="rounded-lg border bg-secondary p-2.5">
71+
<WarningIcon
72+
className="size-5 text-accent-foreground"
73+
weight="duotone"
74+
/>
75+
</div>
76+
<div className="min-w-0">
77+
<div className="flex items-center gap-2">
78+
<h1 className="text-balance font-medium text-foreground text-xl">
79+
Anomaly Detection
80+
</h1>
81+
{!isLoading && items.length > 0 && (
82+
<Badge variant={criticalCount > 0 ? "destructive" : "amber"}>
83+
{items.length}
84+
</Badge>
85+
)}
86+
</div>
87+
<p className="text-muted-foreground text-xs">
88+
Automatic detection of unusual patterns in your event data
89+
</p>
90+
</div>
91+
</div>
92+
<Button
93+
disabled={isFetching}
94+
onClick={() => refetch()}
95+
size="sm"
96+
variant="outline"
97+
>
98+
<ArrowClockwiseIcon
99+
className={`mr-1.5 size-4 ${isFetching ? "animate-spin" : ""}`}
100+
/>
101+
{isFetching ? "Scanning..." : "Scan Now"}
102+
</Button>
103+
</div>
104+
105+
{/* Loading */}
106+
{isLoading && (
107+
<div className="space-y-3 p-4 sm:p-5">
108+
{Array.from({ length: 3 }).map((_, i) => (
109+
<div className="rounded border p-4" key={`skel-${i + 1}`}>
110+
<div className="flex items-start gap-3">
111+
<Skeleton className="size-9 shrink-0 rounded" />
112+
<div className="flex-1 space-y-2">
113+
<div className="flex items-center gap-2">
114+
<Skeleton className="h-5 w-32" />
115+
<Skeleton className="h-5 w-16" />
116+
</div>
117+
<Skeleton className="h-4 w-56" />
118+
</div>
119+
<Skeleton className="h-5 w-16" />
120+
</div>
121+
<Skeleton className="mt-3 h-14 w-full rounded" />
122+
</div>
123+
))}
124+
</div>
125+
)}
126+
127+
{/* No anomalies */}
128+
{!isLoading && items.length === 0 && (
129+
<div className="flex flex-1 items-center justify-center py-16">
130+
<EmptyState
131+
description="No unusual patterns detected in the last hour compared to your 7-day baseline. We check pageviews, errors, and custom events automatically."
132+
icon={<CheckCircleIcon weight="duotone" />}
133+
title="All clear"
134+
variant="minimal"
135+
/>
136+
</div>
137+
)}
138+
139+
{/* Anomaly list */}
140+
{!isLoading && items.length > 0 && (
141+
<div className="space-y-3 p-4 sm:p-5">
142+
{items.map((anomaly, idx) => (
143+
<AnomalyCard
144+
baselineMean={anomaly.baselineMean}
145+
currentValue={anomaly.currentValue}
146+
eventName={anomaly.eventName}
147+
key={`${anomaly.metric}-${anomaly.eventName ?? ""}-${idx}`}
148+
metric={anomaly.metric}
149+
percentChange={anomaly.percentChange}
150+
periodEnd={anomaly.periodEnd}
151+
periodStart={anomaly.periodStart}
152+
severity={anomaly.severity}
153+
type={anomaly.type}
154+
zScore={anomaly.zScore}
155+
/>
156+
))}
157+
</div>
158+
)}
159+
</div>
160+
161+
<RightSidebar className="gap-0 p-0">
162+
<RightSidebar.Section border title="Summary">
163+
{isLoading ? (
164+
<div className="space-y-2.5">
165+
<Skeleton className="h-5 w-full" />
166+
<Skeleton className="h-5 w-full" />
167+
<Skeleton className="h-5 w-full" />
168+
</div>
169+
) : (
170+
<div className="space-y-2.5">
171+
<div className="flex items-center justify-between">
172+
<span className="text-muted-foreground text-sm">Critical</span>
173+
<span className="font-medium text-sm tabular-nums">
174+
{criticalCount}
175+
</span>
176+
</div>
177+
<div className="flex items-center justify-between">
178+
<span className="text-muted-foreground text-sm">Warning</span>
179+
<span className="font-medium text-sm tabular-nums">
180+
{warningCount}
181+
</span>
182+
</div>
183+
<div className="flex items-center justify-between">
184+
<span className="text-muted-foreground text-sm">Spikes</span>
185+
<span className="font-medium text-sm tabular-nums">
186+
{spikeCount}
187+
</span>
188+
</div>
189+
<div className="flex items-center justify-between">
190+
<span className="text-muted-foreground text-sm">Drops</span>
191+
<span className="font-medium text-sm tabular-nums">
192+
{dropCount}
193+
</span>
194+
</div>
195+
</div>
196+
)}
197+
</RightSidebar.Section>
198+
199+
<RightSidebar.Section border title="Affected Metrics">
200+
{isLoading ? (
201+
<div className="space-y-2">
202+
<Skeleton className="h-5 w-full" />
203+
</div>
204+
) : metricCounts.size > 0 ? (
205+
<div className="space-y-2">
206+
{[...metricCounts.entries()].map(([name, count]) => (
207+
<div className="flex items-center justify-between" key={name}>
208+
<span className="truncate text-muted-foreground text-sm">
209+
{name}
210+
</span>
211+
<Badge variant="outline">{count}</Badge>
212+
</div>
213+
))}
214+
</div>
215+
) : (
216+
<p className="text-muted-foreground text-xs">No anomalies found</p>
217+
)}
218+
</RightSidebar.Section>
219+
220+
<RightSidebar.Section>
221+
<RightSidebar.Tip description="Anomalies are detected by comparing the last completed hour against a rolling 7-day hourly baseline using Z-score analysis. Create a traffic_spike or error_rate alarm in Notifications to get alerted." />
222+
</RightSidebar.Section>
223+
</RightSidebar>
224+
</div>
225+
);
226+
}

0 commit comments

Comments
 (0)