Skip to content

NDJSON Output

Offline mode writes one JSON object per line in NDJSON format.

Each line is a self-contained event with network, protocol, and PQC metadata.

For a field-by-field contract, see NDJSON Schema Reference.

Typical Field Groups

  • @timestamp
  • event.*
  • source.*
  • destination.*
  • network.*
  • observer.*
  • tcp.*
  • tls.*
  • ssh.*
  • ike.*
  • pqc.*

Example Record

{"@timestamp":"2026-05-11T18:40:13.141Z","event":{"id":"192.168.64.3-69.194.240.13_443-TCP","category":["network"],"type":["connection","protocol"]},"source":{"ip":"192.168.64.3","port":43864,"bytes":1915,"packets":4},"destination":{"ip":"69.194.240.13","port":443,"bytes":4380,"packets":4},"network":{"bytes":6295,"packets":8,"protocol":"https","transport":"tcp"},"observer":{"name":"sniffer-prod-01","product":"Automated Cryptography Discovery & Inventory (ACDI) Sniffer","type":"sniffer","vendor":"Tychon"},"tls":{"cipher":"TLS_AES_128_GCM_SHA256","established":true,"version_protocol":"TLSv1.3","client_offered_pqc_kex":"X25519MLKEM768"},"pqc":{"quantum_safety":"Vulnerable","readiness_score":13,"has_pqc_kex":false,"has_pqc_sig":false,"pqc_downgrade_detected":true,"app_protocol_basis":"alpn","vulnerability_reason":"TLS ciphersuite TLS_AES_128_GCM_SHA256 - PQC downgrade detected (client offered PQC but server selected classical)"},"tags":["customer-12345"]}

This record is a downgrade, which is the common case worth reading closely: the client offered X25519MLKEM768 and the server chose a classical key exchange, so the offer appears in tls.client_offered_pqc_kex while pqc.has_pqc_kex is false and the session is Vulnerable. See the PQC assessment fields for the distinction between what was offered and what was negotiated.

Emission Rules

  • TLS records are written when the session ends: a close, a reset, or the idle timeout. The handshake still has to be complete — both the ClientHello and the ServerHello — so a session that ends without one is not reported here.

    Changed in 2.1.0

    These records used to be written as soon as both Hellos had been seen, which was normally one packet into the session, and a record is written once and not revised. Everything counted after that point was therefore missing from it: a 309-packet session reported 7 packets and 405 of its 20,536 bytes. source.packets, destination.packets and the three byte counters now cover the whole session.

    What changes for you is timing, not content. A long-running TLS session now appears in the output when it finishes rather than when it starts, so a query over a window of live capture will not yet show sessions that are still open. Replaying a capture file is unaffected: every flow still open at the end of the file is written then.

    • Non-TLS records (HTTP, SSH, DTLS, QUIC, IKEv2, MQTT, WireGuard and the rest) are written only when the application protocol is known, and then at the first of:
    • the flow closing, resetting or going idle;
    • UDP only — the protocol's handshake being analysed: the DTLS or QUIC ServerHello, the IKEv2 IKE_SA_INIT response;
    • the flow having run for 60 seconds without either of those — a flow first seen after its handshake has none left to observe, and is written with what was seen.

    So a device that talks continuously is still reported while it is talking, and a flow that ends is reported with its final counts. Before 2.0.1 these records were written only at close or idle timeout, and a UDP flow that never went quiet for its idle timeout produced no record until the sniffer stopped.

    Changed in 2.1.0

    The handshake-analysed trigger is now UDP only. A TCP flow waits for its close, or for the 60-second mark if it does not close, for the same reason TLS records do: a record is written once, so writing it when the handshake was parsed froze the counters there. An eleven-packet HTTP conversation reported six packets, and a 307-packet one reported five packets and 168 of its 16,980 bytes.

    UDP is unchanged. It has no close, and its idle timeout moves forward on every packet, which is why the early trigger exists at all.

    A plaintext MQTT or SSH session that runs for hours now appears up to 60 seconds after it starts rather than just after its handshake. One that closes is reported at the close, as before. Replaying a capture file is unaffected.

    • Unknown plain traffic, including plain DNS/UDP that is not classified as a supported crypto protocol, may be skipped intentionally.

Output Destination Rules

If the configured path ends with /, the runtime uses connections.ndjson as the base output name.

Example:

offline_output:
  output_file: "/var/log/pqc-sniffer/"

Base output path:

/var/log/pqc-sniffer/connections.ndjson

When rotation is enabled, the writer uses indexed files derived from that base path:

/var/log/pqc-sniffer/connections.0.ndjson
/var/log/pqc-sniffer/connections.1.ndjson

If rotation is disabled, the base path is used directly. If a full file path is provided, that file path becomes the base path.

Performance Notes

  • Writes are buffered by default
  • flush_per_connection: true is safer but slower
  • rotation can be enabled to bound disk usage

Practical Reading Pattern

latest_output=$(ls -t /var/log/pqc-sniffer/connections*.ndjson | head -n 1)
tail -f "$latest_output"