ObservabilityXXXVI · Log ShippingLogShipping
Alloy Configuration
What you'll learn
- Write a River-syntax Alloy configuration for a typical logs pipeline
- Wire loki.source.file, loki.process stages, and loki.write components by their receivers
- Apply the stage order rules so each transform sees the labels it expects
- Validate a config with alloy fmt and alloy validate and read the resulting metrics
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A team rolls a new Loki parsing pipeline. Two of five services ship
their log lines with a level label; three do not. The
investigation finds that the three services are running an older
config that never had a stage.regex for the level field, so the
label is missing. The dashboards that filter by
level=error return nothing for those services. The team
needs a config that parses the field for every service and stamps
the label in the right place in the pipeline order.
This lesson is the configuration grammar that makes that fix
possible: River blocks, the forward_to wiring, and the stage
order inside loki.process.
What it is
River is the configuration language Alloy uses. It is an HCL
dialect with three additions over plain HCL: typed components, a
receiver-based wiring model, and expressions evaluated against a
typed value model. A River file is a list of named blocks. Each
block declares one component instance. Components are connected
by listing their receivers in a forward_to argument.
The minimum building blocks for a log pipeline are three components.
loki.source.file- tails one or more files from the host filesystem, applies static labels, and emits log entries.loki.process- applies a chain of stages to each entry.- stages parse, mutate, filter, and label the entry.
loki.write- serialises the entry and pushes it to a Loki endpoint.
A pipeline is a chain of these three component kinds. The output
of one component is wired into the next via forward_to.
Why a sysadmin cares
The operational cost of a misconfigured pipeline is not visible until it is. Three failure shapes appear when the wiring is treated as an implementation detail rather than a config surface.
- The pipeline that drops labels. A
stage.labelsruns before thestage.regexthat produces the label value. The labels block has nothing to label. Symptom: the labels are present but empty; Loki receiveslevel=""on every entry; downstream filters return nothing. - The pipeline that fans out to two writers. Two
loki.writeblocks share aloki.processreceiver. One writer is healthy, one is misconfigured. Symptom: every entry is sent twice, and the failing writer’s exporter metrics show send failures. - The pipeline that is hot-reload invisible. A regex change
is rolled out without a
systemctl reload. Symptom: the new regex is on disk but the running process is still using the old one; the on-call engineer spends an hour wondering why the change has no effect.
How it works
Block syntax
component.kind "label" {
argument_one = "value"
argument_two = 42
forward_to = [component.kind.other_label.receiver]
}
Three rules govern the syntax.
- Kind. The kind is the component type.
loki.source.file,loki.process,loki.write. The kind is fixed; the label is the operator’s choice. - Label. The label is the instance name within the kind. Two blocks of the same kind with different labels coexist happily. Two blocks of the same kind with the same label collide at parse time.
forward_to. The list of receivers that receive the component’s output. Every receiver must exist on a declared component. Aforward_tothat points to a non-existent component failsalloy validate.
The forward_to wiring
The wiring is a list of references of the form
component.kind.label.receiver. A receiver is an exported input
on the component. Most components export a single receiver
called receiver. Some expose additional receivers for
branching.
loki.source.file "app" {
targets = [{ __path__ = "/var/log/app/*.log", job = "checkout" }]
forward_to = [loki.process.app.receiver]
}
loki.process "app" {
stage.json { expressions = { level = "level" } }
stage.labels { values = { level = "" } }
forward_to = [loki.write.central.receiver]
}
loki.write "central" {
endpoint { url = "https://loki/loki/api/v1/push" }
}
Three components, one pipeline. The runtime wires them in evaluation order.
Variables and expressions
River is a real language. A block can declare local variables;
a top-level block can reference constants.hostname,
env("VAR"), and file("/path"). The values are typed.
loki.source.file "system" {
targets = [{
__path__ = "/var/log/syslog",
job = "system",
host = constants.hostname,
env = env("DEPLOY_ENV"),
}]
forward_to = [loki.process.system.receiver]
}
constants.hostname resolves at load time. env("DEPLOY_ENV")
resolves at load time from the process environment. The values
must be strings; a number passed where a string is expected
fails the type check.
How to configure it
A complete annotated logs pipeline for an application that writes
JSON lines to /var/log/app/*.log.
// /etc/alloy/config.alloy
logging {
level = "info"
format = "logfmt"
}
// 1. Source: tail the application log files.
loki.source.file "app" {
targets = [{
__path__ = "/var/log/app/*.log",
job = "checkout",
host = constants.hostname,
env = env("DEPLOY_ENV"),
}]
forward_to = [loki.process.app.receiver]
}
// 2. Process: parse JSON, label level, drop noise.
loki.process "app" {
// 2a. Parse the line as JSON. The fields become entries in the
// value map; the original `entry` field becomes a string.
stage.json {
expressions = {
ts = "ts",
level = "level",
msg = "msg",
traceid = "trace_id",
}
}
// 2b. Promote `level` and `traceid` to labels. Labels are indexed
// by Loki; values are not. Choose the smallest set of fields
// that the dashboards filter by.
stage.labels {
values = {
level = "",
traceid = "",
}
}
// 2c. Drop entries below info. The filter runs against the
// entry map; it sees the parsed fields, not the labels.
stage.match {
selector = "{job=\"checkout\"}"
stage.drop {
expression = ".*"
matcher = "level"
drop_counter_reason = "below_info"
older_than = "1h"
}
}
forward_to = [loki.write.central.receiver]
}
// 3. Write: push to Loki over HTTPS.
loki.write "central" {
endpoint {
url = "https://loki.internal.example.com/loki/api/v1/push"
tenant_id = "prod"
basic_auth {
username = "ingest"
password_file = "/etc/alloy/secrets/loki-pass"
}
}
}
Stage-by-stage
The four stages used above each have a specific job.
stage.jsonparses a JSON line and projects fields into the entry map. If the line is not valid JSON, the entry is passed through with the raw line preserved. The expressions block names the fields to project.stage.labelspromotes values from the entry map to labels. The value on the right-hand side is the entry-map key; the value on the left-hand side is the label name. An empty string on the right-hand side means “use the same name as the label”.stage.matchwraps one or more stages in a conditional filter. The selector matches against the current labelset. The nested stages run only when the selector matches.stage.dropremoves entries from the stream. The expression matches against the entry-map value; the matcher names the field;older_thanlets a buffered entry through for a grace period before drop.
A common mistake is to put stage.labels before stage.json.
The labels block has no parsed fields to label, so the labels
stay empty. The fix is order: parse first, then label.
Other useful stages
stage.regex- parse a non-JSON line by named capture groups. The captured groups become entry-map values.stage.template- rewrite the entry line using Go templating. Useful for normalising formats.stage.output- emit the current entry map to a separate receiver. Useful for splitting a stream into two destinations.stage.tenant- set the Loki tenant on a per-entry basis. Only effective when theloki.writeblock does not have its owntenant_idset.
How to validate it
Validation has three stages: format, syntax, and runtime health.
# CONFIGURATION: format-check. The formatter rewrites the file
# in place if it is unformatted.
alloy fmt --check /etc/alloy/config.alloy
# (no output on success; non-zero exit if the file is unformatted)
# CONFIGURATION: parse-check against the component schema.
alloy validate /etc/alloy/config.alloy
ts=2026-08-13T13:11:04Z level=info msg="config valid"
# SERVICE-IMPACT: hot-reload the running Alloy process.
systemctl reload alloy
# READ-ONLY: confirm the source is tailing files.
curl -s http://localhost:12345/metrics | grep loki_source_file_files_count
loki_source_file_files_count{component="loki.source.file.app"} 8
# READ-ONLY: confirm the writer is shipping.
curl -s http://localhost:12345/metrics | grep loki_write_sent
loki_write_sent_entries_total{component="loki.write.central"} 1872
If loki_write_sent_entries_total advances but lines do not
appear in Loki, the failure is downstream of the collector
(Loki distributor rejecting, network, tenant mismatch). If it
does not advance, the failure is in the pipeline above the
writer.
How it can fail
Five failure modes specific to Alloy configuration.
- The label-collision error. Two
loki.writeblocks declared with the same label. Symptom:alloy validateexits withcomponent "loki.write.central" already exists. The runtime refuses to load the graph; no traffic flows at all. - The
forward_toreference that points nowhere. Aloki.processblock forwards toloki.write.central.receiverbut the onlyloki.writeblock is labelledloki. Symptom:alloy validateexits withcomponent "loki.process.app.forward_to" references unknown component "loki.write.central". The runtime refuses to start. - The stage that runs before the parser. A
stage.labelsblock beforestage.json, so the labels block has no parsed field to label. Symptom: the labels are present but empty; Loki receiveslevel=""on every entry. The pipeline runs, the labels are silent failures. - The regex that matches nothing. A
stage.regexwhose expression does not match the actual line format. Symptom:loki_source_file_target_lines_totaladvances, but the captured groups in the value map are empty, and downstream label promotions produce empty labels. The metric counters lie about success. - The hot-reload that left the old component running. A
label rename on
loki.source.filefromapptocheckoutduring a hot reload. Symptom:loki_source_file_target_lines_totalshows two counters advancing in parallel for several minutes, and Loki receives duplicated log streams.
How to troubleshoot it
When the pipeline is up but the labels are wrong, the order matters.
- Read the agent log.
journalctl -u alloy -n 200shows the last reload. The first error is the one to fix; subsequent errors are usually its consequences. - Inspect the stage outputs.
curl -s http://localhost:12345/debug/pprofis not the right tool; instead, add aloki.writeblock that writes a copy of the stream to a separate tenant, then query that tenant for the labels. The label state at the writer is the label state that arrived at Loki. - Compare counters across stages.
loki_source_file_target_lines_totalis the input rate;loki_process_dropped_lines_totalis the drop rate;loki_write_sent_entries_totalis the output rate. The output should equal input minus drop modulo the in-flight batch. - Run
alloy fmtto surface indentation errors. An unclosed block produces a parse error with a line number;alloy fmtwill refuse to reformat a file with a syntax error and will print the location. - Smoke test the export. Ship a known marker line with a unique UUID and confirm it arrives in the right Loki tenant with the expected labels within ten seconds.
Security implications
The configuration file holds the wiring; it does not hold the secrets.
- Secrets in the config. River supports
env("...")andfile("...")lookups for sensitive values. Use them for passwords, never the literal. A literal password in a committed config file is a credential leak waiting to happen. - CA bundles. The collector must trust the Loki certificate. Mount the CA bundle from the host’s trust store or from a ConfigMap, and reference it by path. A stale bundle is the most common cause of silent shipping failure.
- Filesystem access. A
loki.source.fileblock with a wildcard like/var/log/**/*.logwill tail whatever the process can read. The process runs as thealloyuser by default; ensure the user has read access to the intended paths and no more.
Performance implications
- Stage order. A
stage.regexis more expensive than astage.labels. Put the cheap stages first; put the regex last. The pipeline runs every entry through every stage; a misordered chain spends CPU on regex when a label match would have rejected the entry earlier. - Buffering.
loki.writebuffers entries to disk when the destination is slow or unreachable. The buffer is bounded bybuffer_config; a buffer that fills up causes the source to stall, which causes the file position to fall behind. - Hot reload cost. Reloading a config with a small change restarts only the changed components. Reloading a config with a widespread change (a new file pattern, a new label) restarts the affected sources and re-reads the position file. Tune the position-file sync interval to balance crash recovery against hot-reload cost.
Production guidance
- Pin the component versions. Each
import.gitblock pins a revision. Use a tag or a commit SHA, not a branch name. Amainreference will silently change after a module owner commits. - Run
alloy validatein CI. The same parse that the runtime runs at reload should run in the pipeline that commits the config. A parse error caught in CI is a comment; the same error caught at 03:00 is a page. - Smoke test after every config change. Ship a known line with a unique UUID and confirm it arrives in the right Loki tenant with the expected labels. The smoke test should be automatic; manual smoke tests do not run.
- Back up the position file.
/var/lib/alloy/positions.yamlis the file that records which lines have been read. A position file lost on restart means re-reading every file from the start. Back it up alongside the config.
Verification
You should now be able to answer:
- What is the relationship between
component.kind.label.receiverand theforward_toargument on the previous block? - Why must
stage.jsonorstage.regexrun beforestage.labelsin aloki.processchain? - What does
alloy fmt --checkcatch thatalloy validatedoes not? - What is the difference in symptom between a label collision
error and a
forward_toreference error?
Quiz
Knowledge check · 8 questions
Q1. In River, the wiring between components is declared by:
Q2. The stages inside `loki.process` run:
Q3. Credentials in an Alloy config belong behind an env("...") or file("...") lookup rather than written literally into the committed file.
Q4. loki.source.file targets are declared as:
Q5. Which Alloy subcommand rewrites the file in place to canonical formatting?
Q6. Which of these are real loki.process stages?
Q7. A stage.labels block runs before stage.json. What is the symptom?
Q8. When a regex change is rolled out without a hot reload, the symptom is:
Passing score: 75%. Answers are checked in this browser.