Skip to content

Flow view

The exporter serves a built-in view of your tailnet's network flow logs at /flows on the admin server. It answers the questions you actually open a flow tool for — who talked to whom, over what, how much, and when — without a Grafana or Prometheus backend in the loop.

It is a convenience view, not a second telemetry pipeline. Everything it shows is derived from the same flow records the exporter is already sending over OTLP, which remains the system of record. By default the store is in memory, bounded, and lost on restart; setting flows.store.directory switches to an opt-in on-disk backend that survives a restart and can answer over days — see Persistent storage below.

What it shows

Topology. A force-directed graph of device-to-device conversations. Vertices are sized by total volume, edges by the bytes on that conversation. Drag a device to reposition it; click one to filter the whole page to it and dim everything it does not talk to.

Traffic over time. Transmit and receive over the selected window. Drag horizontally on the chart to zoom into a span — that pins the view and pauses polling; double-click, or press jump to now, to go back to live.

Top devices and conversations. Ranked by bytes, split by role, so you can tell what a device sent from what it received rather than inferring it from edge direction.

Destination services. What is being talked to, resolved to IANA service names where the port is registered. Overlay traffic only — see what the services section counts.

Identity. Traffic by user, by tag, and by operating system, taken from the node metadata each flow record carries. Tags break down individually, so a device tagged tag:servers,tag:prod is counted under both rather than under a joined label that matches nothing you would search for.

Who talks to whom. A tag-to-tag (or user-to-user) matrix: rows are the sending identity, columns the receiving one, shaded by volume. Click a cell to filter the connection list to exactly that relationship. See what the matrix can and cannot show below.

Policy reconciliation. What the tailnet's own ACL says about the traffic above — see reading the policy section, which is worth reading before acting on it.

Path quality. Whether each peer was reached directly or had to be relayed through DERP, and which relay carried it — see reading the path section.

Recent connections. The newest individual connections, with their raw endpoints, the verified reporter node ID, bounded trust/consistency diagnosis, the policy's reading of each one and how each underlay connection was carried, filterable by device, reporter, address, port, service, protocol, verdict or path. The aggregates cannot answer "what exactly was that", so a bounded number of raw connections are kept alongside them.

The reporter is FlowLog.NodeID, which Tailscale's logging service verifies. Most other flow fields, including the embedded source reference, are written by the reporting node and are not independently verified. Reporter trust therefore uses only the configured node-ID allowlist or authoritative device-cache tags; embedded tags never grant it. match, mismatch, and missing_reference compare the verified reporter with the unverified embedded source reference without adding a raw node ID to high-volume metrics.

Field presence is reported as observed completeness, not as a guessed Destination Logging setting. Missing exit destinations and ports can be expected privacy-driven omissions when Destination Logging is off, but the exporter has no authoritative settings API that can distinguish that from damaged input. One populated record does not prove that the setting is enabled.

Enabling it

It is on by default. It needs the admin landing page, which is also on by default:

admin:
  enabled: true
  landing_page: true
  auth:
    token: ""        # required on any non-loopback bind — see below
flows:
  enabled: true
  retention: 6h

With admin.enabled or admin.landing_page off, the store is not built at all and a startup advisory says the setting is doing nothing. See flows for the full key reference.

Persistent storage

flows.retention's in-memory ring is the default and stays the default: nothing on disk, nothing that survives a restart, no new failure mode for the stateless single-binary story. Persistence is opt-in. Setting flows.store.directory to a directory replaces the ring with a SQLite-backed store:

flows:
  enabled: true
  retention: 6h        # ignored while store.directory is set — it only sizes the in-memory ring
  store:
    directory: /var/lib/tailscale2otel/flows   # setting this is what turns persistence on
    retention: 720h                            # 30 days — the disk retention, independent of the above

Two retentions, two different things — this is the easiest setting on this page to get wrong. flows.retention sizes the in-memory ring and is capped at 24h. flows.store.retention is a separate knob on the disk-backed store, with its own default (30 days) and no 24h cap.

The two never run together. A store is one or the other, chosen at startup by whether flows.store.directory is set, and there is no tiering between them — with persistence on, every query including the most recent minute is answered from disk, and flows.retention no longer governs what /flows can see. It still bounds the ring the process would build if you unset the path, and the window clamp follows whichever store is active, so a 30-day disk retention really is queryable.

One file per tailnet: flows-<tailnet>.db inside the configured directory. A device name is only unique within its own tailnet, so multi-tailnet mode gets one database per tailnet automatically — nothing to configure per entry.

What changes versus the in-memory ring:

  • Aggregates are exact, not capped. The in-memory store folds per-bucket overflow into __other__ and marks coverage partial (see Limits worth knowing). The disk store keeps one row per connection and computes every aggregate with GROUP BY at query time, so there is no per-key cap to overflow — Truncated on a query result is zero unless the write-behind queue below actually dropped something.
  • Recent connections pages the whole retained window, not a fixed 2,000-row ring. recent/ cursor paging (see the JSON API) walks however much is on disk, bounded only by flows.store.max_export_rows and the retention/row-cap sweep.
  • Bounded by retention sweep and a hard row cap, not by key caps. flows.store.max_rows (default 5,000,000) is enforced independently of flows.store.retention, so a traffic flood can't fill the disk before the next sweep (flows.store.sweep_interval, default hourly) runs.

Writes never block ingestion. Recording a connection enqueues it onto a bounded in-process queue (flows.store.queue_size) and returns immediately; a background goroutine batches queued rows to disk (flows.store.batch_size per transaction, forced out at least every flows.store.flush_interval so a quiet tailnet doesn't sit on a partial batch). If the queue is full — the writer falling behind a burst, or disk I/O stalling — the observation is dropped and counted, never queued indefinitely and never allowed to slow OTLP export. Drops surface in the admin API the same way the in-memory ring's truncation does.

PII / data at rest — read this before pointing directory at anything. The in-memory ring dies with the process; nothing is written anywhere, and Section Privacy above is what governs it. The disk store is a different exposure: flow rows carry user identities (source/destination emails, tags), and once written they persist across restarts and land in whatever backs up that volume. The configured pii_filter policy is applied to a row before it is written — the same redaction the OTLP export path applies — so a category you've turned off does not reach the database either. What pii_filter leaves enabled, the database keeps indefinitely (up to flows.store.retention) and your backup process will too. Size access to that directory and its backups accordingly; the admin token gates the view, but a filesystem-level or backup-level read bypasses it entirely.

Disk sizing — an estimate, not a measurement. This backend stores one row per connection across 29 mostly-short TEXT/INTEGER columns (endpoints, tags, verdict, path, byte/packet counters — see internal/flowstore/sqlitestore/schema.go) plus two indexes. Assume roughly 250–350 bytes per row including index overhead — nobody has benchmarked the real figure on this project's fixtures yet, so treat it as an order-of-magnitude planning number, not a measured one. Two ways to reason about it:

  • The row cap is the hard ceiling regardless of rate. flows.store.max_rows defaults to 5,000,000; at 300 bytes/row that's roughly 1.5 GB at capacity, whatever flows.store.retention says.
  • The retention window is the usual limit at moderate traffic, until the cap overtakes it. A tailnet sustaining around 2 connections/second (~170,000/day) reaches roughly 5.1M rows over the default 30-day retention — already past the row cap above. Below that rate, retention days × your connections/day × 300 bytes is the estimate; above it, the row cap is what actually bounds disk use.

Measure your own tailnet's connection rate (the tailscale.network.flow.count metric, or watch the row count directly) before committing to a volume size, and raise flows.store.max_rows deliberately if 1.5 GB is too small a ceiling for the retention you want.

Backups. Nothing backs this up for you — it's a plain SQLite file under flows.store.directory. Back it up the way you would any other application database if the history matters to you (a filesystem snapshot of the directory is fine; SQLite in WAL mode is safe to copy while the process is stopped, or via your storage layer's own consistent-snapshot mechanism while running). A lost or corrupted file loses history — nothing else. /flows on the in-memory path continues to work either way; loss here is not an outage.

Binary footprint. The engine is modernc.org/sqlite, a pure-Go, cgo-free implementation — chosen specifically because the release matrix builds CGO_ENABLED=0 and ships a distroless image. Adds about 4.6 MB to the binary; nothing else about the build changes.

Access control

/flows and /api/flows.json sit behind the same gate as the rest of the admin surface, with no exceptions:

  • With admin.auth.token set, both require it as the HTTP Basic password or as Authorization: Bearer <token>.
  • With no token, both are served only on a loopback admin.listen. On any other bind they are refused with HTTP 403 — the same fail-closed behaviour as the status page.

This matters more here than elsewhere: the page shows device names, addresses, and the user each device belongs to, unfiltered — see Privacy.

Privacy

/flows shows what the flow record carried, in full. pii_filter does not apply to it.

