NICo Logging
How NICo components emit logs, where they go, what format they use and how to tune them.
TL;DR
- All NICo components log to stdout. In Kubernetes the kubelet writes container stdout to
files under
/var/log/pods/, where a collector (typically an OpenTelemetry Collector DaemonSet) picks them up. - Most components use logfmt (key=value pairs). nico-dns uses JSON. nico-ssh-console uses a compact single-line format. nico-pxe uses plain text.
- Default log level is INFO. Override at startup with
RUST_LOGor the--debugflag. - nico-api supports runtime log-level changes via
nico-admin-cli set log-filter. Changes auto-expire (default 1 hour) and revert to the startup level - useful for time-boxed debugging without forgetting to turn verbosity back down. - Centralized logging uses a pull model: a DaemonSet collector reads pod log files and ships them to your backend (Loki, Elasticsearch, VictoriaLogs, Datadog, etc.).
1. Where logs go
Every NICo binary writes its own service logs to stdout. There is no file-based logging for service logs - the expectation is Kubernetes-native log collection.
In a Kubernetes cluster:
- The container runtime captures stdout and writes it to a log file under
/var/log/pods/<namespace>_<pod>_<uid>/<container>/*.log. - A log collector (DaemonSet) tails those files and forwards entries to a backend.
This is the standard Kubernetes pattern. NICo does not require any special log driver or sidecar - just configure your collector to read pod logs.
Note: nico-ssh-console is an exception - in addition to stdout, it writes machine console output (BMC serial console streams) to local files. These are not service logs but captured output from managed machines. See section 2.5 for details.
2. Which components log and in what format
2.1 logfmt format
Most NICo components use logfmt - a line-oriented format of
space-separated key=value pairs, easy for both humans and machines to parse.
Event lines — one per log call:
Span lines — emitted when a unit of work closes (level=SPAN), carrying timing data:
Common fields:
The logfmt layer is implemented in the logfmt crate (crates/logfmt). It emits span lifecycle
events (open/close) as level=SPAN lines with timing data - useful for identifying slow operations
without enabling full distributed tracing.
The component field
On logfmt lines, NICo sets the component field to identify the emitting service or subsystem:
State-controller lines also carry a controller=<name> field with the same value.
This enables filtering logs by component in your backend. For example, with Loki:
Convention: NICo uses
componentfor the emitting service/subsystem. Don’t reuse this key for domain data - give those their own keys (e.g.machine_id,controller).
Coverage
Components that do not use the logfmt layer and carry no component field:
2.2 JSON format (nico-dns)
nico-dns uses tracing-subscriber’s JSON formatter. Each line is a self-contained JSON object:
2.3 Compact format (nico-ssh-console)
nico-ssh-console uses tracing-subscriber’s compact formatter - a human-readable single-line
format similar to traditional log output:
2.4 Plain text (nico-pxe)
nico-pxe uses println! for startup messages and request logging middleware. Output is
unstructured plain text.
2.5 Machine console logs (nico-ssh-console)
In addition to its own service logs (compact format on stdout), nico-ssh-console captures machine and DPU serial console output to local files. This is separate from the service’s tracing output.
When a BMC console session is established, nico-ssh-console streams the serial output to a per-machine log file:
Key details:
The console logger strips ANSI escape sequences and adds timestamps at session start/stop. Rotation happens automatically - old logs are renamed with numeric suffixes and the oldest is deleted when the limit is reached.
These files contain raw machine boot output, kernel messages and anything else that appears on the serial console. They are useful for debugging boot failures, kernel panics and hardware issues.
Centralizing console logs: The nico-ssh-console Helm chart includes an optional OpenTelemetry Collector sidecar (
lokiLogCollector.enabled: true) that ships console logs to Loki. The sidecar reads from/var/log/consoles/*.log, extracts the machine ID and BMC IP from filenames, and exports to your Loki endpoint. To use a different backend, customize theconfigFiles.otelcolConfigvalue in the chart. A separate document covers machine log collection workflows in detail.
3. How to tune log levels
3.1 At startup: RUST_LOG and —debug
All components respect the RUST_LOG environment variable, which uses tracing-subscriber’s
EnvFilter
syntax:
Several binaries also accept a --debug flag that sets the default level to DEBUG (equivalent
to RUST_LOG=debug if RUST_LOG is unset):
Default log level is INFO for all components.
Noisy dependencies
NICo components automatically suppress verbose output from common dependencies. For example, nico-api applies these directives by default:
Your RUST_LOG directives are merged with these defaults, so you don’t need to manually
silence framework noise.
3.2 At runtime: nico-admin-cli (nico-api only)
nico-api supports live log-level changes without a restart. This is useful for debugging production issues - you can increase verbosity temporarily, then let it automatically revert.
When the expiry elapses, the log filter automatically reverts to the startup value. This prevents accidentally leaving debug logging on and filling your storage.
How it works
The admin CLI calls a gRPC endpoint on nico-api that updates the EnvFilter in the running
process. The new filter applies immediately to all subsequent log events - no restart, no
config file change. Under the hood, tracing-subscriber’s reload::Handle swaps the active
filter.
Scope: runtime log-level changes affect nico-api only. Other components (nico-dns, nico-dhcp, etc.) require a restart to change log levels.
3.3 Kubernetes deployment
Set RUST_LOG in your pod spec or Helm values:
For temporary debugging, use nico-admin-cli to avoid redeploying:
4. Centralizing logs with OpenTelemetry Collector
The recommended approach for centralized logging is an OpenTelemetry Collector DaemonSet that reads pod log files and exports them to your backend. This is the standard Kubernetes pattern and works with any OTLP-compatible backend: Grafana Loki, Elasticsearch, VictoriaLogs, Datadog, Splunk, etc.
4.1 Collector configuration
Below is a reference Helm values file for deploying the OpenTelemetry Collector Helm chart to collect NICo logs. Adapt the exporter section for your backend.
The logsCollection preset parses the Kubernetes container log format; the explicit
filelog receiver below controls which pod log files are collected.
4.2 Extracting structured fields
Since most NICo components use logfmt, you can parse structured fields at the collector level.
This makes fields like level, msg, machine_id, etc. available for filtering and querying
in your backend.
Add a transform processor to parse logfmt:
If you cannot use the transform processor, a minimal filelog receiver regex can extract
level and msg while keeping the remaining key-value pairs in rest. Use the
ParseKeyValue approach above for full field extraction.
4.3 Filtering and sampling
For high-volume deployments, filter or sample logs at the collector to reduce noise and storage costs.
Dropping noisy log messages
Some log messages are expected but not useful for debugging. For example, DHCP relay requests that don’t match a defined network segment (because switches relay DHCP device-wide, not per-network), or DPU kernel messages about mlx5_core module state changes during normal operation. These can flood logs without adding signal.
Use the filter processor to drop specific messages by pattern:
The error_mode: silent setting prevents the filter processor from logging errors when a
record doesn’t match - otherwise you’d get noise about the noise filtering.
Managing filter patterns with Helm
When deploying via Helm, keep filter patterns separate from the main collector config for easier maintenance. There are two approaches:
Option A: Patterns in chart files
Place patterns in a file within your chart package at files/drop-patterns.txt:
Then use .Files.Lines in your template to load them:
This reads patterns at helm install time from the chart’s files/ directory. To update
patterns, you must update the chart and redeploy.
Option B: Patterns in values.yaml
For patterns that operators can change without modifying the chart, use values:
This approach lets operators add or remove filter patterns by updating Helm values
(--set-json or a values file override) without modifying the chart itself.
Routing logs to different pipelines
For more control, use a routing connector to send different log types to different pipelines. This lets you apply different filters or export to different backends:
Probabilistic sampling
For extremely high-volume logs where you only need a statistical sample:
Log support in the OpenTelemetry probabilistic_sampler processor is alpha. Verify that
your collector distribution supports log pipelines for this processor before relying on it
in production.
Use sampling cautiously - you may miss the one error message that matters.
4.4 Backend-specific notes
VictoriaLogs
VictoriaLogs accepts OTLP logs. Use the otlphttp exporter with logs_endpoint:
Grafana Loki
Loki works well with logfmt - it can parse key=value pairs natively in queries using
| logfmt. Set loki.format: raw in the resource processor to send logs unparsed, letting
Loki handle parsing at query time:
Query example:
Elasticsearch / OpenSearch
Parse logfmt at ingest time (in the collector or an ingest pipeline) to get structured documents. This enables efficient field-based queries and aggregations.
Datadog
Use the Datadog exporter or OTLP endpoint. Datadog automatically parses common log formats.
5. Troubleshooting
Verifying log output
To see raw logs from a NICo component:
For more advanced log tailing across multiple pods, use stern:
stern is particularly useful when you have multiple replicas or want to watch several components at once.
Checking current log level (nico-api)
The current log filter is visible in nico-api’s startup log and via the admin API. Look for: