Almost every meaningful detection is a statement about time: five failed logons then a success within two minutes, a process spawn followed by an outbound beacon within thirty seconds, a privilege grant that is never followed by a corresponding revoke. Getting those statements right depends entirely on how the correlation engine defines and closes its time windows, and the naive choice — windowing on the clock of the machine doing the processing — silently breaks every one of these rules the moment telemetry arrives out of order. This page is part of the broader alert correlation rule engines discipline, and it builds the event-time windowing substrate that sequence and threshold rules run on: tumbling, sliding, and session windows keyed on the event’s own timestamp, with watermarks that decide when a window is safe to close and how late an event may be before it is dropped.
Problem Framing
Consider a concrete failure scenario. A brute-force rule fires on “≥ 10 failed logons for one account within 60 seconds.” The SOC ingests authentication events from three collectors, one of which batches and flushes every 45 seconds, so its events routinely arrive 30–40 seconds after they actually occurred. A window that closes on processing time — “the last 60 seconds of wall clock” — sees only the events that happened to arrive in that wall-clock minute, splitting a real 12-failure burst across two windows as 7 and 5 and firing on neither. Worse, when the batched collector flushes, ten already-late events land in a window that has moved on, and the engine either silently drops them or, if it naively reopens the window, becomes trivially evadable by an attacker who paces failures to straddle the boundary.
The job of a temporal windowing engine is to make those rules correct under real-world disorder: assign every event to a window by its event time, not its arrival time; hold a window open until a watermark guarantees no more on-time events can arrive; and drop or divert genuinely late events deterministically rather than letting them corrupt an already-closed result. That requires three things working together: an event-time window assigner, a watermark that tracks progress and bounds lateness, and a sequence evaluator that reasons over the window’s ordered contents. The rest of this page builds exactly that.
Prerequisites & Environment
The reference implementation targets Python 3.11+. The core is standard library; pydantic provides the validated event contract and redis optionally backs shared window state across workers.
python3 -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6,<3.0" "redis>=5.0,<6.0"
Infrastructure assumptions:
- An upstream normalized event stream where every event carries a timezone-aware event-time timestamp. Windowing on un-normalized timestamps is the most common source of phantom results; timestamp normalization is an ingestion responsibility handled by the schema validation pipelines.
- A shared state store (Redis or equivalent) if windows must survive worker restarts or be shared across a consumer group; a single-worker deployment can hold window state in memory.
- A dead-letter queue for events that arrive beyond the allowed lateness bound, so late data is retained for forensic replay rather than silently discarded.
Architecture Overview
The engine is a single logical stage with three internal phases — assign, watermark-gate, evaluate — fed by normalization and feeding the rule actions. Events later than the watermark’s lateness bound fail closed to the DLQ; on-time events populate windows that are evaluated at close.
The pivotal design decision is that windows are keyed and closed on event time, gated by a watermark. The watermark is a monotonic estimate of “event time up to which the stream is believed complete”; a window closes only when the watermark passes its end, and an event whose timestamp is older than watermark − allowed_lateness is late by definition and diverted. This is what makes results correct under disorder and non-reopenable by an attacker pacing events across a boundary.
Step-by-Step Implementation
Step 1 — Define the event contract and window types
Every event carries an event-time timestamp and a correlation key. Window types are modeled explicitly so a rule declares its temporal shape.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pydantic import BaseModel, Field
class WindowType(str, Enum):
TUMBLING = "tumbling" # fixed, non-overlapping
SLIDING = "sliding" # fixed size, overlapping by step
SESSION = "session" # gap-bounded, activity-defined
class CorrelationEvent(BaseModel):
event_id: str = Field(min_length=1)
event_time: float = Field(gt=0) # timezone-aware epoch seconds
key: str = Field(min_length=1) # correlation key (user, host, ...)
signal: str = Field(min_length=1) # e.g. "auth_fail", "auth_success"
@dataclass(frozen=True)
class WindowSpec:
wtype: WindowType
size_seconds: float
allowed_lateness: float = 30.0
session_gap: float = 0.0 # used only for SESSION
Step 2 — Assign an event to its window(s)
Tumbling and sliding windows are computed arithmetically from the event time; session windows are resolved against recent activity for the key.
def window_starts(event_time: float, spec: WindowSpec) -> list[float]:
"""Return the start timestamp(s) of the window(s) this event belongs to."""
size = spec.size_seconds
if spec.wtype is WindowType.TUMBLING:
return [event_time - (event_time % size)]
if spec.wtype is WindowType.SLIDING:
# A step-size of size/2 yields 50% overlap; adjust as the rule needs.
step = size / 2
first = event_time - (event_time % step)
# Event belongs to every sliding window still covering it.
return [first - step, first]
# SESSION windows are keyed by activity gaps, resolved in the evaluator.
return [event_time]
Step 3 — Track the watermark and gate lateness
The watermark advances as event time progresses, minus a bounded delay that absorbs normal disorder. An event older than the watermark by more than the allowed lateness is diverted.
class Watermark:
def __init__(self, max_out_of_order: float = 15.0) -> None:
self._max_ts = 0.0
self._delay = max_out_of_order
def observe(self, event_time: float) -> None:
self._max_ts = max(self._max_ts, event_time)
@property
def value(self) -> float:
# Watermark trails the max seen time by the disorder bound.
return self._max_ts - self._delay
def is_late(self, event_time: float, allowed_lateness: float) -> bool:
return event_time < self.value - allowed_lateness
Step 4 — Evaluate a closed window’s sequence
When the watermark passes a window’s end, the window closes and its ordered events are checked against the rule’s pattern — here a threshold-then-transition sequence.
import logging
logger = logging.getLogger("soc.correlation.windows")
def evaluate_bruteforce(events: list[CorrelationEvent], min_fails: int = 10) -> bool:
""">= min_fails auth_fail followed by an auth_success, in event-time order."""
ordered = sorted(events, key=lambda e: e.event_time)
fails = 0
for ev in ordered:
if ev.signal == "auth_fail":
fails += 1
elif ev.signal == "auth_success" and fails >= min_fails:
logger.info('{"msg":"bruteforce_match","key":"%s","fails":%d}', ev.key, fails)
return True
return False
def close_due_windows(
windows: dict[tuple[str, float], list[CorrelationEvent]],
spec: WindowSpec,
wm: Watermark,
) -> list[tuple[str, float]]:
"""Return keys of windows the watermark has passed; caller evaluates them."""
due = []
for (key, start), _events in list(windows.items()):
if wm.value >= start + spec.size_seconds:
due.append((key, start))
return due
Schema & Validation Integration
Temporal windowing depends on one field being correct above all others: the event-time timestamp. That value is set and type-enforced upstream by JSON event normalization, which guarantees a timezone-aware epoch so event_time % size is meaningful across sources in different timezones. The CorrelationEvent model is the contract boundary — an event missing a timestamp or key is rejected (ERR_WINDOW_001) rather than dropped into an arbitrary window. Windows are keyed by the same correlation key that cross-source event linking resolves, so a temporal rule and an entity-linking rule reason about the same principal. A rising rejection rate on the timestamp field is a direct drift signal to feed into the error categorization frameworks.
Error Handling & DLQ Routing
Every failure produces a stable code so window behavior is queryable. Codes follow the ERR_CATEGORY_NNN convention.
| Code | Meaning | Recovery action |
|---|---|---|
ERR_WINDOW_001 |
Event missing event-time or correlation key | Reject to DLQ; fix the normalization mapping |
ERR_WINDOW_011 |
Event later than watermark minus allowed lateness | Divert to DLQ for forensic replay; do not reopen a closed window |
ERR_WINDOW_021 |
Watermark stalled (a source stopped advancing event time) | Force-close on a processing-time timeout; alert on the silent source |
ERR_WINDOW_031 |
Per-key window cardinality exceeded | Shed oldest events; investigate a flood inflating one key |
ERR_WINDOW_041 |
Shared window-state store unreachable | Trip circuit breaker; buffer locally with bounded backpressure |
The cardinal rule is a closed window never reopens. Late events (ERR_WINDOW_011) are retained and replayable but may not mutate a result the engine has already emitted, because reopen-on-late-data is a sequence-rule evasion primitive. The second rule handles the opposite failure: if a source goes silent the watermark can stall (ERR_WINDOW_021), so a processing-time safety timeout force-closes stuck windows rather than letting detections hang indefinitely.
Performance Tuning
Windowing cost is dominated by the number of concurrently open windows and per-window state size. Tune in this order:
- Prefer tumbling over sliding where the rule allows. Sliding windows multiply the number of open windows per event by the overlap factor; only pay that cost when the rule genuinely needs overlapping coverage.
- Bound the disorder delay to the real p99 out-of-order lateness, not a generous guess. A watermark delay larger than necessary keeps windows open longer and inflates memory; measure actual arrival skew and set it just above the p99.
- Cap per-key cardinality (
ERR_WINDOW_031) so a scanning host cannot balloon one window; this pairs with the same discipline in cross-source event linking. - Evict on close, promptly. Free a window’s state the moment it is evaluated so closed windows do not accumulate; drive closing from watermark advancement, not a periodic full scan.
- Shard by correlation key across workers so window state and evaluation scale horizontally, keying the partition on the same field the rule windows on.
Verification & Observability
Confirm correct operation with a match log per closed window, counters per error code, and tests that assert out-of-order correctness.
def test_out_of_order_events_still_correlate() -> None:
spec = WindowSpec(WindowType.TUMBLING, size_seconds=60, allowed_lateness=30)
wm = Watermark(max_out_of_order=15)
base = 1_000_000.0
events = [
CorrelationEvent(event_id=f"f{i}", event_time=base + i, key="u1", signal="auth_fail")
for i in range(10)
]
# Success arrives out of order (earlier processing, later event time).
events.append(CorrelationEvent(event_id="s1", event_time=base + 20,
key="u1", signal="auth_success"))
for ev in events:
wm.observe(ev.event_time)
# All events share one tumbling window; evaluation sees the full ordered set.
assert evaluate_bruteforce(events, min_fails=10) is True
def test_late_event_is_diverted_not_merged() -> None:
wm = Watermark(max_out_of_order=15)
wm.observe(1_000_100.0) # advances watermark
# An event 90s older than the watermark is late beyond a 30s allowance.
assert wm.is_late(1_000_000.0 - 40, allowed_lateness=30) is True
Operationally, emit windows_open (a memory proxy), windows_closed_total, window_dlq_total{code}, and watermark_lag_seconds (max event time minus watermark). A climbing watermark_lag_seconds with steady ingest points at a stalling source (ERR_WINDOW_021); a climbing window_dlq_total{code="ERR_WINDOW_011"} points at a collector whose batching exceeds the allowed lateness.
Troubleshooting
FAQ
What is the difference between event time and processing time?
Event time is when the activity actually happened, carried in the event’s own timestamp; processing time is when the correlation engine happens to handle it. SOC telemetry routinely arrives out of order because collectors batch, buffer, and retry, so processing-time windows split real bursts and produce phantom results. Windowing on event time — and using a watermark to decide when a window is complete — is what makes time-based rules correct despite that disorder.
How do I choose between tumbling, sliding, and session windows?
Use tumbling (fixed, non-overlapping) for simple threshold rules like “N events in a minute” — it is the cheapest. Use sliding (overlapping) when a burst that straddles a fixed boundary must still be caught, accepting higher memory from overlapping windows. Use session (gap-bounded) when the unit of analysis is a period of activity with no fixed length, such as a login session or an attack sequence delimited by idle gaps.
What is a watermark and why not just wait a fixed delay?
A watermark is a moving estimate of the event time up to which the stream is believed complete; a window closes when the watermark passes its end. It adapts to the stream’s actual progress rather than a blind fixed wall-clock delay, so it closes windows promptly when data is flowing and holds them open when a source lags. The allowed-lateness bound on top of the watermark then defines exactly how late an event may be before it is diverted.
Why must a closed window never reopen for late data?
Because reopening is an evasion primitive. If a late event can mutate an already-emitted result, an attacker who deliberately delays one stage of an attack can slip a sequence rule by making the triggering event arrive after its window closed. Diverting late events to a dead-letter queue keeps them available for forensic replay while guaranteeing that a detection, once decided, cannot be silently unwound.