That filter governs the telemetry this process exports — what reaches your OTLP backend, and whoever can read it. The flow store is a different thing: in memory, never written anywhere, never sent anywhere, and readable only through the admin-authenticated surface above. Narrowing what you send onward is not a request to be blinded to your own tailnet, so emails: false still leaves the users breakdown populated here, and hostnames: false still leaves the topology graph drawn.

The consequence to be aware of: the admin token is what protects this data, on its own. Anyone you hand it to can see every device name, address and user the control plane reported, whatever your pii_filter says. If that is wider than you want, the answer is the token, not the filter.

This changed. The view previously applied pii_filter, so an operator who had switched a category off for their backend found it missing here too. See the changelog entry for the release that carries it.

What the identity matrix can show

A matrix cell needs the identity on both endpoints, and Tailscale's flow records do not carry all three fields on both sides. Measured against a live 3-hour capture (18,702 records, 34,680 source → destination node pairings):

matrix both endpoints carry it usable?
Tag → tag 24,154 pairings (70%) Yes — this is the one to reach for.
User → user 241 pairings (1%) Rarely. A tag-owned device has no user at all, so a machine-to-machine tailnet shows almost nothing.
OS → OS 0 No. Not offered.

There is no OS matrix, deliberately. srcNode never carries os — zero times in 18,702 records — while dstNodes entries do. An OS matrix could therefore only ever be empty, so the page does not offer one. For the same reason the Operating system breakdown is labelled destination side only: it describes what traffic was sent to, not what sent it.

The page states the matrix's coverage as a percentage of the window's traffic, so a low number reads as "most of this traffic has no tags on both ends" rather than as missing data.

Reading the policy section

Every policy-governed connection is checked against the tailnet's ACL as this evaluator reads it, as the connection is processed. The ACL comes from the acl collector and the tailnet roles from the users collector; with either disabled the section degrades rather than guessing.

This is a diagnostic, not an audit. Tailscale carried every connection shown, so it permitted every connection shown. Anything the section reports is a lead to look into — most often a subnet router or a VIP service whose semantics this reading does not fully capture — not traffic that got through.

The four verdicts

Verdict Meaning
permitted A rule covers the connection in the direction it was observed.
return traffic The half of the connection that established it is covered. Flow logs report both halves; a policy governs only one. On a live tailnet this was 37% of all connections — it is normal, not a finding.
not explained No rule covers it in either direction, as this evaluator reads the policy.
undecidable The policy could not be applied here. Never a finding — see below.

"Undecidable" is not "unexplained". A selector match is yes, no, or unknown, and a connection is reported as unexplained only when every rule definitively fails. An undeclared group:, a svc: VIP service, or an endpoint carrying no identity all make a rule undecidable, and the section says so rather than counting it against you. Collapsing unknown into "no" is what would turn this into a generator of confident false alarms.

Traffic no rule explains

Unexplained connections are aggregated into relationships: source identity, destination identity, transport and destination port — the shape a grant would be written in. Each endpoint is named by the most useful thing known about it: its tags, then its owner, then its device name, then its address.

That aggregation is what makes the output usable. On a live tailnet, 9,786 individually unexplained connections were three relationships, all involving one tag talking to two LAN addresses behind a subnet router.

tsmp — Tailscale reporting its own rejections

A connection whose transport reads tsmp is not traffic anybody sent. TSMP is Tailscale's own ICMP-ish protocol — IP protocol 99, which IANA reserves for "any private encryption scheme" — carried only between nodes inside the WireGuard tunnel, and it exists to say why something failed: an ACL drop, a refused connection, no route.

Two things make it worth reading rather than dismissing as an odd protocol number:

  • The source is the node that did the rejecting. A tsmp flow travels in the opposite direction to the traffic it is about, so it names the end that dropped something and the end whose traffic was dropped. That is independent corroboration of a not explained verdict, arriving from the other side of the connection.
  • It never appears in a packet capture. tailscaled neither accepts these from the host network stack nor sends them to it, so tcpdump will not show them on any interface — including the Tailscale one. On a live tailnet a 60-second capture of the sending host's LAN interface took in 1,978,983 packets and matched none of them while the API reported the flow continuously. Chasing the packets is a dead end; the flow log is the only place they are visible.

TSMP has no ports, so a tsmp connection shows none — see protocols with no ports below.

Rules that permitted nothing

The other half: which rules never covered anything in the window. The window is stated on the list, and it is the whole point — a rule can be entirely healthy and idle. NTP, ICMP and break-glass access routinely go hours without firing; on a live tailnet 9 of 19 rules were idle over three hours and almost all of them legitimately so. Widen the window before concluding a rule is dead.

