Journal Tailing and Observability
r7 writes request/response traffic straight to memory-mapped binary files (journals) on disk instead of serializing logs to text or making network calls on the request thread — that's what keeps the hot path fast. To get that data into Grafana, ELK, ClickHouse or a WARC archive, you run a Tailer: a separate process/sidecar that reads the journals asynchronously.
graph LR
Client -->|HTTP| R7[r7 gateway Container]
R7 -->|Binary Write| Vol[(Shared Volume: /journals)]
Vol -->|Binary Read| Tailer[r7 Tailer Container]
Tailer -->|JSON / Batch Insert| Ops[Observability Stack]
Quick start
The common setup: the gateway journals headers and bodies, the tailer writes the metadata and headers as JSON lines to its standard output, and the bodies go to WARC files. The tailer needs no configuration for this; its defaults are exactly that.
1. Journal the route in full. In routes.yaml:
routes:
- id: my-route
match:
- PathPrefix:
prefix: /api
upstream:
targets:
- url: http://backend:8080
journal:
request:
level: FULL
response:
level: FULL
2. Run the gateway and the tailer on one volume. The gateway writes the journals, the tailer
reads them. Add the two services to the compose file that runs your upstream (backend in the
route above):
services:
r7-api:
image: ghcr.io/ethlo/r7-gateway:main
ports:
- "8888:8888"
environment:
# Required: the key behind the fingerprints in place of redacted values.
R7_FINGERPRINT_KEY: ${R7_FINGERPRINT_KEY:?set it in .env}
volumes:
- ./config:/app/config:ro
- r7-journals:/journals:rw
r7-tailer:
image: ghcr.io/ethlo/r7-tailer:main
volumes:
- r7-journals:/journals:ro
- r7-warc:/warc:rw
- r7-tailer-checkpoints:/checkpoints:rw
volumes:
r7-journals:
r7-warc:
r7-tailer-checkpoints:
Next to it, create the gateway's fingerprint key
once, and keep the file. Only the gateway needs it. If config/server.yaml sets
fingerprint_key instead, drop the environment block.
3. Read the result. Each exchange is one JSON line on the tailer's standard output, which
Promtail, Fluent Bit, Vector or your Docker logging driver picks up. The line holds the timing,
status, sizes and the headers of every leg (redacted
values appear as fingerprints), and, for a complete exchange with a body, a warc pointer to its records in /warc. The line is one physical line; this one is abbreviated and
pretty-printed for reading. Every field is listed under The JSON line:
{
"request_id": "01JA0Q6R8X4V2N7M3K5T9B1C0D",
"route_id": "items",
"start": "2026-10-01T12:00:00.000120Z",
"duration": 0.004290,
"client_request": {"method": "POST", "path": "/items", "headers": {"host": ["api.example.com"]}, "body_bytes": 212},
"client_response": {"status": 201, "headers": {"content-type": ["application/json"]}, "body_bytes": 1274},
"warc": {"file": "r7-1759320000000-6f1c….warc.zst", "offset": 48211, "length": 1873}
}
The /warc files are standard WARC; see the tailer. Journals grow until
something deletes them: before sustained use, add the reaper from Retention. Everything else on this page is reference for tuning this
setup: the journal levels, redaction, every tailer property, other output layouts and retention.
1. Journal levels and redaction
Tailers only see what the gateway actually writes. Set the levels per route, group or the whole
file, and raise or lower them with rules, in routes.yaml:
routes:
- id: my-route
match:
- PathPrefix:
prefix: /api
upstream:
targets:
- url: http://backend:8080
journal:
request:
level: METADATA
response:
level: METADATA
on_response:
- id: failures
match:
- Status:
include: [5xx]
response: { level: HEADERS } # more detail when things break
What each level records, and how rules resolve the levels, is in
Configuration §7. Each rule that fires is recorded in the
gateway.journal.rule attribute, by its id or its path, and a rule that could not be evaluated
in gateway.journal.rule_error, and counted in the management view's rule_errors whether or
not the exchange was journaled; where and how journals are written on disk is
the storage block. Requests refused before any route
matches (no route, ambiguous path, TRACE and similar) are journaled only when configured, see
Unrouted Requests.
Capping bodies
At FULL the journal keeps every body whole unless told otherwise. Request bodies are bounded
by limits.max_entity_size; response bodies by nothing, so a route that serves a 2 GB download
writes 2 GB to the journal, and a tailer holds every fragment of an exchange in its heap until
the exchange ends. truncate_body_at on a direction keeps the body's first bytes instead; the
traffic itself streams whole:
journal:
request: { level: METADATA, truncate_body_at: 64KB }
response: { level: METADATA, truncate_body_at: 64KB }
on_response:
- id: failures
match:
- Status:
include: [5xx]
response: { level: FULL } # the first 64KB of each failure's body
A capped body is marked in the record: the end record carries a truncation flag per direction,
the body's checksum covers the prefix that was kept, the JSON line adds body_truncated: true to
the leg, and the WARC record says WARC-Truncated: length with the digest of the prefix. A body
exactly truncate_body_at long is kept whole and not flagged. How the cap is inherited and
which rules may set it is in Configuration §7.
Route ids and upstream target URLs are recorded in every exchange's journal attributes
(gateway.route.id, gateway.target; the JSON line has them as route_id and
upstream_request.targets), and the journal stores ISO-8859-1 text only. A route declared in
a group is also recorded with the group's id as
gateway.route.group (in the JSON line's attributes). A route or group
id or target URL with a character outside ISO-8859-1 is refused at startup, naming the field.
Redacted header and query parameter values
Headers. At HEADERS and FULL, every header name is journaled, but a value is written as
sent only when its name is on the safe list for its direction (request or response). The
built-in lists hold names such as host, user-agent, content-type, etag,
x-forwarded-for and sec-fetch-mode; everything else, such as authorization, cookie,
set-cookie or a custom API key header, is written as a fingerprint.
Query parameters. The query is part of the request line, which every level records,
METADATA included. Each parameter name is journaled as sent, and its value is written as sent
only when the name is on safe_query_parameters. There is no built-in list, because no
parameter name is safe everywhere, so with nothing configured every value is fingerprinted:
GET /search?page=2&api_key=s3cret&q=shoes HTTP/1.1 # as sent
GET /search?page=2&api_key=fp:R5XU2m_VAik&q=fp:0uCr61zmqQQ HTTP/1.1 # journaled, with page safe
- Case. Names on
safe_query_parametersmatch regardless of case, as header names do. Query parameter names are case sensitive on the wire, so when your upstreams tellidfromID, setsafe_query_parameters_case_sensitive: true. - Encoding. Names and values are compared after percent-decoding, as the upstream reads
them:
p%61geandpageare the same parameter, andq=a+bandq=a%20bget the same fingerprint. - Repeats. Each occurrence of a repeated parameter is redacted on its own; a safe name is safe every time it appears.
- Bare parameters. A parameter without
=(?s3cret-token) is fingerprinted whole unless its name is safe. Empty values (?q=) stay empty.
Both request lines are redacted the same way: the one the client sent and the one forwarded
upstream, after filters such as SetQueryParameter have changed it.
The safe lists are set in storage.journal_security; see
Journal Redaction for the keys.
Redaction applies to the journal only; it never changes what a client or upstream receives.
Fingerprints. A fingerprint is fp: followed by 11 base64url characters, for example
fp:R5XU2m_VAik: the first 64 bits of HMAC-SHA-256 of the value, keyed with
storage.journal_security.fingerprint_key.
The same value always gives the same fingerprint under the same key, so you can see that two
requests, two replicas, or records from before and after a restart carried the same value. Without
the key, nobody who reads the journal can check a guess, which matters for low-entropy secrets
such as Authorization: Basic with a known user name and a common password, a short API key or a
role=admin cookie. The characters are URL-safe, so a fingerprint stands in a query string, a
JSON string or a WARC header without escaping.
The key. r7 refuses to start without it. Set it in server.yaml, or set only the
R7_FINGERPRINT_KEY environment variable, which is the default when the file does not:
storage:
journal_security:
fingerprint_key: ${R7_FINGERPRINT_KEY} # at least 32 characters, e.g. `openssl rand -base64 32`
Keep the key out of anything a journal reader can see: anyone who has it can confirm a guess. Give every replica of a deployment the same key so their fingerprints correlate. To rotate it, change it: records already written keep the fingerprints they have, and values journaled before and after the change no longer correlate. Nothing reads a fingerprint back, so there is no old key to keep.
Journals written by earlier versions keep the forms they were written with, id:sha256: (an
unkeyed, truncated SHA-256) and id:hmac:; nothing rewrites them.
2. Mount the shared volume
Gateway and tailer share the journal_dir volume: the gateway writes, the tailer only reads.
Mount journal_dir read-only on the tailer
The tailer never deletes a segment; it only reads. Deciding when a fully read segment can
be removed is the reaper's job (ghcr.io/ethlo/r7-reaper, see
§4 below), and the reaper is the only container that needs write
access to journal_dir. The tailer keeps its progress in its own checkpoint_dir, which
the reaper reads to learn which segments the tailer is done with.
3. The tailer
Image: ghcr.io/ethlo/r7-tailer:main
The tailer reads the journals once and writes each exchange to one or both of two outputs:
- WARC files: WARC 1.1
records, the standard web-archiving format used by the Internet Archive and national
libraries, in rotating
.warc.zstfiles (one independent Zstandard frame per record, sozstd -dalone decompresses a file back to plain WARC). This is the archive: durable, replayable and readable by any WARC tool, such as pywb, for compliance archiving, incident forensics or replaying traffic against a new backend. - JSON lines: one JSON object per exchange, to standard output for Promtail (Grafana Loki), Fluent Bit, Vector or a Docker logging driver, or to rotating files. This is the log.
With both on, a body is stored once, in the WARC file: the JSON line carries a warc pointer to
the exchange's records instead of the bodies. A body the WARC output does not store (one of an
incomplete exchange, or any body with warc.bodies: false) goes into the JSON line when json.bodies is true. With both
bodies settings false, no body is stored anywhere; the records and lines still say there was
one, and which, by its size and checksums.
If writing the JSON line fails, the exchange's WARC records are taken back before the tailer retries it, so a retry never archives an exchange twice.
Configuration: like the gateway, the tailer reads a YAML file: tailer.yaml in the working
directory by default (/app/config in the image), or the path in the TAILER_CONFIG
environment variable. It supports the same ${VAR:default} interpolation as
routes.yaml/server.yaml. Without a file, every field takes its default, so a bare container
tails /journals to standard output as JSON lines without bodies, and archives the exchanges
that have a captured body in WARC files under /warc.
| Field | Default | Meaning |
|---|---|---|
journal_dir |
/journals |
Directory the tailer reads binary journals from |
checkpoint_dir |
/checkpoints |
Directory .r7_checkpoints is read from and written to; a volume of its own, outside journal_dir (mounted :ro) and the outputs, since the reaper reads it and has no business in the archive |
poll_interval |
1s |
The longest the tailer waits between reads. It wakes as soon as the gateway commits an entry (the gateway bumps a counter in shard-<id>.ctl beside the segments), so on the same host an exchange is read within microseconds to about a millisecond; this interval only matters where that file is not shared, such as across hosts. Supports ms, s, m, h, d |
warc |
The WARC output; see below | |
json |
The JSON output; see below |
At least one output must be enabled.
warc
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Write WARC files |
exchanges |
with_body |
Which exchanges get records. with_body archives only exchanges with a captured request or response body; all archives every exchange, so every JSON line points into the WARC files. Whether the records hold the bodies is bodies |
output_dir |
/warc |
Directory the files are written to. How files are named, sealed and recovered after a crash is in the WARC profile |
file_prefix |
r7 |
Filename prefix |
max_file_size |
1gb |
Roll to a new file once the current one reaches this size (at least 64kb). Supports b, kb, mb, gb |
max_file_age |
15m |
Roll to a new file once the current one is this old, even with little traffic. Supports ms, s, m, h, d |
zstd_level |
9 |
Zstandard compression level (1-22), applied per record |
dedup_cache_entries |
100000 |
How many payload digests are remembered for deduplicating identical payloads across exchanges |
cdxj_index |
false |
Also write a sorted CDXJ index next to each file; see below |
bodies |
true |
Store captured bodies in the records. false writes the headers with WARC-Truncated: unspecified and the body's WARC-Payload-Digest, and leaves the body to the JSON line when that output is on with bodies: true; otherwise the body is not stored |
The WARC profile specifies what an exchange becomes in the files: the records, the
WARC-R7-* fields and the rules a reader can rely on.
design/warc.md explains why they are
shaped this way.
CDXJ index. With cdxj_index: true, every sealed r7-….warc.zst gets an r7-….cdxj beside it: one line per exchange, sorted by SURT key and timestamp, holding the URL, status, mime type, payload digest and the file name, offset and length of the client response record. This is the index format pywb and OutbackCDX read, so a lookup by URL and time goes straight to the record without scanning the archive. The index is built from the finished WARC file and renamed into place before the WARC file is, so a sealed .warc.zst always has its index; one sealed after a crash is indexed on the next start. Requests other than GET carry __wb_method=<method> in their key, as pywb does; request bodies are never copied into the index.
json
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Write JSON lines |
output |
stdout |
stdout, or file for rotating files. Each file is written as <file_prefix>-<millis>-<uuid>.jsonl.open and renamed to .jsonl once finished, so a shipper that picks up *.jsonl never reads one still being written. A file left .open by a crash is sealed on the next start, minus a torn last line, whose record is written again |
output_dir |
/json |
With output: file: directory the files are written to |
file_prefix |
r7 |
With output: file: filename prefix |
max_file_size |
256mb |
With output: file: roll to a new file once the current one reaches this size (at least 64kb). Supports b, kb, mb, gb |
max_file_age |
15m |
With output: file: roll to a new file once the current one is this old, even with little traffic. Supports ms, s, m, h, d |
bodies |
false |
Write captured bodies into a line when no WARC record stores them, base64 encoded. Off, payloads stay out of the log; body_bytes and the checksums still show there was a body |
The JSON line
The output is JSON Lines: one compact JSON object per exchange, one
object per line, UTF-8. A proxied GET with headers journaled, archived in a WARC file under
exchanges: all, looks like this (pretty-printed here):
{
"request_id": "01JA0Q6R8X4V2N7M3K5T9B1C0D",
"route_id": "items",
"start": "2026-10-01T12:00:00.000120Z",
"end": "2026-10-01T12:00:00.004410Z",
"duration": 0.004290,
"remote_address": "10.0.0.7",
"remote_address_source": "SOCKET",
"client_request": {
"level": "HEADERS", "method": "GET", "path": "/items", "query": "page=fp:b9ZtS0R2qzY", "protocol": "HTTP/1.1",
"headers": {"host": ["api.example.com"]}, "header_bytes": 142, "body_bytes": 0
},
"upstream_request": {
"level": "HEADERS", "method": "GET", "path": "/v1/items", "query": "page=fp:b9ZtS0R2qzY", "protocol": "HTTP/1.1",
"targets": ["http://backend:8080"], "headers": {"host": ["backend:8080"]}
},
"upstream_response": {
"level": "HEADERS", "protocol": "HTTP/1.1", "status": 200, "reason": "OK",
"headers": {"content-type": ["application/json"]},
"start": "2026-10-01T12:00:00.000300Z", "first_byte": "2026-10-01T12:00:00.004100Z",
"end": "2026-10-01T12:00:00.004380Z", "duration": 0.004080
},
"client_response": {
"level": "HEADERS", "protocol": "HTTP/1.1", "status": 200, "reason": "OK",
"headers": {"content-type": ["application/json"]}, "header_bytes": 98, "body_bytes": 1274
},
"warc": {"file": "r7-1759320000000-6f1c….warc.zst", "offset": 48211, "length": 1873}
}
The rules every field follows:
- A field with no value is left out, never written as
null. Without an end event (incompleteisTIMED_OUT,CAPACITY_EVICTEDorSHUTDOWN), timing, status and sizes are unknown, and are left out rather than written as zero. - Timestamps are RFC 3339 in UTC with microseconds. Durations are seconds, as a number with six decimals.
- Header and attribute values are always arrays of strings, in the order they were sent or set, so a header sent once and one sent twice have the same type. Values outside the safe lists are fingerprints.
- Enumerated values are upper case.
Fields of the exchange:
| Field | Type | Present |
|---|---|---|
request_id |
string | Always |
incomplete |
string | Only when the record is not a complete exchange: NO_START_EVENT, NO_STATUS, TIMED_OUT, CAPACITY_EVICTED or SHUTDOWN |
route_id |
string | With an end event, when the exchange matched a route; unrouted requests that are journaled have <unrouted> |
start |
timestamp | With an end event: when the gateway received the request |
end |
timestamp | With an end event: when the response to the client finished |
duration |
number | With an end event: end minus start, in seconds |
remote_address |
string | When known: the client address |
remote_address_source |
string | With remote_address: SOCKET, X_FORWARDED_FOR, X_REAL_IP or UNKNOWN |
identity |
object | When an auth filter verified the caller: who they are, in plain text; see The caller's identity |
identity.scheme |
string | basic for BasicAuth, jwt for JwtAuth, or the scheme a plugin sets |
identity.issuer |
string | When the scheme has one: a token's iss. In Keycloak, the realm is its last path segment |
identity.subject |
string | The BasicAuth username, or a token's sub |
identity.username |
string | When known: the BasicAuth username, or the token's username_claim |
client_request |
object | Always: the request from the client |
upstream_request |
object | When the request was proxied: the request head r7 built for the last target tried, with the target's path, Host, the X-Forwarded-* headers and framing r7 set. When no connection to that target could be made, it is the head r7 would have sent, and upstream_response has no status line |
upstream_response |
object | When the request was proxied: the upstream's response, and the round trip's timing. When no upstream sent a response head (a refused connect, a timeout or a protocol error before one), it holds only the timing, at level NONE: r7's own 502, 503 or 504 is recorded in client_response, never as the upstream's |
client_response |
object | Always: the response to the client |
attributes |
object of string arrays | With an end event, when the gateway recorded attributes other than the route and targets, such as gateway.fallback.id, gateway.unrouted.reason, gateway.shortcircuit.name and gateway.journal.rule |
warc |
object | When a WARC file holds the exchange |
warc.file |
string | The sealed WARC file's name |
warc.offset |
number | Byte offset of the exchange's first record in that file |
warc.length |
number | Compressed length of all the exchange's records: read length bytes at offset and decompress them with zstd -d |
Fields of the four leg objects (client_request, upstream_request, upstream_response, client_response):
| Field | Type | Legs | Present |
|---|---|---|---|
level |
string | all | Always: the journal level of that leg, NONE, METADATA, HEADERS or FULL |
method |
string | requests | When the request line was journaled |
path |
string | requests | When the request line was journaled; a malformed request line is kept whole here |
query |
string | requests | When the request line has a query string |
protocol |
string | all | When the start line was journaled |
status |
number | responses | client_response: with an end event, at every level. upstream_response: when the status line was journaled |
reason |
string | responses | When the status has a registered reason phrase. r7 writes the phrase registered for the code, not the one the upstream sent, and none for an unregistered code |
targets |
array of strings | upstream_request |
With an end event: the upstream URLs tried, in order. When the last one answered, its response is the one in upstream_response; when none did, upstream_response has no status line and client_response holds r7's own 502, 503 or 504 |
headers |
object of string arrays | all | At HEADERS and FULL, when there are any |
header_bytes |
number | client legs | With an end event: bytes of the head on the wire |
body_bytes |
number | client legs | With an end event: bytes of the body on the wire, whether or not it was capped |
body_truncated |
boolean | client legs | Only when the journal kept just the body's first truncate_body_at bytes: body and checksum cover that prefix |
body |
string | client legs | With bodies: true, at FULL, when there is a body and no WARC record stores it: base64 |
checksum |
string | client legs | At FULL, when there is a body: the CRC32C the gateway recorded over the bytes it journaled, as crc32c: and eight hex digits |
observed_checksum |
string | client legs | Only when the body read back does not match checksum |
start |
timestamp | upstream_response |
With an end event: when the request to the upstream started |
first_byte |
timestamp | upstream_response |
With an end event, when the upstream answered: when its response head had been read |
end |
timestamp | upstream_response |
With an end event, when the upstream's response was read to its end; not for a failed relay or a WebSocket tunnel |
duration |
number | upstream_response |
With end: end minus start, in seconds |
A consumer should ignore fields it does not know.
The caller's identity
When an auth filter verified the caller, the line carries who they are, in plain text:
"identity": {"scheme": "jwt", "issuer": "https://auth.example.com/realms/acme",
"subject": "6f1c2b8e-4d3a-4f0e-9a51-2c7d8e9f0a1b", "username": "alice"}
The journal is an audit record, and who made a request is the question it exists to answer, so the
identity is recorded verbatim, like the client address, in any script. The credentials themselves stay
fingerprinted: the Authorization header's value is never written in plain text. If two filters
on a route set the identity, the last one wins.
Other layouts
Each of these is a tailer.yaml and replaces the defaults only where it says.
Every exchange in the WARC archive, with a CDXJ index, so every complete exchange's JSON line has a warc pointer:
JSON to rotating files for a loader instead of standard output:
JSON lines alone, bodies included:
4. Retention: The Reaper
Image: ghcr.io/ethlo/r7-reaper:main
The tailer is read-only by design (see §2's warning) — something else has to delete a
sealed segment once it is no longer needed, or journal_dir grows without bound. That something
is the reaper: a small standalone process whose only job is deleting old segments from a shared
journal volume.
ttl deletes whether or not a segment was read
With tailers listed, a segment is deleted early once every one of them is done with it
(see the table below). Whatever the tailers say, a sealed (.r7f) segment is deleted once
it has existed for longer than ttl, read or not: that is the bound on disk use. Set ttl comfortably
longer than the slowest tailer's realistic lag — including time to recover from a restart —
or a slow or temporarily-down tailer will lose data it never got a chance to read. A
segment the gateway's recovery quarantined (*.corrupt, see
design/journal-invariants.md) is reaped under the same ttl, since nothing will ever read
it. A segment a tailer cannot read keeps its name: the tailer records that in its own
checkpoint file and logs it, because the journal directory is read-only to it, and the
file is reaped like any other sealed segment. Active (.flux) segments are never reaped, at any age — they belong exclusively to the
gateway process that may still be writing to them.
Age is read from the timestamp embedded in the segment's own filename, never from the
file's filesystem last-modified time: mtime is reset by a backup restore, an rsync run
without -a, or a volume migration, any of which would otherwise silently defeat ttl. A
segment whose name the reaper cannot parse is left alone rather than guessed at from
mtime — it is simply never a deletion candidate.
Configuration: same YAML config mechanism as the tailers — reaper.yaml by default,
overridable via the REAPER_CONFIG env var.
| Field | Default | Meaning |
|---|---|---|
journal_dir |
/journals |
Directory the reaper scans (and deletes from) — mount this read-write, unlike every tailer |
ttl |
7d |
How long a sealed segment is kept, counted from the last-event timestamp embedded in its filename (see the warning above), before it is deleted, read or not. Supports ms, s, m, h, d |
tailers |
none | Checkpoint directories of the tailers that must all be done with a segment before it is deleted ahead of ttl (mount them read-only). A segment is done for a tailer when its checkpoint file says it was read to the end and everything in it delivered, and the tailer does not need it to rebuild an exchange still open. A listed tailer with no readable checkpoint file stops early deletion, so a tailer that is down keeps everything for up to ttl |
min_age |
1h |
How old a segment must be before it is deleted ahead of ttl. A tailer counts a segment as done once its output reached the OS, not the disk; keep this above the tailers' max_file_age (15 minutes by default), after which their output files are fsync'd, so a power loss cannot take both copies |
poll_interval |
1m |
Delay between sweeps. Supports ms, s, m, h, d |
Example config/reaper.yaml:
journal_dir: /journals
ttl: 7d
# Delete as soon as the tailer is done with a segment (and it is an hour old), not after 7 days
tailers:
- /checkpoints/tailer
Who may write what. Each component writes only what it owns and reads the rest, and the mounts enforce it:
r7-journals |
a tailer's checkpoints | a tailer's output | |
|---|---|---|---|
| gateway | writes (segments, .seq, .ctl) |
— | — |
| tailer | reads | writes its own | writes its own |
| reaper | deletes sealed and quarantined segments | reads | — |
So the reaper can delete what the gateway wrote, a tailer cannot, and the reaper cannot touch
anything a tailer wrote. The one thing that crosses a boundary, the reaper trusting a tailer's
checkpoint file, can only keep a segment longer: anything short of "done" leaves it for ttl.
Example Docker Compose Integration:
services:
r7-api:
image: ghcr.io/ethlo/r7-gateway:main
volumes:
- r7-journals:/journals:rw
r7-tailer:
image: ghcr.io/ethlo/r7-tailer:main
volumes:
- ./config/tailer.yaml:/app/config/tailer.yaml:ro
- r7-journals:/journals:ro # reads only; retention is the reaper's job
- r7-warc:/warc:rw
- r7-tailer-checkpoints:/checkpoints:rw # its own progress
r7-reaper:
image: ghcr.io/ethlo/r7-reaper:main
volumes:
- ./config/reaper.yaml:/app/config/reaper.yaml:ro
- r7-journals:/journals:rw # the only container that deletes from it
- r7-tailer-checkpoints:/checkpoints/tailer:ro # read to learn what the tailer is done with; never its output
volumes:
r7-journals:
r7-warc:
r7-tailer-checkpoints:
5. Where the data goes next
The JSON lines go to any log shipper and on to ClickHouse, Loki or an OpenTelemetry backend; the WARC files go to any WARC reader. Where r7 fits shows how, with a minimal configuration for each.