Log CSV Replay¶
This page covers log_csv_replay. You point it at a structured CSV, and Sonda replays each row as a LogEvent at the cadence recorded in the file. It is the log-side counterpart of csv_replay for metrics.
A common case: you captured a 10-minute window around an incident from Loki with logcli. You want to send the same lines back through your pipeline with the same severities, fields, and timing.
The replay rate is derived from the CSV's timestamp column. The rate: you set in YAML is ignored. A 10-minute window in the CSV replays over 10 minutes of wall clock time with no manual rate tuning.
CSV format¶
log_csv_replay expects a CSV with three semantic columns plus any number of free-form field columns:
timestamp,severity,message,user_id
1700000000,info,GET /api/v1/health returned 200,u-42
1700000003,info,GET /api/v1/metrics returned 200,u-17
1700000006,warn,GET /api/v1/users returned 200 with high latency,u-91
1700000009,info,POST /api/v1/events returned 201,u-42
1700000012,error,POST /api/v1/events returned 500: upstream timeout,u-19
| Column | Role | Required | Behavior |
|---|---|---|---|
timestamp |
Drives the replay rate via median Δt | yes | Epoch seconds, or epoch milliseconds when the value is greater than 1e12. |
severity |
Maps to LogEvent::severity |
no | Falls back to default_severity when missing or unparseable. |
message |
Maps to LogEvent::message |
no | An empty cell becomes an empty string. |
| everything else | Becomes an entry in LogEvent::fields |
no | The column name is the field key. Empty cells are omitted. |
Sonda detects each role from the header row, case-insensitive. The aliases below are matched in order:
timestamp/ts/time→ timestamp roleseverity/level→ severity rolemessage/msg/log→ message role
Any header that does not match one of these becomes a free-form field column. Headers like user_id, trace_id, or pod are written as a fields entry on every emitted event.
When the header names don't match the conventions, use explicit columns: to map Sonda to the right columns by name.
The minimal scenario¶
version: 2
kind: runnable
defaults:
duration: 60s
encoder:
type: json_lines
sink:
type: stdout
scenarios:
- signal_type: logs
name: app_logs_csv_replay
rate: 1
log_generator:
type: csv_replay
file: examples/sample-logs.csv
default_severity: info
repeat: true
{"timestamp":"2026-05-15T18:37:55.791Z","severity":"info","message":"GET /api/v1/health returned 200","labels":{},"fields":{"user_id":"u-42"}}
{"timestamp":"2026-05-15T18:37:55.791Z","severity":"info","message":"GET /api/v1/metrics returned 200","labels":{},"fields":{"user_id":"u-17"}}
{"timestamp":"2026-05-15T18:37:55.791Z","severity":"warn","message":"GET /api/v1/users returned 200 with high latency","labels":{},"fields":{"user_id":"u-91"}}
The CSV has a 3-second step between timestamps. Sonda derives rate = 1 / 3 ≈ 0.333 events/s and emits one event every three seconds. The rate: 1 in YAML is replaced. A tracing::warn! records the override:
WARN log_csv_replay 'app_logs_csv_replay': overriding rate=1 with derived rate=0.3333333333333333 samples/s (CSV Δt=3s, timescale=1)
The timestamp field on each emitted event is the wall-clock time at emission, not the CSV row's timestamp. The CSV column is only used to derive the cadence. Severity, message, and field values are taken from the CSV without changes.
CSV-derived rate overrides YAML rate:¶
log_csv_replay behaves like the metrics-side csv_replay. The scenario's rate: is always replaced by timescale / median_delta_t. The value median_delta_t is the median interval between consecutive timestamps in the timestamp column. Setting rate: in YAML has no effect on emission cadence.
scenarios:
- signal_type: logs
name: incident_replay
rate: 1 # ignored -- CSV Δt and timescale drive the rate
log_generator:
type: csv_replay
file: incident-2026-05-12.csv
timescale: 10.0 # play back 10x faster
timescale must be a positive finite number (> 0). The default is 1.0.
How the derivation works
Sonda reads the timestamp column for up to 100 data rows. It parses each cell as a number and computes the median of consecutive differences. Values larger than 1e12 are read as epoch milliseconds. Smaller values are read as epoch seconds. The derived rate is timescale / median_delta. Run sonda --verbose --dry-run to confirm the value, or check the startup banner.
Pulling a CSV out of Loki with logcli¶
A typical workflow: you have a real log stream in Loki. You want to extract a window of it as a CSV that log_csv_replay can read.
Loki's official CLI logcli produces JSON output by default. The format you want for log_csv_replay is timestamp,severity,message,...fields. The conversion is a small jq pipeline.
Step 1: query the window from Loki¶
logcli query \
--from="2026-05-12T14:00:00Z" \
--to="2026-05-12T14:10:00Z" \
--output=jsonl \
'{app="api-gateway"}' > raw-logs.jsonl
Each line is one log entry:
{"timestamp":"2026-05-12T14:00:00.123Z","labels":{"app":"api-gateway","level":"info","pod":"api-7c4f9"},"line":"GET /api/v1/health returned 200"}
Step 2: project the columns you need¶
Use jq to flatten each entry into a CSV row. The timestamp comes from the entry timestamp. The severity comes from the labels. The message is line. Other values become field columns.
jq -r '
[
(.timestamp | fromdate),
(.labels.level // "info"),
.line,
.labels.pod,
.labels.trace_id
] | @csv
' raw-logs.jsonl > body.csv
echo "timestamp,severity,message,pod,trace_id" > incident.csv
cat body.csv >> incident.csv
You now have incident.csv:
timestamp,severity,message,pod,trace_id
1715522400,info,"GET /api/v1/health returned 200","api-7c4f9","abc123"
1715522401,warn,"GET /api/v1/users high latency","api-7c4f9","abc124"
...
Step 3: replay¶
version: 2
kind: runnable
defaults:
duration: 10m
encoder:
type: json_lines
sink:
type: loki
endpoint: http://localhost:3100/loki/api/v1/push
labels:
source: replay
job: api-gateway-replay
scenarios:
- signal_type: logs
name: api_gateway_replay
log_generator:
type: csv_replay
file: incident.csv
default_severity: info
repeat: false
Sonda derives the replay rate from the timestamps in incident.csv. A 10-minute window replays over 10 minutes. Each event is sent to Loki tagged with source="replay", so you can query the originals and the replay side by side.
Add a tag label, not a CSV column
Use the sink's labels: block to add source=replay or run_id=... to every event. Adding the value in the CSV would create a redundant LogEvent.fields entry on every row.
Explicit column mapping¶
When the CSV header uses names that don't match the auto-discovery aliases, declare them with columns:. Examples: ts instead of timestamp, sev instead of severity, text instead of message.
scenarios:
- signal_type: logs
name: custom_headers
log_generator:
type: csv_replay
file: weird-headers.csv
columns:
timestamp: ts
severity: sev
message: text
Explicit columns: overrides auto-discovery. Any column not listed and not auto-matched becomes a field column.
Failure modes¶
| Error message | Cause | Fix |
|---|---|---|
csv_replay: file "..." has fewer than 2 data rows; cannot derive replay rate |
The CSV only has a header and zero or one data rows. | At least two data rows are needed to measure the sample interval. Export a wider window. |
csv_replay: non-monotonic timestamps in "..." (row N value X <= previous Y) |
A timestamp goes backward or repeats. Common with concatenated exports or paused recordings. | Sort the CSV by timestamp, deduplicate, or split it at the discontinuity. |
csv_replay: 'timescale' must be a positive finite number, got 0 |
timescale: 0, a negative value, or NaN / Inf. |
Set timescale to a positive number, or remove it to use the default 1.0. |
log_csv_replay: CSV content is empty |
The file is empty or contains only comments. | Re-export the window with actual data rows. |
log_csv_replay: column "X" not found in CSV header |
An explicit columns: mapping references a header name that does not exist. |
Check the header row of the CSV. Column name matching is case-insensitive. |
Severity fallback (soft-fail)¶
When a row's severity cell is empty or contains an unrecognised value like bogus, Sonda does not error. Instead, the row falls back to default_severity, which is Info by default. At expand time, Sonda emits one summary warn line counting how many rows used the fallback:
WARN log_csv_replay 'incident_replay': 7 row(s) used default_severity due to missing or unparseable severity values
This is a per-scenario summary, not per-event. If the fallback count is high relative to the row count, check the severity column in your CSV. Typos in custom severity values often appear this way.
Valid severity strings: trace, debug, info, warn (or warning), error, fatal. Case-insensitive.
Empty messages and fields¶
- An empty
messagecell produces an empty string in the emitted event. It is not an error. - An empty field cell is omitted from the row's
fieldsmap. A row withuser_id,,trace_id=abcproduces{"trace_id":"abc"}, with nouser_idkey, rather than{"user_id":""}.
Quick reference¶
log_generator:
type: csv_replay
file: path/to/logs.csv
timescale: 1.0 # optional, default 1.0, must be > 0
default_severity: info # optional, default 'info'
repeat: true # optional, default true
columns: # optional -- when omitted, auto-discover from header
timestamp: timestamp
severity: severity
message: message
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
file |
string | yes | -- | Path to the CSV file, relative to the working directory where you run sonda. |
timescale |
float | no | 1.0 |
Replay speed multiplier. 2.0 is 2x faster, 0.5 is 2x slower. Must be strictly positive. |
default_severity |
string | no | info |
Fallback severity when the severity column is missing, empty, or unparseable. One of trace, debug, info, warn, error, fatal. |
repeat |
boolean | no | true |
When true, cycles back to the start of the CSV. When false, holds the last row for all later ticks. |
columns |
object | no | auto-discover | Explicit name-based column mapping. Sub-fields: timestamp, severity, message. Any column not named here and not auto-matched becomes a field column. |
The scenario's rate: is always replaced
For log_csv_replay, rate: is computed from the CSV's timestamp column and timescale. Any value you set in YAML is replaced. Run sonda --verbose --dry-run to confirm the derived rate, or check the startup banner.
For the full generator reference, see Generators: log csv_replay. For the metrics-side workflow, see Grafana CSV Replay.
Coming from type: replay?¶
Earlier versions included a type: replay log generator that cycled lines from a plain text file at a hand-tuned rate:. It hardcoded severity to Info, had no field support, and ignored timestamps. It has been removed.
If you have an existing YAML that uses type: replay, the migration is:
- Convert your text log file into a CSV with at least a
timestampcolumn. The simplest CSV istimestamp,messagewith synthetic timestamps spaced at your old replay rate. For example, 200ms apart forrate: 5. - Change
type: replaytotype: csv_replay. - Remove the YAML
rate:. The rate is derived from the CSV now.
If your source data is unstructured text and you cannot convert it to CSV, raw-file replay is planned. It will live in a separate sonda-integrations adapter. Track issue #347 for status.