The demo had 75k generic records across eight host-0N/service pairs, one dashboard, one alert rule, and -- because nothing ever called AgentControl.CheckIn -- a completely empty Agents page. /hack/demo-simulator replaces the generic data with a fictional but coherent fleet: 14 hosts running nginx, an API tier, workers, Postgres, Redis, mail, Linux journals and Windows event logs, whose messages and attributes look like what those services actually write. It backfills a week (~370k records, ~20s) and then keeps running. Running continuously is the point, not an implementation detail. Three things the demo has to show are only true if data keeps arriving: the Agents page marks a host stale once check-ins stop, alert rules evaluate over trailing windows and would freeze in one state against a static dataset, and any "last 15 minutes" view is empty on data that stopped growing overnight. It also emits metrics/heartbeats and answers CheckIn faithfully enough that the remote-config editor's pending -> applied transition works end to end. Seeded incidents give the data something to find: an api-02 outage with matching slow queries on db-01, 5xx at the edge and cascading job failures; an SSH probe burst; a spam wave; a disk filling up; and one decommissioned host left deliberately stale. /hack/demo-seed holds the rest of the deployment -- the nightly reset, eight dashboards (64 panels, every viz type but line), eleven alert rules across three notification targets, and the systemd unit. Rule thresholds are calibrated against what the simulator actually produces: the first pass had four rules whose thresholds the traffic could never reach and one that fired during normal operation. No line charts: dashboard panels reject the raw-SQL escape hatch, and the pipe language has no time-bucketing, so a real time axis isn't expressible today. Noted in demo-seed/README.md rather than papered over.
135 lines
5.0 KiB
Go
135 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
// Incidents are what turn a wall of uniform noise into a dataset worth
|
|
// clicking around in: a demo viewer who filters to 5xx, or opens the
|
|
// Alerts page, should find a *story* (one bad API node, a burst of SSH
|
|
// probing, a spam wave) rather than the same flat error rate everywhere.
|
|
// Every window below is expressed relative to `origin` -- the moment the
|
|
// simulator started, which is also the end of the backfill window -- so
|
|
// a freshly reset demo always has its incidents at the same, recent,
|
|
// predictable offsets no matter what day it is.
|
|
type conditions struct {
|
|
// apiErrorRate replaces the API tier's baseline 5xx probability for
|
|
// the affected host during an outage window.
|
|
apiErrorRate float64
|
|
// latencyMult multiplies API/DB latencies -- an outage that only
|
|
// changed status codes without slowing anything down wouldn't look
|
|
// like a real one.
|
|
latencyMult float64
|
|
// jobFailureRate replaces the worker tier's baseline failure
|
|
// probability. An API/database outage doesn't stay in the request
|
|
// path: the same failing dependencies take background jobs down with
|
|
// them, which is also the only thing that ever makes the job-failure
|
|
// alert rule true -- a steady 5% background failure rate is normal
|
|
// operation, not an incident.
|
|
jobFailureRate float64
|
|
// bruteForce and spamWave switch the system/smtp generators from
|
|
// their normal mix to an attack-shaped one for the window.
|
|
bruteForce bool
|
|
spamWave bool
|
|
}
|
|
|
|
// Windows, as offsets back from origin. Kept as one table so the story
|
|
// is readable in one place and the alert rules in
|
|
// /hack/demo-seed/alerts can be written against known-true conditions.
|
|
const (
|
|
apiOutageStart = 8 * time.Hour
|
|
apiOutageEnd = 6*time.Hour + 30*time.Minute
|
|
apiOutageHost = "api-02"
|
|
|
|
bruteForceStart = 14 * time.Hour
|
|
bruteForceEnd = 13 * time.Hour
|
|
|
|
spamWaveStart = 30 * time.Hour
|
|
spamWaveEnd = 26 * time.Hour
|
|
|
|
// Live mode can't rely on the backfill windows above -- they recede
|
|
// into the past as a demo session runs. These recurring bursts keep
|
|
// the *present* interesting too, which is what the alert rules
|
|
// (evaluating over -5m/-10m windows) actually see: without them,
|
|
// every rule would settle into a permanent OK state a few minutes
|
|
// after a reset and the Alerts page would never do anything again.
|
|
liveErrorBurstPeriod = 47 * time.Minute
|
|
liveErrorBurstLen = 5 * time.Minute
|
|
liveProbePeriod = 2 * time.Hour
|
|
liveProbeLen = 8 * time.Minute
|
|
liveSpamPeriod = 3 * time.Hour
|
|
liveSpamLen = 10 * time.Minute
|
|
)
|
|
|
|
func conditionsAt(t, origin time.Time, h *host) conditions {
|
|
hostName := h.name
|
|
c := conditions{latencyMult: 1}
|
|
since := origin.Sub(t)
|
|
|
|
// Is an API-tier outage in effect? Two sources feed the same
|
|
// handling: the backfilled incident window, and -- in live mode --
|
|
// the recurring burst that keeps the present interesting.
|
|
outage := 0.0
|
|
if since <= apiOutageStart && since >= apiOutageEnd {
|
|
outage = 0.42
|
|
}
|
|
bruteForce := since <= bruteForceStart && since >= bruteForceEnd
|
|
spamWave := since <= spamWaveStart && since >= spamWaveEnd
|
|
|
|
if t.After(origin) {
|
|
// Phases are measured from origin so the first burst of each kind
|
|
// lands a predictable few minutes into a demo session rather than
|
|
// immediately at reset.
|
|
elapsed := t.Sub(origin)
|
|
if phase := (elapsed + 10*time.Minute) % liveErrorBurstPeriod; phase < liveErrorBurstLen {
|
|
outage = 0.45
|
|
}
|
|
if phase := (elapsed + 20*time.Minute) % liveProbePeriod; phase < liveProbeLen {
|
|
bruteForce = true
|
|
}
|
|
if phase := (elapsed + 35*time.Minute) % liveSpamPeriod; phase < liveSpamLen {
|
|
spamWave = true
|
|
}
|
|
}
|
|
|
|
if outage > 0 {
|
|
switch {
|
|
case hostName == apiOutageHost:
|
|
c.apiErrorRate = outage
|
|
c.latencyMult = 4.5
|
|
case internetFacingName(hostName):
|
|
// The edge tier fronts all three API nodes, so roughly a
|
|
// third of what it proxies during the outage hits the failing
|
|
// one. Without this the outage would be invisible from the
|
|
// edge, which isn't how a viewer expects to be able to trace
|
|
// it: client-visible 5xx are exactly what makes it an outage
|
|
// rather than an internal blip.
|
|
c.apiErrorRate = outage / 3
|
|
c.latencyMult = 2
|
|
case hostName == "db-01":
|
|
// The database is the *cause*, not a second unrelated
|
|
// incident -- a viewer who drills from the API errors into
|
|
// the same window on db-01 should find slow queries waiting.
|
|
c.latencyMult = 6
|
|
case h.service == "worker":
|
|
c.jobFailureRate = 0.35
|
|
c.latencyMult = 2.5
|
|
}
|
|
}
|
|
c.bruteForce = bruteForce
|
|
c.spamWave = spamWave
|
|
|
|
return c
|
|
}
|
|
|
|
// diurnal scales event rates by time of day: a shop's traffic peaks
|
|
// mid-afternoon UTC and bottoms out around 04:00, roughly a 3.5x spread.
|
|
// Without this every chart is a flat line and the "last 24h" view tells
|
|
// a viewer nothing that "last 1h" didn't.
|
|
func diurnal(t time.Time) float64 {
|
|
hour := float64(t.UTC().Hour()) + float64(t.UTC().Minute())/60
|
|
// Peak at 15:00 UTC, trough at 03:00.
|
|
return 0.35 + 0.65*(0.5+0.5*math.Cos((hour-15)/24*2*math.Pi))
|
|
}
|