What the evaluator covers

Selector families: *, tag:, named users, group:, ipset:, autogroup:{owner,admin,member,self,internet}, host aliases, IP literals and CIDRs. Port specs: *, bare ports (tcp+udp), proto:port, proto:* and ranges. Both grants (the current syntax) and the legacy acls accept entries.

Known limits, which the section reports honestly rather than guessing around:

  • svc: (VIP services) is undecidable. Resolving one needs a mapping this evaluator does not have.
  • autogroup:admin includes the Owner. A modelling choice, not something the API states.
  • Only accept rules are evaluated. ssh, nodeAttrs, postures and autoApprovers govern other things entirely.
  • physicalTraffic is not evaluated. It is the WireGuard underlay, not a connection a policy describes; those connections show no verdict at all.
  • Exit traffic carries no destination, so it is evaluated only against autogroup:internet.
  • ip: ["*"] covers every protocol, including ICMP — it is a wildcard over protocol as well as port. A bare port number is what implies tcp/udp only.

Reconciliation reads the identity the flow record carried — the same identity the rest of the page shows, and for the same reason (see Privacy). An endpoint the record carried nothing identifying for appears as unidentified.

Reading the path section

Tailscale connects two nodes directly when it can and falls back to relaying through a DERP server when it cannot. Both are end-to-end encrypted; a relayed path is simply slower and lower throughput, because the traffic goes via Tailscale's infrastructure instead of straight between the two machines.

This is read from physicalTraffic — the WireGuard underlay, which reports the endpoint each peer was actually reached at. Three values:

Path Meaning
direct_ipv4 The two nodes reached each other directly over IPv4.
direct_ipv6 The two nodes reached each other directly over IPv6.
derp The connection was relayed. Tailscale writes the loopback marker 127.3.3.40 in place of an endpoint address, and the DERP region ID in place of the port.

The marker is never shown as a device. A relayed connection's destination is that loopback marker, so the connection list shows a dash where the destination device name would be and keeps the raw 127.3.3.40:<region> beside it. The peer is on the source side of a physical record — that is how the API reports it — so it is still named there, and the per-peer table below is keyed on it.

The counts are connections, not bytes, and the two are usually far apart. On a live tailnet 11.6% of underlay connections were relayed but only 0.4% of the bytes: handshakes and keepalives relay while bulk transfer finds a direct path. The per-peer table shows both so neither can be read as the other.

Regions are shown as IDs. Tailscale's API does not serve its DERP map, so there is no supported source for the ID-to-name mapping. A built-in name table would go stale silently and mislabel a region you were about to act on, so the raw ID is what is shown. Tailscale's own published DERP map is where to look one up.

A peer being relayed is a lead, not a fault. The usual causes are a hard NAT in front of one of the two nodes, UDP blocked on the path, or genuinely no route between the two networks. Start with tailscale netcheck on the relayed peer.

Two things this deliberately does not claim:

  • Peer-relay paths are not distinguished from direct ones. A flow record reports the endpoint a peer was reached at, and a Tailscale peer relay looks like an ordinary endpoint in that field.
  • A physical connection with no endpoint gets no path at all, rather than being counted as direct. "We cannot tell" must not read as good news.

Note also that the peer here is named by its device name only — never by its tags, unlike the unexplained-relationship list. The question is which machine to go and look at, and a row keyed on tag:servers would merge every tagged server into one.

Limits worth knowing

Memory is bounded, and coverage is honest about it. Everything is aggregated to one-minute resolution on the way in, and every dimension has a per-minute cap. Beyond a cap, keys fold into __other__ and the page says coverage is partial rather than implying it is complete — the totals stay exact either way. This matters because the streaming receiver is a potentially unauthenticated ingress, and a flood of unique flow keys must not be able to grow the process without limit.

By default, retention is memory, not storage. flows.retention sizes a ring of one-minute buckets and is bounded to 24h. In multi-tailnet mode each tailnet keeps its own store, so memory scales with the number of tailnets too. Nothing survives a restart on this path. Set flows.store.directory to opt into an on-disk backend that does survive a restart and can hold weeks instead of hours — see Persistent storage. For long-range history you don't want to run a database for, query your OTLP backend instead — that remains the system of record either way.

Some flows carry no destination. Exit traffic never does — that is how the Tailscale API reports it, not a gap in decoding. Those connections count toward totals and show a dash where a destination would be, rather than a fabricated endpoint. Use the tailscale.exit_node.* metrics to measure exit traffic, which attribute by the relaying node.

