How it works
One record, one clock, and a chain that names every link
Everything NetRewind records is an Event. That single struct is the contract the whole system depends on: the collectors produce these, the store keeps them, the correlation engine reads them, and the CLI renders them. It is versioned, currently at schema v1.
Five principles decided its shape, and each of them is a constraint the code is actually held to rather than a slogan.
- 01
An event is a state change or a measured observation, never a packet
Recording deltas rather than traffic is what makes a week of history fit in gigabytes instead of terabytes.
It is also why most of what is stored is directly useful during an incident, rather than being raw material that has to be mined for an answer while the outage is still running.
- 02
Every event is correlatable
Every event carries identities that can be matched across sources. Without that there is no causal chain — only a pile of logs that happen to share a clock.
It is the difference between five tools each reporting a symptom and one record in which the symptoms can be joined.
- 03
Every event carries its evidence
Enough of the raw observation is kept on the event to show a human why we believe it, without going back to another tool.
An assertion you cannot check is a rumour. During an incident, at three in the morning, a rumour is worse than nothing — it costs an hour before anyone thinks to doubt it.
- 04
One strict notion of time
Wall clock and monotonic clock, both in nanoseconds, both UTC. Every event carries both, always.
This is the field most systems get casually wrong, and it is the one that decides whether a timeline can be trusted at all.
- 05
The recorder admits its blind spots
A gap that is not recorded is indistinguishable from a period in which nothing happened.
This is the difference between a log and evidence. system.gap states exactly how long the recorder was not watching, measured against a heartbeat written every ten seconds. system.drop states how many events the kernel had to throw away when the eBPF ring buffer overflowed.
A recorder that silently omits what it missed is not evidence of anything: the record would show a quiet network, and a quiet network is what an operator concludes when nothing is wrong.
The common envelope
Carried by every event without exception, whatever produced it.
| Field | Type | Meaning |
|---|---|---|
| event_id | ULID | Unique, and lexically sortable by creation time |
| schema_v | uint16 | Envelope version |
| ts_wall | int64 | Nanoseconds since the Unix epoch, UTC |
| ts_mono | int64 | Nanoseconds on the observer’s monotonic clock |
| observer_id | string | Which recorder saw this |
| source | enum | netlink · ebpf · conntrack · nftables · dhcp · dns · lldp · snmp · probe · config · internal |
| kind | string | family.action, e.g. l2.arp_binding_changed |
| severity | enum | info · notice · warn · error |
| confidence | uint8 | 100 when observed directly, lower when inferred |
| subject | EntityRef | The entity the event is about |
| related | EntityRef[] | Other entities involved |
| attrs | object | Fields specific to this kind |
| evidence | object | The trimmed raw observation |
| dedup_key | string | Folds repeats of the same fact together |
| count | uint32 | How many repeats were folded |
Why two clocks
Because the two questions a timeline has to answer are not the same question, and no single clock answers both.
ts_wall
When did this happen. It is what a human queries by.
It can jump. NTP corrects it, a VM is resumed, someone sets the date by hand.
ts_mono
In what order did this happen. It never jumps.
It is meaningless across a reboot, and cannot be compared between machines.
Recording both is what lets the timeline stay correctly ordered through a clock correction instead of quietly reordering itself — which is a failure mode that produces a perfectly plausible, perfectly wrong account of an outage.
It is also how system.clock_step is detected: a wall-clock movement the monotonic clock did not see.
An address is not an identity
How do you say “the same machine” across a week?
This is the hardest decision in the schema, and every easy answer is wrong. An IP address moves. A MAC is forged, and modern phones randomise theirs per network. A hostname may not exist at all.
So an address is not stored as the identity of anything. Events reference a stable host_id, resolved at ingest time against a temporal identity table — one that records not just which attribute belonged to which machine, but when.
identity_bindinghost_id stable ULIDattr_type mac | ipv4 | ipv6 | hostname | switch_port | dhcp_client_idattr_value stringvalid_from tsvalid_to ts | NULL -- NULL means still currentconfidence uint8
- EntityRef.ID holds the resolved identifier, and is empty until resolution has run.
- EntityRef.Label is what a human should read.
- EntityRef.Attrs keeps the raw observations — mac, ip, ifindex — so an event stays interpretable even if resolution later turns out to have been wrong.
hostifacedevicesubnet / vlanserviceflowobserver
The payoff is a query you cannot otherwise write: asking about an address as it was last Tuesday returns the machine that held it then, not the one holding it now — and it does not drag in the events of whichever machine has the address today.
Resolution happens at ingest rather than at query time, so the answer does not change depending on when you ask the question.
Known limitation
MAC randomisation breaks long-term tracking of mobile devices. DHCP client-id and behavioural fingerprinting narrow the gap; they do not close it. This is stated rather than papered over.
The nine families
Every event is one of these, and the family is the part before the dot. The family says which layer of the network the event describes; the action after it is the state change that occurred.
Seven kinds across four of these families are declared in the schema and produced by nothing, and change.* is produced by nothing at all. They are listed anyway and named as what they are, because a catalogue that hides its holes is the thing this project is arguing against.
The sources below are the Linux ones. link.*, the neighbour entries under l2.*, and the route and address entries under l3.* also have a Windows source — the IP Helper API rather than netlink. flow.*, policy.*, and the dhcp./dns./metric. families have no Windows source in this release; a recorder’s own capability report says exactly what a given build is watching.
link.*
Layer 1 · netlink
- link.up
- link.down
- link.flap
- link.mtu_changed
- link.error_rate_high
Administrative state — someone shut the port — is distinguished from operational state — the carrier dropped — through attrs.cause. Reporting both as “interface down” is what makes most tools useless mid-incident: the two have completely different causes and completely different fixes. link.flap counts only carrier drops, never administrative ones: a run of outages is a different finding from one outage, but an engineer shutting a port repeatedly is somebody working, and counting that as flapping blames the cable for the engineer. link.error_rate_high is not a state change at all — it comes from the interface error counters crossing a proportion of total packets.
l2.*
Layer 2 · netlink neighbour table
- l2.arp_binding_new
- l2.arp_binding_changed
- l2.mac_moved
- l2.duplicate_ip
- l2.neighbor_failed
l2.arp_binding_changed opens more incidents than anything else at this layer: a binding moving under a running network is exactly what a hijack, a failover and a misconfigured static entry all look like from here. When the address it moved under is the default gateway it is raised to an error rather than a warning — the same observation, a much larger consequence. l2.lldp_neighbor_changed and l2.vlan_seen are in the schema; the LLDP collector is not written, so switch-side topology is the largest thing this recorder still cannot see.
l3.*
Layer 3 · netlink routes and addresses, and ICMP from the packet socket
- l3.route_added
- l3.route_removed
- l3.route_changed
- l3.default_route_changed
- l3.addr_added
- l3.addr_removed
- l3.icmp_unreachable
- l3.mtu_blackhole
A change is reported only when the route that actually wins for a destination changes, so a standby path being installed is not mistaken for traffic moving. The last two come from reading ICMP rather than from netlink: l3.mtu_blackhole is the one worth knowing about, because a path that silently drops large packets while small ones sail through is the fault most often misdiagnosed as an application problem.
flow.*
Layer 4 · eBPF on sock/inet_sock_set_state and tcp/tcp_receive_reset
- flow.first_failure_for_pair
- flow.handshake_fail
- flow.reset
- flow.timeout_no_close
- flow.rollup
flow.first_failure_for_pair is the strongest single signal in the system. An unanswered connection on its own is ordinary — closed ports are closed. An unanswered connection between two machines that were talking a moment ago means something changed. Individual connections are not events: a busy segment opens thousands a second, so everything ordinary is aggregated into a flow.rollup every ten seconds and only anomalies earn a row. A second tracepoint separates a connection that was refused outright from one that simply stopped answering, which are different faults with the same symptom. That is also why there is deliberately no flow.open or flow.close: a row per connection would tell an operator nothing a counter could not. flow.retransmit_spike is declared and not yet produced.
dhcp.* · dns.*
Naming and addressing · a filtered packet socket
- dhcp.server_seen
- dhcp.offer
- dhcp.ack
- dhcp.nak
- dhcp.lease_changed
- dns.resolver_changed
- dns.query_fail
- dns.latency_spike
Metadata only, never payloads: a cBPF filter attached to the socket admits IPv4 ICMP and UDP on three ports, and nothing else is ever copied to userspace. It is written as instructions in the repository rather than compiled from a tcpdump string at runtime, because a filter that decides what a process is ever handed is a security boundary and should be reviewable as one. dhcp.server_seen — a second DHCP server answering on a segment — is one of the events most likely to open an incident, and it is the fault that is hardest to find by hand because the machines that took the wrong lease are not the machine that is misbehaving.
policy.*
Filtering decisions · nftables, polled
- policy.rule_changed
The ruleset is read every five seconds and compared by digest, so a change to what the firewall permits lands on the same timeline as its consequences — which is what lets a filtering change appear as the opening link of a causal chain. Each rule is qualified by the table and chain that contains it, so the evidence reads table inet filter / chain input :: tcp dport 9300 drop: comparing bare rule text would call a rule moved between chains no change at all, and that is exactly the sort of edit that breaks a network quietly. What is deliberately not here is the consequence of a filtering change. A pair that stopped connecting is observable whatever did the blocking — a rule on this box, an ACL on a switch, a firewall three hops away — so that belongs to flow.first_failure_for_pair; an event here could only ever see the first of the three, which would be the narrowest case dressed as the general one. policy.drop_burst is declared and needs counters that are not read yet.
metric.*
Measured series · active probes
- metric.anomaly
The only collector that puts a packet on the network. Everything else here is passive, which leaves a hole: loss is an absence, and nothing announces an absence. A handful of small probes a minute go to targets given to it or discovered from the routing table, and metric.anomaly is emitted when the answers stop coming back or start taking much longer than they used to — measured against that target’s own baseline rather than a fixed threshold, because a link that always ran at 40 ms is not in trouble at 40 ms. Interface counters and conntrack table size are specified as series and are not collected yet.
change.*
Intended changes · external feed
- change.config_applied
- change.device_reboot
- change.admin_action
The only family that is not observed, and the only one with nothing feeding it. It would come from NetIntent, or from diffing device configuration over SSH or SNMP. It is the family that lets the timeline answer what were we doing when it broke, and the change-broke-a-path rule already matches on it — so the day something feeds it, that rule gets better without being edited.
system.*
The recorder on itself · internal
- system.gap
- system.drop
- system.clock_step
- system.start
- system.stop
Never remove these. system.gap is measured against a heartbeat written every ten seconds and states exactly how long the recorder was blind. system.drop states how many events had to be thrown away when they arrived faster than they could be read. All five are produced today, and two of the nineteen shipped rules exist only to surface them: one for a hole in the record, one for a single collector that died while the rest kept running — which is the more dangerous of the two, because the timeline goes on looking healthy.
The Isnad engine
Named after the classical Arabic method of establishing a chain of transmission in which every link is named and every link is verifiable. That is the standard the engine is held to: it distinguishes causes from correlates from precedes, and never claims a causal link it cannot show the evidence for.
Incidents are produced by the engine, not by collectors. A collector reports what it saw; the engine is the only thing permitted to say what it meant.
incident_id, opened_at, closed_at, statustitle, severity, confidenceroot_cause { kind, entity_ref, confidence }chain[] { seq, event_id, relation, why, evidence_ref }victims[] host_id[]rule_id
The symptom arrived. What changed just before it?
A rule is an ordered sequence of clauses, not a set. The first required clause is its anchor: the event whose arrival makes the engine evaluate the rule at all.
Clauses after the anchor are matched forwards in time. Clauses before it are matched backwards, nearest first. That single asymmetry is what lets a rule ask the question this project exists for.
Because the symptom is what arrives — a pair that stopped connecting, a route that vanished — and the useful question is never what followed it. It is what changed just before.
match:- as: change # optional, searched BACKWARDSkinds: [l2.arp_binding_changed, l3.route_changed, link.down]optional: truewhy: This is the last thing that changed on the path before it broke.- as: breakage # required - the anchorkinds: [flow.first_failure_for_pair]relation: causeswhy: Two machines that had been connecting can no longer complete a handshake.
With no preceding change inside the window the rule still fires, with a one-link chain: the symptom stands on its own rather than being attached to a guess.
The nearest change is chosen deliberately. An older one would be a worse guess wearing the same claim — which is precisely why a rule built this way must say in its advice that the named change is the nearest in time, not a proven cause.
Correlation keys keep unrelated failures apart
correlate_on names fields every matched event must agree on. Without it, two failures happening at the same moment on different machines get woven into one incident that never happened. With correlate_on: [subject], a port flapping on one switch cannot be joined to a neighbour failing on another.
Confidence is an upper bound, not a score
The number in a rule is how far its author trusts the inference. The engine then lowers it to the least certain event in the chain. An incident resting on an inference cannot be more certain than the inference.
An incident grows rather than duplicating
When an optional clause arrives after the rule has already fired, the fuller account replaces the thinner one rather than appearing beside it as a near-duplicate. One incident, better told.
A consequence cannot arrive before its cause
The clauses are a sequence. The engine looks for the first, then the second after it, all inside the window. A chain that reads backwards is not the shape the rule describes, and the engine will not pretend otherwise.
The signal the chain hangs on
flow.first_failure_for_pair
Two machines that were connecting a moment ago no longer can
This is the strongest single signal in the system, and the reason the causal chain above has anything to anchor to.
- Ordinary
- An unanswered connection on its own. Closed ports are closed; nothing has been learned.
- Evidence
- An unanswered connection between two machines that were talking a moment ago. Whatever else is true, something between them changed — a filtering rule, an ACL, a route, a service.
- The pair is (source, destination, destination port). The ephemeral source port is not part of the relationship — it is the relationship that matters, not the socket.
- A success stays relevant for twenty-four hours. A service that last worked a month ago is not evidence that anything just changed.
- It is reported once per break and re-armed the moment the pair works again, so a fault that recurs after a recovery is reported afresh rather than swallowed.
- The source is an eBPF program on the sock/inet_sock_set_state tracepoint — a stable tracepoint rather than a kprobe, because kprobes break silently when the kernel renames or inlines a function, and a recorder that stops recording without saying so is the failure mode this project exists to prevent.
Known limitation. The memory of which pairs worked is in-process. After a restart there is no baseline, and it reports ordinary handshake failures until it sees a pair succeed again.
The discipline
Note the middle column
which caused is a claim about mechanism. and at the same time is only co-occurrence. The engine never blurs the two.
A tool that does teaches its operator to distrust it — and an operator who distrusts the timeline is back to guessing, which is the condition this whole project exists to end.
- causes
- | which caused
- A mechanism. This event is why the next one happened. Only where a rule author encoded the mechanism and could defend it to a sceptical colleague.
- correlates
- | and at the same time
- They moved together. No mechanism is claimed. Anything can notice that two things happened close together. This is that, and says so.
- precedes
- | and then, without a known link
- Only ordering in time. The weakest claim available, and it is spelled out rather than drawn as an arrow.
The correlation core is named Isnad, after the classical Arabic method of establishing a chain of transmission in which every link is named and every link is verifiable. That is the standard the engine is held to.
Confidence follows the same rule. The number in a rule is how far its author trusts the inference, and it is an upper bound: the engine lowers it to the least certain event in the chain. An incident resting on an ARP binding the collector was only 80% sure of is an 80% incident, however sure the rule is of itself.
The path an observation takes
Collectors normalise whatever they saw into the common envelope. Identity resolution attaches a stable host_id. The store keeps it, folding repeats. The correlation engine reads it back and produces incidents with an explicit causal chain.
Each stage can only weaken a claim, never strengthen one — which is why confidence falls through the pipeline and never rises.
Three ways out of the same store: the CLI, a read-only server-rendered web view bound to loopback (netrewind serve), and export to Prometheus and OpenTelemetry.