-
Notifications
You must be signed in to change notification settings - Fork 0
Watermarks and Restarts
Watermarks remember how far this forwarder process has read in each source log file. They let you restart without re-sending lines that were already published successfully.
Default file: .log-forwarder/watermarks.json (override with watch.state.path).
{
"files": {
"/var/log/billing/application.log": {
"offset": 1048576,
"inode": 883241
}
}
}| Field | Meaning |
|---|---|
Key (/var/log/...) |
Absolute or resolved path of the source log file being tailed |
offset |
Bytes read from the start of that file (including newlines) |
inode |
OS file identity — used to detect rotation |
Not stored: sink type, Kafka topic, HTTP URL, or any destination detail. Watermarks belong to the process + source file, not to the sink.
| Event | What happens |
|---|---|
| First time tailing a file | No entry → start at beginning of file |
| Record published successfully | Watermark updated to end of that event (last line for multiline) |
| Process restart, same file & inode | Resume from saved offset — log: resuming file from watermark
|
| Log rotation (same path, new inode) | Ignore old offset → tail new file from beginning |
| Process crash mid-line | Last fully published record’s offset is saved; may re-send partial event on restart depending on timing |
Each forwarder process should have its own watch.state.path. If two processes share a watermark file, they will fight over offsets.
| Setup | Watermark files |
|---|---|
| One forwarder → Kafka | One file, e.g. /var/lib/log-forwarder/watermarks.json
|
| Two forwarders, same logs, different sinks |
Two files, e.g. watermarks-kafka.json and watermarks-file.json
|
Scenario: You ran with Kafka, then edit config to use sink.type: file, and restart.
| What happens | Detail |
|---|---|
| Watermark | Unchanged — still at previous offset |
| Tailing | Resumes from that offset |
| Old lines | Not re-sent to the file sink |
| New lines | Go to the file sink |
To backfill the file sink with historical logs, you must reset the watermark (below).
Edit the watermark JSON and remove that file’s entry from files, then restart the forwarder.
Delete the entire watermark file (or move it aside), then restart. Every watched file is tailed from offset 0 and lines are published again to the current sink.
Warning: Re-publishing can duplicate data in Kafka or your ingest pipeline. Coordinate with downstream consumers.
If the watermark file exists but contains invalid JSON (disk error, partial write, manual edit mistake), the forwarder refuses to start and logs an actionable error with the file path.
Recovery options:
-
Archive and start fresh — restart with
--reset-watermarksor setwatch.state.reset_on_corrupt: true. The corrupt file is renamed to{path}.corrupt.{timestamp}and a new empty store is created. A warning log records the backup path. - Manual — rename or remove the corrupt file yourself, then restart (same effect as a missing watermark file: tail from the beginning).
After recovery, tailing resumes from offset 0 for all watched files — expect at-least-once re-delivery of already-shipped lines. Inspect the .corrupt.* backup if you need to salvage offsets.
- Use an absolute path for
watch.state.path - Keep the watermark file outside watched log directories (config validation enforces this)
- Back up or document watermark location before major config changes
- After
logrotate, a new inode means the forwarder reads the new file from the start — expected behavior
- Choosing a Sink — one sink per process; separate watermarks when running multiple processes
- Troubleshooting — “no new records after restart”
Watermarks track how far this forwarder process has read in each tailed source file. They belong to the process (via watch.state.path), not to a particular sink — the watermark file stores only file path, byte offset, and inode. There is no sink type or destination in the watermark.
That means a restart does not re-publish lines that were already read and successfully published, even if you change sink.type or sink settings before restarting (for example switching from Kafka to file). The forwarder resumes tailing from the saved offset; only new lines after that point go to the new sink. Previously shipped lines are not automatically re-sent to the new destination.
Default location: .log-forwarder/watermarks.json in the forwarder's current working directory (the directory you start the process from). Relative paths in config are resolved the same way.
If you run without -config, the default is still .log-forwarder/watermarks.json. The directory is created automatically on first write.
Change the location with watch.state.path:
watch:
poll: 1s
state:
path: /var/lib/log-forwarder/watermarks.json
flush_interval: 1s
flush_every: 0
sources:
- path: ./logs/app
patterns:
- "*.log"Persistence: Watermark updates are kept in memory on every processed line and written to disk on a schedule (flush_interval, default 1s) or after flush_every updates when set. A final flush runs on graceful shutdown (SIGINT / SIGTERM). Set flush_interval: 0 to persist after every line (previous behavior, higher disk I/O). On crash or kill -9, the on-disk watermark may lag by up to one flush window; already-published lines may be sent again after restart (at-least-once).
Kafka duplicate window: With sink.type: kafka, the forwarder may ack records to the broker before the watermark file catches up. The maximum duplicate replay after crash is roughly one flush_interval window (plus any lines processed since the last flush). Lower flush_interval or set it to 0 to shrink that window. This does not enable exactly-once — see Choosing a Sink#Kafka delivery semantics. Re-read lines after restart are counted in log_forwarder_lines_replayed (see Monitoring#5-metrics-reference).
Use an absolute path in production so the file location does not depend on where the service is started from. The forwarder logs the resolved path at startup (state_path in the log forwarder started message).
File format — JSON mapping each tailed file path to a byte offset and inode:
{
"files": {
"/var/log/billing/application.log": {
"offset": 1048576,
"inode": 883241
}
}
}| Field | Meaning |
|---|---|
offset |
Number of bytes read from the start of the file (including newline characters) |
inode |
OS inode of the file when the offset was recorded |
How it works:
- First run — no watermark file exists (or no entry for a file). The forwarder tails from the beginning of the file.
-
After each committed parser event — the in-memory watermark for that source file is updated to the event's byte offset (for multiline records, the end of the last physical line in that event). Filtered and transform-skipped lines advance the watermark too; only publish failures stall it. With
parser.type: multiline, buffered continuation lines do not commit until the next header line,parser.flush_intervalidle flush (default100ms), or graceful shutdown — see Watermarks-and-Restarts#Multiline-parser-and-watermarks. The watermark file on disk is updated on the flush schedule (see above). -
Restart — if the file's inode matches the stored value, tailing resumes from
offset. The log lineresuming file from watermarkindicates this. -
Log rotation — if the path is reused but the inode changed (typical after
logrotatewithcreateorrename), the stored offset is ignored and the forwarder tails the new file from the beginning. The log linetailing file from beginningindicates this.
copytruncate rotation is not supported. Some logrotate configs truncate the file in place (copytruncate) while keeping the same inode. The forwarder detects rotation by inode change only; it does not reset tail position when a file shrinks. With copytruncate, the forwarder may continue from a stale byte offset and miss or mis-read new content. Use create / rename rotation instead (rename the old file, create a new file at the same path). Integration test E2E-4 covers rename rotation only; see docs/integration-test-cases.txt.
Operational notes:
-
Re-read from the beginning of a file — remove that file's entry from the
filesobject in the watermark JSON (or delete the entire watermark file to reset all watched files). On the next start the forwarder tails from offset0and publishes those lines again to the current sink. - Change sink and restart — the existing watermark is respected; tailing continues where it left off. To backfill the new sink with older log content, you must clear the relevant watermark entry(ies) as above.
-
Different sinks for the same log files — run separate forwarder processes, each with its own config and
watch.state.path. One process, one sink; one watermark file per process. -
Do not place the watermark file inside a watched directory — config validation rejects paths that would be tailed as log input. Keep it outside
watch.paths/watch.sources, similar tologging.file. - Writes are atomic (write to a
.tmpfile, then rename) to reduce the risk of a corrupted state file on crash.
With parser.type: multiline, watermark updates are tied to committed parser events, not to every physical line the watcher reads.
| Stage | What happens |
|---|---|
| Header line | Starts a new buffer; the previous multiline record (if any) is committed and its watermark is set to the last byte offset of that record (the final line that belonged to it). |
| Continuation lines | Appended to the buffer only. No publish and no watermark update yet. |
| Trailing record | The last event in a file stays buffered until a new header line arrives, parser.flush_interval elapses with no new lines (default 100ms), or the process shuts down gracefully. |
Implications for operators:
-
Watermark can lag the watcher briefly — byte offsets in
watermarks.jsonreflect the last committed multiline event, not necessarily the last line already read from the file. With defaultflush_interval, the lag is at most ~100ms after the last line; setflush_interval: 0to hold the tail until the next header or shutdown. -
Last record on shutdown — graceful stop flushes any remaining parser buffer, publishes the trailing record, and updates the watermark.
kill -9or crash may leave a record unpublished for up to one idle-flush window; on restart the forwarder resumes from the last committed offset and will re-read and re-publish those lines (at-least-once). -
Sidecar / Kubernetes — rely on graceful termination (preStop hook +
SIGTERM) for any record not yet idle-flushed. With defaultflush_interval, trailing records usually commit within 100ms; integration tests may still append a sentinel header line (see E2E-2 indocs/integration-test-cases.txt). -
Line parser for strict per-line watermarks — use
parser.type: linewhen each physical line should commit and advance the watermark immediately (see E2E-3 in the integration test catalog).
The offset stored for a committed multiline event is always the end offset of its last physical line, not an intermediate line within the stack trace.
User guide
- Home
- Installation and First Run
- How It Works
- Filtering Vendor Noise
- Log Forwarder ATC
- Configuration Guide
- Configuration-Reference
- Config-Catalog
- Choosing a Sink
- Spring Boot Logs
- Watermarks and Restarts
- Built-in-Components
- Custom-Extensions
- Monitoring
- Testing
- Docker
- Deployment
- Example Configs
- Troubleshooting
Contributors