The JSONL contract
One JSON object per line, terminated by \n. Same
records on every sink — file
(-o FILE), UNIX-domain socket, or TCP
(--data-socket SPEC). Read-only by design:
sloth never reads from a connected client. Access control is
the operator's job (bind address, UNIX perms, Tailscale ACLs).
Each line is identical regardless of sink. -o FILE
and --data-socket SPEC can both run at once and
broadcast every record to every active sink.
| Transport | Configured by | Use |
|---|---|---|
| File (append) | -o /var/log/sloth.jsonl |
log forwarder pulls / tail -f pipelines |
| UNIX-domain | --data-socket unix:/var/run/sloth.sock |
local consumer on the same host |
| TCP | --data-socket tcp:HOST:PORT |
remote consumer over a trusted transport (Tailscale, private VPN, localhost only) |
Backpressure. The socket writer is non-blocking. A slow client that fills its kernel send buffer loses lines for the duration of the stall (it does not get queued). A broken pipe closes the client fd; reconnect to resume.
Every line is a JSON object with at least type and
ts (Unix seconds). Fields are append-only;
consumers ignore unknown type values and unknown
keys gracefully.
{"type":"dns","ts":1700000000,
"src":"192.168.1.5",
"qname":"example.com",
"qtype":"A",
"answer":"93.184.216.34",
"is_resp":1}
{"type":"tls","ts":1700000001,
"src":"10.0.0.5","dst":"93.184.216.34",
"host":"example.com",
"ver":"TLS 1.3",
"ja3":"deadbeefcafef00d…"}
{"type":"quic","ts":1700000002,
"src":"10.0.0.5","dst":"1.1.1.1",
"host":"cloudflare.com",
"ver":"v1"}
{"type":"http","ts":1700000003,
"src":"10.0.0.5",
"host":"example.com",
"method":"GET",
"path":"/index.html"}
{"type":"ntp","ts":1700000004,
"src":"10.0.0.1","dst":"192.168.1.5",
"mode":"server",
"version":4,"stratum":1,"ref":"GPS"}
{"type":"icmp","ts":1700000005,
"src":"192.168.1.5","dst":"8.8.8.8",
"desc":"Echo Req",
"ty":8,"code":0,"seq":42,"v6":0}
{"type":"alert","ts":1700000006,
"title":"THREAT_DOMAIN",
"detail":"192.168.1.5 queried …",
"key":"threat-d:malware.testing.com",
"sev":2,"ty":3,"count":1}
sev is the severity tier: 0 = LOW (yellow), 1 = WARN (orange), 2 = CRIT (red). The enum is stable. ts on alerts is the last_seen time of the dedup key, not the first observation.
examples/consumer/sloth-stream.py is the textbook
consumer loop — connect, stream, parse, filter, reconnect.
Stdlib only. Read it as a template for porting to Go, Node, or
Swift's Network.framework; the structure ports
directly.
python3 examples/consumer/sloth-stream.py \
unix:/tmp/sloth.sock
python3 examples/consumer/sloth-stream.py \
tcp:100.64.0.5:8765 --type alert
python3 examples/consumer/sloth-stream.py \
unix:/tmp/sloth.sock --raw | jq .
python3 examples/consumer/sloth-stream.py \
unix:/tmp/sloth.sock --count
examples/forwarder/sloth-forward.py reads from the
data socket, batches, and pushes to a downstream SIEM. Three
sinks ship; the sink interface is two members
(.name, .send(batch)) so adding Loki,
Datadog, or your in-house collector is ~30 lines.
python3 examples/forwarder/sloth-forward.py \
unix:/tmp/sloth.sock \
--sink hec \
--hec-url https://splunk.example.com:8088/services/collector \
--hec-token-env SLOTH_HEC_TOKEN
python3 examples/forwarder/sloth-forward.py \
unix:/tmp/sloth.sock \
--sink elastic \
--es-url https://elastic.example.com:9200 \
--es-index 'sloth-events-%Y.%m.%d' \
--es-api-key-env SLOTH_ES_API_KEY
python3 examples/forwarder/sloth-forward.py \
unix:/tmp/sloth.sock \
--sink syslog \
--syslog-host siem.example.com \
--syslog-port 514
python3 examples/forwarder/sloth-forward.py \
unix:/tmp/sloth.sock \
--type alert \
--batch-size 500 --batch-ms 500 \
--sink hec --hec-url … --hec-token-env …
The data socket is non-durable, and the forwarder doesn't
pretend otherwise. When the downstream sink fails after
--max-retries, the batch is dropped and
a count is logged to stderr.
If your deployment needs durability, also pass
-o /var/log/sloth.jsonl to sloth and ship the
file with a log-shipping agent (filebeat, vector, fluent-bit)
on a separate path. That pipeline gives you durability;
this one gives you low-latency triage.
The stats line (every --stats-interval seconds,
default 30s) is the operator's ground truth:
received ≥ forwarded + dropped. A non-zero
dropped over a long window means the sink can't
keep up.