The destination-services section counts overlay traffic only. A physical connection describes the WireGuard underlay — how two nodes reached each other — so its destination port is the ephemeral one the peer happened to be listening on, and on a relayed path it is not a port at all but the DERP region ID written where a port would go. Neither names a service, so neither is counted here; on a live tailnet the underlay was half the section's bytes and both of its top two rows. Nothing is hidden by this: the underlay endpoint is on the connection list verbatim, its direct-versus-relayed split is the path section, and its bytes count toward the totals and every other breakdown.

Device names need the devices collector or the record's own metadata. Flow records embed their endpoints' identity, so names resolve even with the devices collector disabled; the collector still gives better coverage for devices that have not appeared in a flow yet.

Protocols with no ports

ICMP and TSMP have no ports, and Tailscale still has to fill the addr:port shape it reports every endpoint in, so it writes :0 on both ends. That is a placeholder, not a port: port 0 cannot be a TCP or UDP endpoint, and over 3 hours of a live tailnet no tcp/udp connection carried it on either end (0 of 71,253) while every portless one did (3,224 of 3,224).

The view reads it as the absent port it is. A connection over one of these protocols therefore shows no destination port and no destination service, contributes nothing to the destination-services section, and is not counted as a distinct port by tailscale.network.unique.dst_ports. Its addresses are untouched — the record really carried those — and its bytes count toward the totals and every other breakdown, so nothing goes missing; what is dropped is a number that was never a port.

The same applies to the source port of every physical connection. The underlay reports the peer's overlay address as its source, which is an identity rather than a socket, so it too arrives as :0 and is read as absent.

Every ingestion path feeds it

The polling collector, the streaming receiver and the object-store reader share one flow processor, so the view is complete regardless of collectors.flowlogs.source. Cross-source de-duplication applies here exactly as it does to the metrics, so a window delivered twice is counted once.

The JSON API

The page is a shell that polls /api/flows.json; that endpoint is a supported read-only API in its own right, behind the same auth. The response carries a top-level schema_version integer and is a published, versioned contract — see docs/api/compatibility.md.

Parameter Default Meaning
window 1h Lookback span. Clamped to [1m, flows.retention].
end now Right-hand edge of the window, RFC3339. Clamped to what is retained.
top 20 Length of each ranked list. Capped at 200.
recent 200 Raw connections returned. Capped at 1000; 0 omits them.
tailnet first Which tailnet to report on. An unknown name is a 404, never another tailnet's data.
device Substring, case-insensitive, matched against either endpoint's node name.
addr Substring, case-insensitive, matched against either endpoint's address.
service Substring, case-insensitive, matched against the destination service.
identity Substring, case-insensitive, matched against either endpoint's user or tags.
type Exact: virtual, subnet, exit or physical.
verdict Exact: permitted, no_rule or undetermined.
path Exact: direct or derp.
cursor Opaque page token from a previous response's next_cursor.

Filtering and pagination

The seven filter parameters run server-side against the whole retained connection ring, not against the page a request happened to return. Before #296 the browser filtered only the rows the API had already sent, so a connection matching your search but sitting outside the returned tail was invisible — and the page gave no sign of it. Filters combine with AND, and apply to recent only; the ranked aggregates always describe the unfiltered window.

Each filter value is capped at 128 bytes. A longer one is rejected with 400 Bad Request naming the parameter rather than being truncated: a truncated filter matches more than you asked for, and would read as an answer.

Pagination uses next_cursor, an opaque token — pass it back as cursor for the next page of matching rows. An absent, malformed or unrecognized cursor is treated as "start from the newest" rather than an error, so a token held across a restart degrades to a fresh view instead of breaking it. Do not parse it; its contents are not a stable interface.

Four counters describe what you are looking at, and they are not interchangeable:

Field Meaning
recent_matched Rows in the window satisfying the filter, ignoring pagination.
recent_returned Rows in this response.
recent_retained Rows the ring currently holds, ignoring both window and filter.
recent_truncated The ring is at capacity, so a matching connection older than the oldest row shown may already have been evicted.

recent_truncated is the one that matters when a search comes back empty: it distinguishes "nothing matched" from "the ring no longer goes back that far".

Alongside the ranked lists, result carries tag_matrix, user_matrix and os_matrix — each an array of {src, dst, counts} cells ranked by bytes and capped at 400. Entries in recent carry the endpoint identity (src_user, src_tags, src_os and their dst_ counterparts) as well as the raw addresses, plus that connection's own verdict, reversed and rule, and — on underlay connections only — its path and derp_region.

