vector-log-metrics-forward¶
Behaviors
Centralized log + metric shipping with Vector, where the
local journald is only a short-retention buffer and the durable copy lives
on one or more upstream collectors. A single NixOS module.
The problem¶
You have a fleet of boxes and you want their logs (and, optionally, Prometheus metrics) in one place. You don't want to depend on local disk to keep history, and you don't want a single collector outage — or the slow bring-up of a VPN link — to lose events.
This module:
- Runs a Vector pipeline that reads
journald(+ nginx logs, + an optional inbound socket from other nodes) and forwards it to your collector(s). - Trims
journalddown to a small buffer, because the authoritative copy is upstream. - Fans out to multiple upstreams in parallel, each behind its own disk buffer, so whichever sink is reachable first carries the data and a wedged sink can't stall the others.
The key insight / traps¶
These are the parts the config can't explain on its own — the reason this recipe exists.
1. journald is a buffer, not the archive¶
journaldMaxUse / journaldMaxRetentionSec are deliberately small. The durable
copy is upstream, so local journald only needs to hold enough to survive a short
upstream outage. Keeping it small also stops a wedged upstream + full disk buffer
from being compounded by an unbounded local journal.
2. Parse nginx access logs non-aborting (parse_regex, not parse_regex!)¶
This is the sharpest trap. TLS probers that connect to a plaintext :80 cause
nginx to log the raw ClientHello bytes as the "request". Those bytes contain a
literal ", which terminates the [^"]+ capture early and fails the regex.
With the aborting form (parse_regex!), every such line becomes a Vector
ERROR and is dropped — so you silently lose exactly the traffic you most
want to see. The non-aborting form returns an error value you can branch on:
on failure, forward the raw line tagged nginx_access_raw instead of dropping
it.
parsed, err = parse_regex(.message, r'...')
if err == null {
. |= parsed
.labels.source = "nginx_access"
} else {
.labels.source = "nginx_access_raw" # forward raw, don't drop
}
3. Fan out to a list of upstreams in parallel¶
upstream accepts a list. Each entry gets its own Vector sink and its own
buffer, and events go to all of them at once. The motivating case: during a
box's bring-up, a LAN relay is reachable immediately while a VPN/tailnet
collector only becomes reachable a little later. Sending to both means the first
one to answer carries the data, and the buffer holds the rest for the slower
sink — no ordering assumption, no lost events.
4. Order Vector after the GeoIP updater¶
GeoIP enrichment reads .mmdb files that a separate updater provisions. If
Vector starts against a missing or half-written database it errors on every
lookup. Set geoipUpdaterService and the module adds an after/wants
ordering so that can't happen.
5. Drop known noise once, up front¶
dropUnitNoise filters high-volume, zero-value journald lines (matched by
_SYSTEMD_UNIT + a substring) before they fan out to every downstream
transform, so you filter each spam line once rather than in every consumer.
Usage¶
Import the module and enable it on a shipper:
{
imports = [ ./vector-log-metrics-forward ];
behaviors.logs = {
useVector = true;
# One string, or a list to fan out in parallel.
upstream = [ "relay.lan" "collector.example.com" ];
enableMetrics = true;
enableNginxLogs = true;
enableGeoIP = true;
geoipUpdaterService = "geoip-updater.service"; # your updater unit
# Optional extra metric sources:
enableGPUMetrics = true;
postgresqlExporter = { enable = true; port = 9187; };
dropUnitNoise = [
{ unit = "some-chatty.service"; contains = "context canceled"; }
];
};
}
On the collector host, turn on the receiver:
{
imports = [ ./vector-log-metrics-forward ];
behaviors.logs = {
useVector = true;
enableLogReceiver = true;
receiverInterface = "vpn0"; # restrict the open ports to a trusted iface
# `enableLogReceiver` is a RELAY: received events flow through the same
# `add_labels` pipeline into the forwarding sinks, i.e. on to `upstream`.
# So `upstream` must point at the NEXT hop, never back at this host — a
# self-referencing upstream is an infinite loop. On a terminal collector,
# point `upstream` at your store's Vector, or add your own sink (see below).
upstream = "store.internal";
};
}
enableLogReceiver opens an inbound Vector source on logsPort and merges it
into add_labels, so received logs are re-labeled and forwarded like local ones.
To make this host the terminal store instead of a relay, extend
services.vector.settings.sinks with your own sink reading from add_labels
(Loki, Elasticsearch, an object store, …) and point upstream at a next hop you
actually want (or a throwaway you don't mind — the fan-out sinks are always
created).
Two receiver caveats:
- Metrics are not received here.
enableMetricson a receiver only scrapes this host's local exporters. Forwarded metrics arrive onmetricsPort, but this module binds no inbound source there — add a secondtype = "vector"source onmetricsPortyourself if you want to aggregate remote metrics. - The firewall opens
metricsPortwheneverenableMetricsis set on a receiver, even though nothing listens on it yet; harmless, but don't expect it to work until you add that source.
Options¶
| Option | Default | Purpose |
|---|---|---|
useVector |
false |
Master switch. |
upstream |
"" |
Collector host, or list of hosts to fan out to. |
logsPort / metricsPort |
4044 / 4045 |
Receiver ports. |
compression |
true |
Compress the forwarded stream. |
enableMetrics |
false |
Scrape local exporters and forward metrics. |
scrapeIntervalSecs |
60 |
Scrape cadence (raise to cut metric volume). |
enableNginxLogs |
false |
Forward nginx access + error logs. |
enableFail2banLogs |
false |
Forward fail2ban logs. |
enableLogReceiver |
false |
Act as a collector (open inbound socket). |
receiverInterface |
null |
Restrict receiver ports to one interface. |
enableGeoIP |
false |
Enrich nginx logs with GeoIP/ASN. |
geoipDatabasePath |
/var/lib/geoip-databases |
Where the .mmdb files live. |
geoipUpdaterService |
null |
Updater unit to order Vector after. |
enableGPUMetrics |
false |
Scrape NVIDIA GPU exporter (:9835). |
postgresqlExporter |
{enable=false; port=9187;} |
Scrape a postgres exporter. |
upsName |
"ups" |
NUT UPS name (with services.prometheus.exporters.nut). |
dropUnitNoise |
[] |
Unit+substring noise-drop rules. |
journaldMaxUse |
"500M" |
Local journald size cap. |
journaldMaxRetentionSec |
86400 |
Local journald time cap. |
vectorBufferType |
"disk" |
disk (survives restarts) or memory. |
vectorBufferMaxSize |
1073741824 |
Disk buffer cap, bytes. |
Caveats¶
- Provide the GeoIP databases yourself. This module reads
GeoLite2-City.mmdbandGeoLite2-ASN.mmdbfromgeoipDatabasePath; it does not download or license them. PointgeoipUpdaterServiceat whatever refreshes them. - The metric exporters must exist.
enableMetricsscrapesservices.prometheus.exporters.node(enabled by this module), and optionally a postgres exporter, an NVIDIA GPU exporter, andservices.prometheus.exporters.nut— enable those separately where you want them. - Don't expose the receiver on an untrusted network. With no
receiverInterfacethe ports open on all interfaces. Set it to a VPN/private interface, or firewall the ports yourself. - Requires
services.nginxfor the nginx sources andservices.fail2banfor the fail2ban filter; both are guarded, so leaving them off just omits those inputs. - It retargets Docker's log driver. On a host with
virtualisation.dockerenabled the module setslogDriver = "journald", so container logs land in the journal and get shipped with everything else. If you deliberately run a different driver, override it after importing. - Vector runs with
journaldAccess = trueandCAP_DAC_READ_SEARCH, and gets read-only access to/var/log/nginx(plus thenginxsupplementary group) and togeoipDatabasePathonly when those features are on.
Source¶
behaviors/vector-log-metrics-forward/default.nix
# vector-log-metrics-forward
#
# A NixOS module that ships journald + nginx logs (and, optionally, Prometheus
# metrics) off the box with Vector. The local journald is treated as a
# short-retention *buffer* only — the durable copy lives on one or more
# upstream collectors. Events fan out to every upstream in parallel behind a
# disk buffer, so whichever sink is reachable first wins.
#
# Drop into your host modules and set `behaviors.logs.useVector = true` plus an
# `upstream`. Nothing here is host- or fleet-specific; every site value is an
# option with a generic default.
#
# See README.md for the why and the traps.
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.behaviors.logs;
forwardMetricsEnabled =
cfg.enableMetrics
&& (
config.services.prometheus.exporters.node.enable
|| cfg.postgresqlExporter.enable
|| cfg.enableGPUMetrics
|| config.services.prometheus.exporters.nut.enable
);
# Build the VRL predicate that drops known-noisy journald lines *before* they
# fan out to every downstream transform. Each rule drops a message when it
# comes from `unit` AND its text contains `contains`. Empty list => pass all.
dropNoisePredicate =
if cfg.dropUnitNoise == [ ] then
"true"
else
lib.concatStringsSep " && " (
map (r: ''!(unit == "${r.unit}" && contains(msg, "${r.contains}"))'') cfg.dropUnitNoise
);
in
{
options.behaviors.logs = {
useVector = mkOption {
type = types.bool;
default = false;
description = "Master switch: run the Vector log/metric forwarder on this host.";
};
upstream = mkOption {
type = with types; either str (listOf str);
default = "";
description = ''
Upstream collector host(s) to forward to. Accepts either a single
string (one Vector sink) or a list of strings (one sink per entry).
With a list, events go to all upstreams in parallel — whichever is
reachable wins, and the disk buffer holds anything a slow/unreachable
sink hasn't accepted yet. This is the point of the pattern: e.g. a LAN
relay that is reachable immediately plus a VPN/tailnet host that only
becomes reachable a bit later.
'';
example = [
"relay.lan"
"collector.example.com"
];
};
logsPort = mkOption {
type = types.port;
default = 4044;
description = "TCP port the upstream Vector receiver listens on for logs.";
};
metricsPort = mkOption {
type = types.port;
default = 4045;
description = "TCP port the upstream Vector receiver listens on for metrics.";
};
compression = mkOption {
type = types.bool;
default = true;
description = "Compress the forwarded Vector stream(s) on the wire.";
};
enableMetrics = mkOption {
type = types.bool;
default = false;
description = "Also scrape local Prometheus exporters and forward the metrics.";
};
scrapeIntervalSecs = mkOption {
type = types.int;
default = 60;
description = ''
How often to scrape the local Prometheus exporters before forwarding.
Raising this proportionally lowers the forwarded metric volume.
'';
};
enableNginxLogs = mkOption {
type = types.bool;
default = false;
description = "Forward nginx access and error logs (requires services.nginx).";
};
enableFail2banLogs = mkOption {
type = types.bool;
default = false;
description = "Forward fail2ban logs (requires services.fail2ban).";
};
enableLogReceiver = mkOption {
type = types.bool;
default = false;
description = ''
Turn this host into an upstream collector: open a Vector source socket
that accepts logs (and metrics, if enableMetrics) from other nodes.
'';
};
receiverInterface = mkOption {
type = types.nullOr types.str;
default = null;
example = "vpn0";
description = ''
When acting as a receiver, restrict the opened firewall ports to this
interface only (e.g. a VPN interface). Null opens them on all
interfaces — only do that on a trusted network.
'';
};
enableGeoIP = mkOption {
type = types.bool;
default = false;
description = ''
Enrich nginx access logs with GeoIP data (country/city/coords/ASN).
Requires GeoLite2-City.mmdb and GeoLite2-ASN.mmdb present at
geoipDatabasePath (provisioned by whatever updater you run).
'';
};
geoipDatabasePath = mkOption {
type = types.path;
default = "/var/lib/geoip-databases";
description = "Directory holding GeoLite2-City.mmdb and GeoLite2-ASN.mmdb.";
};
geoipUpdaterService = mkOption {
type = types.nullOr types.str;
default = null;
example = "geoip-updater.service";
description = ''
Systemd unit that refreshes the GeoIP databases. If set, Vector is
ordered `after`/`wants` it so it never starts against a missing or
half-written mmdb. Null disables the ordering.
'';
};
enableGPUMetrics = mkOption {
type = types.bool;
default = false;
description = "Scrape NVIDIA GPU metrics via prometheus-nvidia-gpu-exporter (:9835).";
};
postgresqlExporter = mkOption {
description = "Optional PostgreSQL Prometheus exporter to scrape and forward.";
default = { };
type = types.submodule {
options = {
enable = mkOption {
type = types.bool;
default = false;
description = "Scrape a local postgres exporter and forward its metrics.";
};
port = mkOption {
type = types.port;
default = 9187;
description = "Port the postgres exporter serves /metrics on.";
};
};
};
};
upsName = mkOption {
type = types.str;
default = "ups";
description = ''
NUT UPS name to target when services.prometheus.exporters.nut is
enabled (the exporter is scraped at
/ups_metrics?target=<upsName>@localhost:3493).
'';
};
dropUnitNoise = mkOption {
type = types.listOf (
types.submodule {
options = {
unit = mkOption {
type = types.str;
description = "Exact _SYSTEMD_UNIT the noisy line comes from.";
};
contains = mkOption {
type = types.str;
description = "Substring that marks the line as noise to drop.";
};
};
}
);
default = [ ];
example = [
{
unit = "some-chatty.service";
contains = "context canceled";
}
];
description = ''
Journald lines to drop *before* they fan out to any downstream
transform. Each rule drops a message that both comes from `unit` and
contains `contains`. Keep the list short; this is for known,
high-volume, zero-value spam.
'';
};
journaldMaxUse = mkOption {
type = types.str;
default = "500M";
description = "SystemMaxUse for journald. Kept small: journald is only a buffer here.";
};
journaldMaxRetentionSec = mkOption {
type = types.int;
default = 24 * 3600;
description = "MaxRetentionSec for journald. Kept short: the durable copy is upstream.";
};
vectorBufferType = mkOption {
type = types.enum [
"disk"
"memory"
];
default = "disk";
description = ''
Buffer type for the forwarding sinks. `disk` survives restarts and
upstream outages (recommended); `memory` is faster but loses buffered
events on restart.
'';
};
vectorBufferMaxSize = mkOption {
type = types.int;
default = 1073741824; # 1 GiB
description = "Maximum size of Vector's on-disk sink buffer, in bytes.";
};
};
config = lib.mkIf cfg.useVector {
assertions = [
{
assertion =
if builtins.isList cfg.upstream then
cfg.upstream != [ ] && !(lib.elem "" cfg.upstream)
else
cfg.upstream != "";
message = "behaviors.logs: upstream must be set (string or non-empty list of strings) when useVector is true";
}
];
services.prometheus.exporters.node = mkIf cfg.enableMetrics {
enable = true;
port = 9100;
enabledCollectors = [
"systemd"
"processes"
"filesystem"
"meminfo"
"cpu"
"loadavg"
"netdev"
"diskstats"
"zfs"
];
disabledCollectors = [
"powersupplyclass"
];
};
systemd.services.nvidia-gpu-exporter = mkIf cfg.enableGPUMetrics {
description = "NVIDIA GPU Prometheus Exporter";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${pkgs.prometheus-nvidia-gpu-exporter}/bin/nvidia_gpu_exporter --web.listen-address=:9835";
Restart = "always";
RestartSec = "5s";
DynamicUser = true;
SupplementaryGroups = [ "video" ];
};
path = [ config.hardware.nvidia.package ];
};
services.vector = {
enable = true;
package = pkgs.vector;
journaldAccess = true;
settings = {
enrichment_tables = mkIf cfg.enableGeoIP {
geoip_table = {
type = "geoip";
path = "${cfg.geoipDatabasePath}/GeoLite2-City.mmdb";
};
asn_table = {
type = "geoip";
path = "${cfg.geoipDatabasePath}/GeoLite2-ASN.mmdb";
};
};
sources.journald = {
type = "journald";
current_boot_only = true;
};
# Drop known-noisy journald lines once, up front, so the spam never
# reaches any downstream transform. This transform is the base
# "filtered journald" node every other journald consumer reads from.
transforms.journald_filtered = {
type = "filter";
inputs = [ "journald" ];
condition = {
type = "vrl";
source = ''
unit = string(._SYSTEMD_UNIT) ?? ""
msg = string(.message) ?? ""
${dropNoisePredicate}
'';
};
};
sources.vector_listen = mkIf cfg.enableLogReceiver {
type = "vector";
address = "0.0.0.0:${toString cfg.logsPort}";
version = "2";
};
sources.nginx_access = mkIf (cfg.enableNginxLogs && config.services.nginx.enable) {
type = "file";
include = [ "/var/log/nginx/access.log" ];
read_from = "end";
fingerprint = {
strategy = "device_and_inode";
};
};
sources.nginx_error = mkIf (cfg.enableNginxLogs && config.services.nginx.enable) {
type = "file";
include = [ "/var/log/nginx/error.log" ];
read_from = "end";
fingerprint = {
strategy = "device_and_inode";
};
};
sources.prometheus_scrape = mkIf forwardMetricsEnabled {
type = "prometheus_scrape";
endpoints = lib.flatten [
(lib.optional config.services.prometheus.exporters.node.enable "http://localhost:${toString config.services.prometheus.exporters.node.port}/metrics")
(lib.optional cfg.postgresqlExporter.enable "http://localhost:${toString cfg.postgresqlExporter.port}/metrics")
(lib.optional cfg.enableGPUMetrics "http://localhost:9835/metrics")
(lib.optional config.services.prometheus.exporters.nut.enable "http://localhost:${toString config.services.prometheus.exporters.nut.port}/ups_metrics?target=${cfg.upsName}@localhost:3493")
];
scrape_interval_secs = cfg.scrapeIntervalSecs;
scrape_timeout_secs = 15;
};
transforms.parse_nginx_access = mkIf (cfg.enableNginxLogs && config.services.nginx.enable) {
type = "remap";
inputs = [ "nginx_access" ];
source = ''
# Non-aborting parse ON PURPOSE. TLS probers hitting a plaintext :80
# log raw ClientHello bytes that embed a literal `"` inside the
# request field, which terminates the `[^"]+` capture early and
# fails the regex. The aborting form (parse_regex!) turned every such
# line into a Vector ERROR and DROPPED it. With the non-aborting
# form we forward the raw line instead (tagged nginx_access_raw).
parsed, err = parse_regex(.message, r'^(?P<remote_addr>\S+) - (?P<remote_user>\S+) \[(?P<time_local>[^\]]+)\] "(?P<request>[^"]+)" (?P<status>\d+) (?P<body_bytes_sent>\d+) "(?P<http_referer>[^"]+)" "(?P<http_user_agent>[^"]+)" "(?P<host>[^"]+)"')
if err == null {
. |= parsed
.labels.source = "nginx_access"
.labels.host = "${config.networking.hostName}"
.labels.vhost = .host
del(.message)
} else {
.labels.source = "nginx_access_raw"
.labels.host = "${config.networking.hostName}"
}
'';
};
transforms.parse_nginx_error = mkIf (cfg.enableNginxLogs && config.services.nginx.enable) {
type = "remap";
inputs = [ "nginx_error" ];
source = ''
.labels.source = "nginx_error"
.labels.host = "${config.networking.hostName}"
'';
};
transforms.geoip_enrich =
mkIf (cfg.enableNginxLogs && cfg.enableGeoIP && config.services.nginx.enable)
{
type = "remap";
inputs = [ "parse_nginx_access" ];
source = ''
. = .
if exists(.remote_addr) {
ip = string!(.remote_addr)
if starts_with(ip, "10.") || starts_with(ip, "172.") || starts_with(ip, "192.168.") || starts_with(ip, "127.") || ip == "::1" {
.geoip = {
"status": "private_ip",
"ip": ip
}
} else {
geoip_result, err = get_enrichment_table_record("geoip_table", { "ip": .remote_addr })
if err != null {
.geoip = {
"status": "lookup_error",
"error": to_string(err),
"ip": ip
}
} else if geoip_result == null {
.geoip = {
"status": "not_found",
"ip": ip
}
} else {
.geoip = {}
if exists(geoip_result.city_name) && geoip_result.city_name != null {
.geoip.city_name = string!(geoip_result.city_name)
}
if exists(geoip_result.region_name) && geoip_result.region_name != null {
.geoip.region_name = string!(geoip_result.region_name)
}
if exists(geoip_result.country_code) && geoip_result.country_code != null {
.geoip.country_code = string!(geoip_result.country_code)
.labels.country = downcase(string!(geoip_result.country_code))
}
if exists(geoip_result.latitude) {
.geoip.latitude = geoip_result.latitude
}
if exists(geoip_result.longitude) {
.geoip.longitude = geoip_result.longitude
}
asn_result, asn_err = get_enrichment_table_record("asn_table", { "ip": .remote_addr })
if asn_err == null && asn_result != null {
if exists(asn_result.autonomous_system_number) {
.geoip.as_number = asn_result.autonomous_system_number
}
if exists(asn_result.autonomous_system_organization) && asn_result.autonomous_system_organization != null {
.geoip.as_organization = string!(asn_result.autonomous_system_organization)
}
}
}
}
}
'';
};
transforms.filter_fail2ban = mkIf (cfg.enableFail2banLogs && config.services.fail2ban.enable) {
type = "filter";
inputs = [ "journald_filtered" ];
condition = ''._SYSTEMD_UNIT == "fail2ban.service"'';
};
transforms.add_labels = {
type = "remap";
inputs =
[ "journald_filtered" ]
++ (lib.optional cfg.enableLogReceiver "vector_listen")
++ (lib.optional (
cfg.enableNginxLogs && cfg.enableGeoIP && config.services.nginx.enable
) "geoip_enrich")
++ (lib.optional (
cfg.enableNginxLogs && !cfg.enableGeoIP && config.services.nginx.enable
) "parse_nginx_access")
++ (lib.optional (cfg.enableNginxLogs && config.services.nginx.enable) "parse_nginx_error")
++ (lib.optional (cfg.enableFail2banLogs && config.services.fail2ban.enable) "filter_fail2ban");
source = ''
if !exists(.labels.host) {
.labels.host = "${config.networking.hostName}"
if exists(.host) {
.labels.host = .host
}
}
if exists(._SYSTEMD_UNIT) {
unit_raw = string!(._SYSTEMD_UNIT)
.labels.unit = replace(unit_raw, r'@[^.]+', "@")
} else if exists(._SYSTEMD_USER_UNIT) {
unit_raw = string!(._SYSTEMD_USER_UNIT)
.labels.unit = "user:" + replace(unit_raw, r'@[^.]+', "@")
} else {
.labels.unit = "none"
}
if !exists(.labels.source) {
.labels.source = "journald"
}
if !exists(.labels.severity) {
src = string!(.labels.source)
if src == "nginx_access" {
code = to_int(.status) ?? 0
if code >= 500 {
.labels.severity = "error"
} else if code >= 400 {
.labels.severity = "warn"
} else {
.labels.severity = "info"
}
} else if src == "nginx_error" {
.labels.severity = "error"
} else if exists(.PRIORITY) {
p = to_int(.PRIORITY) ?? 6
if p <= 3 {
.labels.severity = "error"
} else if p == 4 {
.labels.severity = "warn"
} else if p <= 6 {
.labels.severity = "info"
} else {
.labels.severity = "debug"
}
} else {
.labels.severity = "info"
}
}
'';
};
transforms.label_metrics = mkIf forwardMetricsEnabled {
type = "remap";
inputs = [ "prometheus_scrape" ];
source = ''
.tags.host = "${config.networking.hostName}"
if exists(.name) && starts_with(string!(.name), "pg_") {
.tags.instance = "${config.networking.hostName}:${toString cfg.postgresqlExporter.port}"
.tags.job = "postgresql"
} else if exists(.name) && starts_with(string!(.name), "nvidia_") {
.tags.instance = "${config.networking.hostName}:9835"
.tags.job = "nvidia-gpu"
} else if exists(.name) && starts_with(string!(.name), "network_ups_tools_") {
.tags.instance = "${config.networking.hostName}:${
toString (
if config.services.prometheus.exporters.nut.enable then
config.services.prometheus.exporters.nut.port
else
9199
)
}"
.tags.job = "ups"
} else {
.tags.instance = "${config.networking.hostName}:${toString config.services.prometheus.exporters.node.port}"
.tags.job = "node"
}
'';
};
sinks =
let
upstreams = if builtins.isList cfg.upstream then cfg.upstream else [ cfg.upstream ];
sanitize = s: lib.replaceStrings [ "." ":" "/" ] [ "_" "_" "_" ] s;
buf =
if cfg.vectorBufferType == "memory" then
{
type = "memory";
max_events = 10000;
when_full = "block";
}
else
{
type = "disk";
max_size = cfg.vectorBufferMaxSize;
when_full = "block";
};
# One sink per upstream. Events fan out to ALL of them in parallel,
# each with its own buffer — so a slow or down upstream never stalls
# the others, and whichever is reachable first carries the data.
forwardLogs = lib.listToAttrs (
map (u: {
name = "forward_logs_${sanitize u}";
value = {
type = "vector";
inputs = [ "add_labels" ];
address = "${u}:${toString cfg.logsPort}";
compression = cfg.compression;
buffer = buf;
acknowledgements.enabled = true;
request = {
timeout_secs = 120;
retry_initial_backoff_secs = 1;
retry_max_duration_secs = 300;
};
batch.max_bytes = 1048576;
};
}) upstreams
);
forwardMetrics =
if forwardMetricsEnabled then
lib.listToAttrs (
map (u: {
name = "forward_metrics_${sanitize u}";
value = {
type = "vector";
inputs = [ "label_metrics" ];
address = "${u}:${toString cfg.metricsPort}";
compression = cfg.compression;
buffer = buf;
acknowledgements.enabled = false;
request = {
timeout_secs = 120;
retry_initial_backoff_secs = 1;
retry_max_duration_secs = 300;
};
batch.max_bytes = 1048576;
};
}) upstreams
)
else
{ };
in
forwardLogs // forwardMetrics;
};
};
# journald is deliberately a short-retention BUFFER: the durable copy lives
# upstream. Keep it small so a wedged upstream can't blow up local disk.
services.journald = {
extraConfig = ''
SystemMaxUse=${cfg.journaldMaxUse}
MaxRetentionSec=${toString cfg.journaldMaxRetentionSec}
'';
};
systemd.services.vector = {
# Order Vector AFTER the GeoIP updater so it never starts against a
# missing or half-written mmdb.
after = mkIf (cfg.enableGeoIP && cfg.geoipUpdaterService != null) [ cfg.geoipUpdaterService ];
wants = mkIf (cfg.enableGeoIP && cfg.geoipUpdaterService != null) [ cfg.geoipUpdaterService ];
serviceConfig = {
CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
AmbientCapabilities = [ "CAP_DAC_READ_SEARCH" ];
ReadOnlyPaths = mkMerge [
(mkIf (cfg.enableNginxLogs && config.services.nginx.enable) [ "/var/log/nginx" ])
(mkIf cfg.enableGeoIP [ cfg.geoipDatabasePath ])
];
SupplementaryGroups = mkIf (cfg.enableNginxLogs && config.services.nginx.enable) [ "nginx" ];
};
environment = mkIf cfg.enableGeoIP {
GEOIP_DATABASE_PATH = cfg.geoipDatabasePath;
};
};
virtualisation.docker.logDriver = mkIf config.virtualisation.docker.enable "journald";
# Open the receiver ports. If receiverInterface is set, restrict to that
# interface (e.g. a VPN interface) instead of exposing them everywhere.
networking.firewall = mkIf cfg.enableLogReceiver (
let
ports = [ cfg.logsPort ] ++ (lib.optional cfg.enableMetrics cfg.metricsPort);
in
if cfg.receiverInterface != null then
{
interfaces.${cfg.receiverInterface}.allowedTCPPorts = ports;
}
else
{
allowedTCPPorts = ports;
}
);
};
}