ReviewOS

stacks/analyticshq

[feature] Traffic spike/drop alerts + Slack/webhook notifications

#24
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

No alerting or outbound notifications. Spike/drop alerts and Slack/webhook delivery are thin across the whole field (only Plausible does spike+drop) — a real opening.

Proposed

Threshold + anomaly alerts (traffic spike/drop, goal conversions) delivered via email, Slack, and generic webhooks.

Priority: P2.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Shipped in 1bcb491.

What landed

Threshold and anomaly alerts on views, visitors, sessions and goal conversions — spike, drop, above, below — delivered to email, Slack, or a generic webhook. Checked hourly (RunAnalyticsAlerts), unlike the daily digest: a traffic collapse found the next morning is a post-mortem, not an alert. Admin-scoped CRUD at /api/sites/{siteId}/alerts.

The baseline is the whole feature

Comparing this hour against the hour before does not work on web traffic, because traffic has a shape. 09:00 is reliably busier than 08:00 and 03:00 is reliably dead, so that comparison reports a spike every morning and a collapse every night, on every site, forever — and an alert that fires daily for a reason the reader already understands is one they mute.

So the baseline is the same clock window on previous days, summarised with a median. One freak day in the sample — a launch, a scraper, an outage — drags a mean far enough to hide the next week's real movement. Against a seeded 10/10/10/100/10/10/10 week the median answers 10 where a mean answers 23.

min_volume is the floor below which a percentage is not worth believing, and which side it applies to depends on direction: a spike needs the observed count above it, or every quiet site alerts when two people arrive; a drop needs the baseline above it, or every quiet site alerts when its usual two do not. Getting that backwards is the difference between catching an outage and firing all night on a hobby blog.

Cooldown is stamped before delivery is attempted. Recording the decision rather than the outcome means a failed send costs one missed notification, where the other order costs a duplicate when a slow send overlaps the next hourly run — and duplicates are what teach people to mute a channel.

The webhook URL is an SSRF primitive, and I shipped a hole in the guard

A customer-supplied URL this server POSTs to is a request leaving our network, from our address, to wherever they name — and on a cloud host the first thing worth naming is the instance metadata service. URLs are therefore judged on their resolved addresses, not their hostnames: blocking the string 127.0.0.1 accomplishes nothing when an attacker controls DNS for their own domain.

Enforced: https only, port 443 only (arbitrary ports make this an internal port scanner — response timing alone distinguishes open from closed), no embedded credentials, every resolved address public rather than just the first, and redirect: 'error', since a 302 would launder a request past every check above. The guard re-runs immediately before each send, because a hostname that resolved publicly when it was saved can be repointed afterwards.

The first version allowed [::ffff:169.254.169.254]. new URL() rewrites the embedded IPv4 to hex (::ffff:a9fe:a9fe), so a check looking for a dotted quad found nothing to unwrap. Pattern-matching IPv6 was the wrong approach — it now parses the address into eight groups and judges v4-mapped, v4-compatible, NAT64 and 6to4 forms by the address they actually reach. Found by probing the guard, not by reading it.

Note DNS rebinding is mitigated, not closed: fully closing it means connecting to a pinned address with a Host header, which fetch will not do. That limit is documented in app/Alerts/url-safety.ts rather than left for someone to assume.

Alert endpoints are admin, including the list — unlike goals and members, where reading is a viewer right. channels holds a Slack incoming-webhook URL, which is a bearer credential: whoever holds one can post into that channel as this app forever. Being trusted to read a site's numbers does not imply that.

Verification. Two of my own guards passed while broken — the redirect test matched the string in the comment explaining the line, and the cooldown-ordering test held when the stamp was deleted outright, since indexOf returns -1. Both were rewritten and five sabotages now fail them by name. Against real Postgres: 18 detection checks, 9 job checks (fires once, suppresses itself, resumes after the cooldown, ignores inactive rows), FK cascade confirmed.

Deliberately not included

  • No UI — alerts are API-only, same as digests.
  • No delivery history, so a webhook that fails is logged and never surfaced to the customer. That is the gap most worth filing next: a silently broken alert channel is indistinguishable from quiet traffic.

Sign in to comment on this issue.