result.ports is {port, transport, service, counts} per destination endpoint, from the overlay traffic types only — a physical entry's port is a WireGuard underlay port, or a DERP region ID on a relayed path, and neither is a service. A portless protocol contributes nothing either, having no port to report. Its counts therefore sum to less than result.totals.

Path quality is three more fields on result:

Field Contents
result.paths Connection counts per path: direct_ipv4, direct_ipv6, derp. Empty when the window held no underlay traffic.
result.derp_regions {label, counts} per DERP region ID, for the relayed share only.
result.peer_paths {peer, direct, relayed} per peer, ranked with the relayed ones first. direct folds both IP families.
$ # Which peers are being relayed, and how much of their traffic.
$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows.json?window=6h&recent=0' \
  | jq -r '.result.peer_paths[] | select(.relayed.flows > 0)
           | "\(.peer)  \(.relayed.flows)/\(.relayed.flows + .direct.flows) conns relayed"'

Policy reconciliation spans two places. result carries what the policy said about the window:

Field Contents
result.verdicts Connection counts per verdict: permitted, permitted_reverse, no_rule, undetermined. Empty when no policy was in force — which is not the same as everything being permitted.
result.unexplained {src, dst, transport, port, counts} relationships nothing explained, ranked by bytes.
result.rules {rule, counts} per rule index that permitted something. Complete and unranked, so subtracting it from policy.rules gives the rules that permitted nothing.

and policy carries what the policy is: available, an optional compile error, and rules as {index, kind, source} in document order. index is the key result.rules joins on.

$ # Relationships the policy does not explain, busiest first.
$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows.json?window=6h&recent=0' \
  | jq -r '.result.unexplained[] | "\(.src) -> \(.dst)  \(.transport)/\(.port // "-")  \(.counts.flows) conns"'

$ # Rules that permitted nothing in the window.
$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows.json?window=6h&recent=0' \
  | jq -r '[.result.rules[].rule] as $used | .policy.rules[] | select(.index | IN($used[]) | not) | .source'
$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows.json?window=15m&top=5&recent=0' | jq '.result.totals'

$ # Which tags talk to which, busiest first.
$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows.json?window=6h&recent=0' \
  | jq -r '.result.tag_matrix[] | "\(.src) -> \(.dst)  \(.counts.tx_bytes + .counts.rx_bytes)"'

Window, end, tailnet, the selected device, the matrix mode and cell, and all seven filters round-trip through the page's own URL (/flows?window=6h&device=camden), using the same parameter names as the JSON API above rather than a second vocabulary. Back and forward navigate the view; typing in a filter box replaces the current history entry instead of pushing one, so the back button still does what you expect after a search.

A link naming a device or matrix cell that no longer exists — or carrying any other stale or invalid value — falls back to the default view rather than erroring or showing an empty screen that reads as "no traffic". The admin token never appears in the URL: the page is not given one, so there is nothing for it to put there. URLs land in history, logs, referrers and pasted messages.

Exporting CSV and JSON

/api/flows/export.csv and /api/flows/export.json export the same filtered window the recent connections table shows, behind the same admin auth, taking the same window / end / tailnet and seven filter parameters as /api/flows.json.

Both state their own provenance, in the file. The rows come from the bounded in-memory recent ring — never persistent full history — and each export carries the window, the filters applied and the matched/returned/retained/truncated counts. CSV puts this in leading # comment lines before the header row; JSON puts it in an envelope object alongside rows. An export that looked like a complete record of a time range would eventually be treated as evidence, which is the failure this avoids.

A field that a spreadsheet would read as a formula — one starting =, +, -, @, a tab or a carriage return — is prefixed with a single quote in the CSV output. Device names, users, tags and ACL service names come from the tailnet's control plane, not from this code.

$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows/export.csv?window=6h&verdict=no_rule' -o unexplained.csv

$ curl -sH "Authorization: Bearer $TOKEN" \
    'http://127.0.0.1:9091/api/flows/export.json?window=6h&device=camden' | jq '.rows | length'

What it is not

It is not a replacement for dashboards, alerting, or retention. By default it holds hours, not weeks (persistence, opt-in, extends that to whatever flows.store.retention allows — still not a replacement for the points below); it cannot join across time ranges; and it has no alerting. For any of that, use the OTLP export and the dashboards and alert rules that ship with the project.