๐ Data flow โ a Pod crashes
This page traces a single pod crash through every stage of kwatch, from Kubernetes API watch to a chat message. Each step references the real package and function involved.
Phase 1: Detection (Informer โ Workqueue)โ
Pod transitions to CrashLoopBackOff
โ
โผ
Pod informer (SharedInformer) receives ADD/UPDATE event
โ
โผ
Event handler registered by controller.New() fires
โ
โผ
enqueue.go: changeRecordingHandler / graph-aware pod handler update
the change tracker + dependency graph, then enqueue namespace/key
โ
โผ
The pod's resourcePipeline workqueue receives "namespace/name"
The controller builds one resourcePipeline per watched kind
(internal/controller/pipeline.go): a named rate-limited workqueue, informer
sync state, a sync function, and a startWorkers gate. Each queue is
independent โ a slow deployment reconcile can't block a pod crash. Sync
dispatch functions (sync.go) share one signature
func (c *Controller) syncX(_ context.Context, key string) error.
Handlers also feed the two side structures the diagnostic engine needs:
recordChangeโgraphcontext.ChangeTracker, so "what changed recently" has data;- the graph-aware pod handler โ
graphcontext.ResourceGraph, so cause/impact analysis can walk the cluster's family tree.
Phase 2: Filter Pipeline (Handler โ Filter)โ
Worker goroutine picks up key from the pod queue
โ
โผ
handler.ProcessPod() is called with the object
โ
โผ
Builds filter.Context:
โข Sources โ client, config, listers, injected Now clock (read-only)
โข Pod/EvType/Owner/Events โ the object under evaluation
โข Findings โ scratch area for detector conclusions
โ
โผ
Detector chain (any returns StatusSkip โ pod dropped)
โ
โผ (StatusContinue / StatusAlert)
Per-container evaluation (remaining detectors)
โ
โผ
Enricher chain (fetch events, resolve owner, collect logs, check killing)
โ
โผ
handler builds an event.Signal and calls correlation.Engine.Process()
The handler is the only place pod evaluation happens. Detectors write
Findings (PodHasIssues, ContainerHasIssues, PodReason, and so on);
enrichers fill in the incident's evidence and hint. Time-based decisions read
the injected Sources.Now clock rather than the wall clock.
Key filters for a CrashLoopBackOff โ OOM story:
pod_status_filter.goโ pod-level issues first;container_state_filter.go,container_reasons_filter.goโ the container isterminatedwith reasonOOMKilled;container_restarts_filter.goโ the container is restarting (restart count increased sinceLastState);noise_filter.goโ skips banal reasons (Normal,Scheduled,Pulled,Pulling);OOMKilledis not among them, so it passes;container_killing_filter.go,container_logs_filter.go,pod_events_filter.go,pod_owners_filter.goโ enrichment.
Phase 3: Correlation Engine (State & Dedup)โ
correlation.Engine.Process(ev, owner, containerState)
โ
โผ
processLocked โ every path through the same five stages:
1) baseline was it already broken at startup? โ quiet
2) attribution symptom of node / shared-dep / owner? โ counted, silent
3) cooldown resolved a moment ago? โ silent revival
4) identity which incident is this? โ dedup / fold / escalate
5) announcement speak now? โ group or edge
โ
โผ
emit() โ LifecycleHook(inc, action)
โ
โผ
app.lifecycleHook: audit once โ insight diagnosis โ AlertManager.NotifyIncident()
Dedup keyโ
Incidents are keyed by namespace:owner:reason:container
(key.go BuildKey). Crash-looping reasons fold into one canonical key once
restarts pass the threshold, so the incident's identity is stable across the
container's momentary states. Image-pull failures with global scope (rate
limits, registry timeouts, DNS, TLS) use a cluster-wide key.
Edge-triggered notificationโ
// internal/correlation/engine.go
func notifSig(inc *model.Incident) string {
st := "firing"
if inc.State == model.StateResolved {
st = "resolved"
}
return st + "|" + string(inc.Severity)
}
The signature "firing|critical" is compared against the incident's
NotifiedSig. If unchanged, the action is ActionSkip โ no duplicate alert
on every poll. First sighting gives ActionCreate; later changes give
ActionUpdate; the transition to resolved gives ActionResolved.
Severity resolutionโ
The DefaultEnricher (wired in internal/app from severityByReason /
severityByOwnerKind) resolves severity in order:
- By reason โ from
severityByReason(defaults:Evicted,ImagePullBackOffโmedium); - By owner kind โ from
severityByOwnerKind(default:StatefulSetโhigh); - Default โ
normal.
Severity is monotonic: once raised (including by escalation tiers), it never downgrades until the incident resolves.
Escalation checkโ
// Default tiers: [3, 10]. Crossing the first raises severity to "high",
// crossing the second to "critical".
if cur >= e.config.EscalationTiers[i] {
ev.Severity = severityForTier(i, inc.Severity)
}
7 restarts cross tier 0 (โฅ3), so severity escalates to high.
Node inhibitionโ
When inhibition.nodeSuppressesPods is enabled and the pod's node has an
active node-level incident, the pod is attributed to the node's alert โ it is
counted and listed, but not announced itself.
Smart groupingโ
If the event should speak but arrives inside the grouping window, it is
buffered (grouping.go, group_flush.go). The window closes โ one ActionUpdate
notification summarizing the whole group on a stable group key, throttled by a
4ร-window cooldown. If the OOM story was one pod, it is announced at the edge
immediately.
Phase 4: Insight (Cause / Impact / What-Changed)โ
The old "AI analysis" step never existed. In-process analysis replaces it:
LifecycleHook fires (action != skip)
โ
โผ
app.lifecycleHook: opts.diagnose(inc, action)
โ (no diagnosis for resolves or mass failures)
โผ
insight.Engine.Analyze(inc) โ walks the dependency graph
โ
โโโ cause.go "node worker-2 may be unhealthy"
โ "owning Deployment orders-api is unhealthy"
โ "referenced ConfigMap may have changed"
โ (else: walk backward to the deepest root)
โโโ impact.go "5 pods, affecting 2 services" (walk downstream)
โโโ changes.go "Deployment dev/api updated 3m ago" (ChangeTracker)
โโโ patterns.go recognised signatures (repeating OOM, probes, image pulls)
โ
โผ
Result travels with the notification as the ๐ง Diagnosis block
There is no external model, no sidecar container, and no enrichment channel โ
kwatch analyzes the incident with the graph it already maintains. If
diagnoses come back empty on a large cluster, check kwatch_graph_nodes and
kwatch_graph_edges on /metrics: an empty graph explains nothing.
Phase 5: Alert Dispatch (NotifyIncident โ Provider)โ
AlertManager.NotifyIncident(inc, action, insight)
โ
โผ
Silence check โโโโ match? โ DROP (silence.go, compiled silence index)
โ no match
โผ
fanOut: clone incident โ one deliverJob per configured provider channel
โ non-blocking
โผ
Channel saturated? โโโ drop the arriving job, record dead-letter
โ delivered (channel cap 256)
โผ
Provider worker: deliverOne (delivery.go)
โโโ routes match? (routing.go) โโ no match โ Skip provider
โโโ buildMessage โ ReportBuilder + provider renderer
โโโ truncate to provider byte limit
โโโ Send with retry classification (alert/util.Send):
โ โข 2xx โ success
โ โข 429 โ ratelimit.Error; honour Retry-After (header or body)
โ โข other 4xx โ PermanentError โ dead-letter immediately
โ โข 5xx / transport โ retried with backoff
โโโ all attempts failed?
โโโ fallback configured? โ try the fallback provider
โโโ else โ dead-letter queue (ring buffer of 100, /deadletters)
Only retryable failures are retried. A malformed 4xx payload dead-letters
immediately instead of delaying the alerts queued behind it. Provider
delivery is through the shared alert/util.Send helper; the linter forbids
raw net/http inside providers.
What the message looks likeโ
Every notification is built from one provider-agnostic Report and rendered
top-down:
๐ Pod not ready โ dev/api ยท Deployment ยท ContainersNotReady ยท high
pod stopped being ready 2m ago
๐ง Diagnosis
โข Why: node ip-10-0-81-7 may be unhealthy (node_failure)
โข Impact: affects service api
โข Changed recently: deployment dev/api updated 3m ago
๐ก check readiness probe and recent logs
pod api-584ddc9849-gjwjp ยท image api:1.2.0 ยท node ip-10-0-81-7 ยท 2m ยท dev
๐ Events โ from api-584ddc9849-gjwjp
Aug 25 23:52:21 FailedScheduling 0/5 nodes are available โฆ
Report sections (internal/message/report.go) are populated selectively:
headline, current state, diagnosis (hint + cause + impact + pattern), evidence
(logs/events), recent changes, and type-specific sections (OOM timeline,
probe endpoint, image-pull, scheduling delay). Renderers
(slack_renderer.go, discord_renderer.go, plaintext_renderer.go,
text_renderer.go) drop what they don't understand.
Phase 6: Delivery & User Notificationโ
Provider accepts the payload (HTTP 2xx)
โ
โผ
Retry loop exits, breaker state resets
โ
โผ
User sees the notification in #kwatch-alerts
The entire flow โ from pod crash to chat message โ completes in well under a second in normal operation. There is no AI stage and no sidecar round-trip: the diagnostic block is computed locally from the in-memory dependency graph.
Summary: End-to-End in 8 Stepsโ
| # | Phase | Package |
|---|---|---|
| 1 | Informer detects pod change, enqueues key | internal/controller |
| 2 | Filter pipeline evaluates the pod | internal/handler + internal/filter |
| 3 | Correlation engine dedups and decides (five stages) | internal/correlation |
| 4 | Severity resolved, escalation applied | internal/correlation + internal/enricher |
| 5 | Insight diagnoses cause / impact / what-changed | internal/insight |
| 6 | Alert manager formats the report | internal/alert + internal/message |
| 7 | Provider retry / dispatch | internal/alert/* (via alert/util.Send) |
| 8 | User receives notification | the configured provider |