* fix(obs): open cleaner compression source with O_NOFOLLOW The compressor opened the source log via File::open, which follows a symlink at the final path component. Between the scanner selecting a regular file and this open, an attacker with write access to the log directory could swap the entry for a symlink (TOCTOU) pointing at, say, /etc/shadow, whose contents would then be copied into an archive. Open the source with O_NOFOLLOW on Unix so such a swap fails with ELOOP; the temp/archive path already refused symlinks, this closes the source side. Refs rustfs/backlog#1210 Co-Authored-By: heihutu <heihutu@gmail.com> * fix(obs): recompress instead of trusting leftover cleaner archives archive_header_ok only checked the first 2-4 magic bytes before treating an existing .gz/.zst as a completed prior result and letting the caller delete the source log. A file with valid magic but a truncated or forged body passes that check, so an attacker with write access to the log directory (or a crashed prior run) could plant such a stub and make the cleaner delete the real log without ever producing a usable archive — silent audit-data loss. Chosen fix: stop trusting cross-process leftovers entirely and always recompress the source in this pass, rather than fully decoding every leftover to validate it. Full-decode validation would add real CPU cost and decode-bug surface for a rare crash-recovery case; the existing atomic create_new+rename already overwrites whatever sits at the archive path (a planted symlink is replaced, never followed) with a freshly written, fsync'd archive, so a partial/forged leftover can never gate source deletion. This is the lowest-regression option. Refs rustfs/backlog#1211 Co-Authored-By: heihutu <heihutu@gmail.com> * fix(object-data-cache): cap memory-gate reservation at cache growth headroom The memory gate subtracts `admitted_since_refresh` from the snapshot's available bytes so a burst arriving faster than the 5 s refresh cannot over-allocate. That counter is GROSS: it only rolls over on the refresh and never rolls back when a fill is later evicted, cancelled, or loses the invalidation race. Under sustained high-throughput churn (net footprint flat and far below `max_capacity`) the raw counter balloons past the memory the cache actually holds, so `effective_available` collapses and the gate reports false memory pressure — skipping the hottest fills with SkippedMemoryPressure until the next 5 s refresh. This only lowers hit rate; it never returns wrong data and self-heals each refresh. Fix direction 1 (minimal regression): cap the reservation deduction at the cache's own growth headroom (`max_capacity - weighted_size()`) instead of letting the unbounded gross counter shrink the system-available budget. The cache can never hold more than `max_capacity`, so a burst adds at most that headroom of real memory before moka evicts to stay bounded (net-zero churn beyond that point) — capping the deduction there keeps the reservation honest without treating gross churn as growth. Chosen over net-accounting (direction 2, releasing bytes on every failure/cancel/eviction path) because that only plugs the leak on failed fills and would not address the core defect: churn of *successful* insert/evict fills over the 5 s window. It also touches only the gate plus one call site rather than every failure path in moka_backend. The cap only ever raises `effective_available`, so real memory pressure (a low snapshot at refresh) still suppresses fills; when the cache is at capacity the headroom is 0 and the deduction vanishes, correctly reflecting net-zero churn. `MokaBackend` now stores `max_capacity` and passes the live headroom into `allows_fill`. Adds targeted gate tests: gross churn far above headroom no longer falsely suppresses, yet the reservation still bounds a burst while the cache can genuinely grow. Refs rustfs/backlog#1212 Co-Authored-By: heihutu <heihutu@gmail.com> * test(ecstore): assert native O_DIRECT path runs in uring read test uring_preserves_o_direct_for_eligible_reads only compared bytes through LocalDisk::read_file_mmap_copy. On a filesystem that rejects O_DIRECT the read silently degrades to the buffered StdBackend fallback and the byte check still passes, so the test could go green without the native read_at_direct path ever executing -- a vacuous pass. Add a per-disk native_direct_reads counter on UringBackend, incremented only when pread_uring_direct completes, and rebuild the test to drive a real UringBackend's pread_bytes and assert the counter is non-zero (every eligible read went through the native tier). When io_uring or O_DIRECT is unavailable on the host filesystem (restricted CI runners, tmpfs), the test skips loudly via eprintln instead of asserting a tautology, while still checking byte-correctness on whatever tier served the read. The counter also gives a gray release a positive signal that the O_DIRECT tier is serving reads, not just a fallback count. Refs rustfs/backlog#1213 Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): warn + count read-time EINVAL on native O_DIRECT reads classify_direct_read_error is only reached from the read side: the O_DIRECT open in pread_uring_direct already succeeded (an open-time refusal is handled earlier as DirectOpenError::ODirectRefused). So an EINVAL/EOPNOTSUPP arriving here is a read-time error on an fd the kernel accepted for O_DIRECT -- far more likely an alignment bug in the aligned read path than an unsupported filesystem. The old code latched the disk's native path off with only a once-per-disk debug trace, hiding a potential correctness regression behind a silent buffered-read downgrade. Diagnostics only: the fallback behaviour is unchanged (the native path is still latched off and the caller still reads via StdBackend). This adds a rustfs_io_uring_direct_read_einval_total counter and promotes the once-per-disk trace from debug to warn so an operator can see an alignment regression instead of an unexplained latency/CPU shift. Refs rustfs/backlog#1214 Co-Authored-By: heihutu <heihutu@gmail.com> * docs(ecstore): document data-blocks-first default and its tail-latency cost DEFAULT_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP is true and must stay true: deferred-parity is the deliberate, already-rolled-out full-object GET default from backlog#1159/#923. Flipping it back to false in code would silently revert that rollout for every deployment that has not set the env var, so this commit only documents -- no behaviour change. The added notes explain what data-blocks-first does (schedule data shards up front, engage parity lazily on a missing/corrupt data shard), the known trade-off (parity is engaged late, so a slow-but-not-dead data drive raises GET p99 because the faster parity shards are not raced against it until a data shard is declared missing), and the operational rollback switch (RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP=false), which is intentionally an env override rather than a code default change. No metric was added: the low-risk observability hook for "slow data drive engaged deferred parity" would live at the deferred-stripe engage point, which is out of this file's scope; this change stays documentation-only to avoid touching the hot GET path. Refs rustfs/backlog#1215 Co-Authored-By: heihutu <heihutu@gmail.com> * docs(ecstore): document wide-directory walk stall hazard and tuning list_dir enumerates a whole directory in one os::read_dir call (count = -1), and the walk caller bounds that entire enumeration with the per-read stall budget (default 5s) as if it were a single read. For a wide, flat prefix -- one directory holding millions of immediate children -- a single readdir can exceed the budget on a healthy disk, trip DiskError::Timeout, and surface as a ListObjects 500 quorum failure though the drive is fine (a #2999 sub-class). This is documented, not rewritten: turning the one-shot readdir into a streaming/batched enumeration that refreshes the stall deadline between chunks is an architecture-level change with high regression surface (ordering, the count contract, quorum merge) and belongs in a separate follow-up. The supported mitigation today is operational, so the comments point wide-directory deployments at RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS and the high-latency drive-timeout profile, which widen the budget with no code change. Notes were added at list_dir, the scan_dir call site, and get_drive_walkdir_stall_timeout. No behaviour change. Refs rustfs/backlog#1216 Co-Authored-By: heihutu <heihutu@gmail.com> * docs(ecstore): document consumer-peek vs producer-stall coupling In list_path_raw the consumer's peek_timeout is drawn from the same source and same value (walkdir_stall_timeout, default 5s) as the producer-side walk stall budget, but the two measure different things: the producer stall bounds a single drive read, while the consumer peek bounds the gap between two ADJACENT entries arriving from a reader. Because they share a value, the consumer cannot wait meaningfully longer for the next entry than the producer is allowed to spend producing one. Walking a region dense with non-listable internal items can make a HEALTHY drive miss the budget between visible entries; the consumer then declares it stalled and detaches it, dropping a good drive from the merge and capping the "large prefix succeeds" guarantee. Documented, not decoupled: giving the consumer peek an independent, strictly-larger budget would cut these false detaches but equally delays detaching a genuinely dead drive and shifts listing tail-latency semantics, so it wants soak data before changing the default. The comment records the invariant any such follow-up must keep -- consumer peek >= producer stall, never stricter -- so it can never fail a drive before the producer would. No behaviour change. Refs rustfs/backlog#1217 Co-Authored-By: heihutu <heihutu@gmail.com> * fix(io-metrics): add time-based trigger for low-IOPS latency percentiles Percentiles were recomputed only every 128 IOs and seeded to 0, so a low-traffic deployment exported p95/p99 = 0/stale for a long time after startup. Add a 10s wall-clock trigger alongside the count throttle so the first recompute can fire before 128 samples accrue. Hot-path per-op mean update is unchanged. Refs rustfs/backlog#1218 Co-Authored-By: heihutu <heihutu@gmail.com> * test(e2e): cover codec-streaming parity under fault injection and NoSuchKey The codec-streaming compat A/B previously ran only against a healthy 4-disk EC set with successful full GETs: the DiskFaultHarness was constructed but never faulted, the error path was untested, and the range assertion silently compared legacy-vs-legacy (ranges always fall back to the duplex path), overstating what it proved. Add two genuinely-failable scenarios reusing the existing harness and fixtures: - Parity reconstruction A/B: take one data disk offline and re-run the full object matrix on both phases while the EC 2+2 set rebuilds each large object from the surviving shards. Assert codec == legacy byte-for-byte (sha256) and header-for-header, and assert the codec phase served the reconstructed objects with zero duplex-pipe fallback (the reader gate is drive-health-independent, so the codec fast path is really exercised through reconstruction). - NoSuchKey negative path: compare the HTTP status + S3 error code of a missing-key GET across the legacy and codec phases and require them to be identical (404/NoSuchKey), guarding against the codec env perturbing the error path. Also clarify the range-phase comment so it is not misread as codec-range correctness coverage: both sides are served by the same legacy range path, so the assertion only proves ranges keep working and keep falling back to legacy with the gates open. Verified: cargo check/--no-run pass and the test passes locally (1 passed; dup_codec=0 confirms the codec path ran). Refs rustfs/backlog#1219 Co-Authored-By: heihutu <heihutu@gmail.com> * ci(ecstore): exercise native O_DIRECT read path on an ext4 loopback The uring-integration leg ran on the runner's default TMPDIR, which may sit on tmpfs/overlayfs where open(O_DIRECT) fails and the native read_at_direct path silently latches off to the aligned StdBackend fallback. Mount a dedicated ext4 loopback and point TMPDIR at it so the real io_uring dep (bumped git->0.1.0->0.2.0->0.2.1) and the native O_DIRECT read path are actually covered rather than validated only by signature diffing. Refs rustfs/backlog#1220 Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com>
rustfs-obs
Observability library for RustFS providing structured JSON logging, distributed tracing, metrics via OpenTelemetry, and continuous profiling via Pyroscope.
Features
| Feature | Description |
|---|---|
| Structured logging | JSON-formatted logs via tracing-subscriber |
| Rolling-file logging | Daily / hourly rotation with automatic cleanup and high-precision timestamps |
| Distributed tracing | OTLP/HTTP export to Jaeger, Tempo, or any OTel collector |
| Metrics | OTLP/HTTP export, bridged from the metrics crate facade |
| Continuous Profiling | CPU/Memory profiling export to Pyroscope |
| Log cleanup | Background task: size limits, zstd/gzip compression, retention policies |
| GPU metrics (optional) | Enable with the gpu feature flag |
Quick Start
# Cargo.toml
[dependencies]
rustfs-obs = { version = "0.0.5" }
# GPU metrics support
rustfs-obs = { version = "0.0.5", features = ["gpu"] }
use rustfs_obs::init_obs;
#[tokio::main]
async fn main() {
// Build config from environment variables, then initialise all backends.
let _guard = init_obs(None).await.expect("failed to initialise observability");
tracing::info!("RustFS started");
// _guard is dropped here — all providers are flushed and shut down.
}
Keep
_guardalive for the lifetime of your application. Dropping it triggers an ordered shutdown of every OpenTelemetry provider.
Initialisation
With an explicit OTLP endpoint
use rustfs_obs::init_obs;
let _guard = init_obs(Some("http://otel-collector:4318".to_string()))
.await
.expect("observability init failed");
With a custom config struct
use rustfs_obs::{AppConfig, OtelConfig, init_obs_with_config};
let config = AppConfig::new_with_endpoint(Some("http://localhost:4318".to_string()));
let _guard = init_obs_with_config( & config.observability)
.await
.expect("observability init failed");
Routing Logic
The library selects a backend automatically based on configuration:
1. Any OTLP endpoint set?
└─ YES → Full OTLP/HTTP pipeline (traces + metrics + logs)
+ Profiling (Pyroscope) only if:
- RUSTFS_OBS_PROFILING_EXPORT_ENABLED=true (explicit opt-in, default: false)
2. RUSTFS_OBS_LOG_DIRECTORY set to a non-empty path?
└─ YES → Rolling-file JSON logging
+ Stdout mirror enabled if:
- RUSTFS_OBS_LOG_STDOUT_ENABLED=true (explicit), OR
- RUSTFS_OBS_ENVIRONMENT != "production" (automatic)
3. Default → Stdout-only JSON logging (all signals)
Key Points:
- When no log directory is configured, logs automatically go to stdout only (perfect for development)
- When a log directory is set, logs go to rolling files in that directory
- In non-production environments, stdout is automatically mirrored alongside file logging for visibility
- In production mode, you must explicitly set
RUSTFS_OBS_LOG_STDOUT_ENABLED=trueto see stdout in addition to files
Environment Variables
All configuration is read from environment variables at startup.
OTLP / Export
| Variable | Default | Description |
|---|---|---|
RUSTFS_OBS_ENDPOINT |
(empty) | Root OTLP/HTTP endpoint, e.g. http://otel-collector:4318 |
RUSTFS_OBS_TRACE_ENDPOINT |
(empty) | Dedicated trace endpoint (overrides root + /v1/traces) |
RUSTFS_OBS_METRIC_ENDPOINT |
(empty) | Dedicated metrics endpoint |
RUSTFS_OBS_LOG_ENDPOINT |
(empty) | Dedicated log endpoint |
RUSTFS_OBS_PROFILING_ENDPOINT |
(empty) | Dedicated profiling endpoint (e.g. Pyroscope) |
RUSTFS_OBS_TRACES_EXPORT_ENABLED |
true |
Toggle trace export |
RUSTFS_OBS_METRICS_EXPORT_ENABLED |
true |
Toggle metrics export |
RUSTFS_OBS_LOGS_EXPORT_ENABLED |
true |
Toggle OTLP log export |
RUSTFS_OBS_PROFILING_EXPORT_ENABLED |
false |
Toggle profiling export |
RUSTFS_OBS_USE_STDOUT |
false |
Mirror all signals to stdout alongside OTLP |
RUSTFS_OBS_SAMPLE_RATIO |
0.1 |
Trace sampling ratio 0.0–1.0 |
RUSTFS_OBS_METER_INTERVAL |
15 |
Metrics export interval (seconds) |
Service identity
| Variable | Default | Description |
|---|---|---|
RUSTFS_OBS_SERVICE_NAME |
rustfs |
OTel service.name |
RUSTFS_OBS_SERVICE_VERSION |
(crate version) | OTel service.version |
RUSTFS_OBS_ENVIRONMENT |
development |
Deployment environment (production, development, …) |
Local logging
| Variable | Default | Description |
|---|---|---|
RUSTFS_OBS_LOGGER_LEVEL |
info |
Log level; RUST_LOG syntax supported |
RUSTFS_OBS_LOG_STDOUT_ENABLED |
false |
When file logging is active, also mirror to stdout |
RUSTFS_OBS_LOG_DIRECTORY |
(empty) | Directory for rolling log files. When empty, logs go to stdout only |
RUSTFS_OBS_LOG_FILENAME |
rustfs.log |
Base filename for rolling logs. Rotated archives include a high-precision timestamp and counter. With the default RUSTFS_OBS_LOG_MATCH_MODE=suffix, names look like <timestamp>-<counter>.rustfs.log (e.g., 20231027103001.123456-0.rustfs.log); with prefix, they look like rustfs.log.<timestamp>-<counter> (e.g., rustfs.log.20231027103001.123456-0). |
RUSTFS_OBS_LOG_ROTATION_TIME |
hourly |
Rotation granularity: minutely, hourly, or daily |
RUSTFS_OBS_LOG_KEEP_FILES |
30 |
Number of rolling files to keep (also used by cleaner) |
RUSTFS_OBS_LOG_MATCH_MODE |
suffix |
File matching mode: prefix or suffix |
Log cleanup
| Variable | Default | Description |
|---|---|---|
RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES |
2147483648 |
Hard cap on total log directory size (2 GiB) |
RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES |
0 |
Per-file size cap; 0 = unlimited |
RUSTFS_OBS_LOG_COMPRESS_OLD_FILES |
true |
Compress files before deleting |
RUSTFS_OBS_LOG_GZIP_COMPRESSION_LEVEL |
6 |
Gzip level 1 (fastest) – 9 (best) |
RUSTFS_OBS_LOG_COMPRESSION_ALGORITHM |
zstd |
Compression codec: zstd or gzip |
RUSTFS_OBS_LOG_PARALLEL_COMPRESS |
true |
Enable work-stealing parallel compression |
RUSTFS_OBS_LOG_PARALLEL_WORKERS |
6 |
Number of cleaner worker threads |
RUSTFS_OBS_LOG_ZSTD_COMPRESSION_LEVEL |
8 |
Zstd level 1 (fastest) – 21 (best ratio) |
RUSTFS_OBS_LOG_ZSTD_FALLBACK_TO_GZIP |
true |
Fallback to gzip when zstd compression fails |
RUSTFS_OBS_LOG_ZSTD_WORKERS |
1 |
zstdmt worker threads per compression task |
RUSTFS_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS |
30 |
Delete .gz / .zst archives older than N days; 0 = keep forever |
RUSTFS_OBS_LOG_EXCLUDE_PATTERNS |
(empty) | Comma-separated glob patterns to never clean up |
RUSTFS_OBS_LOG_DELETE_EMPTY_FILES |
true |
Remove zero-byte files |
RUSTFS_OBS_LOG_MIN_FILE_AGE_SECONDS |
3600 |
Minimum file age (seconds) before cleanup |
RUSTFS_OBS_LOG_CLEANUP_INTERVAL_SECONDS |
1800 |
How often the cleanup task runs (0.5 hours) |
RUSTFS_OBS_LOG_DRY_RUN |
false |
Report deletions without actually removing files |
Cleaner & Rotation Metrics
The log rotation and cleanup pipeline emits these metrics (via the metrics facade):
| Metric | Type | Description |
|---|---|---|
rustfs_log_cleaner_deleted_files_total |
counter | Number of files deleted per cleanup pass |
rustfs_log_cleaner_freed_bytes_total |
counter | Bytes reclaimed by deletion |
rustfs_log_cleaner_compress_duration_seconds |
histogram | Compression stage duration |
rustfs_log_cleaner_steal_success_rate |
gauge | Work-stealing success ratio in parallel mode |
rustfs_log_cleaner_runs_total |
counter | Successful cleanup loop runs |
rustfs_log_cleaner_run_failures_total |
counter | Failed or panicked cleanup loop runs |
rustfs_log_cleaner_rotation_total |
counter | Successful file rotations |
rustfs_log_cleaner_rotation_failures_total |
counter | Failed file rotations |
rustfs_log_cleaner_rotation_duration_seconds |
histogram | Rotation latency |
rustfs_log_cleaner_active_file_size_bytes |
gauge | Current active log file size |
These metrics cover compression, cleanup, and file rotation end-to-end.
Metric Semantics
deleted_files_totalandfreed_bytes_totalare emitted after each cleanup pass and include both normal log cleanup and expired compressed archive cleanup.compress_duration_secondsmeasures compression stage wall-clock time for both serial and parallel modes.steal_success_rateis updated by the parallel work-stealing path and remains at the last computed value.rotation_*metrics are emitted byRollingAppenderand include retries; a failed final rotation incrementsrotation_failures_total.active_file_size_bytesis sampled on writes and after successful roll, so dashboards can track current active file growth.
Grafana Dashboard JSON Draft (Ready to Import)
Save this as
rustfs-log-cleaner-dashboard.json, then import from Grafana UI. The canonical metric names use underscore notation, for examplerustfs_log_cleaner_deleted_files_total.The same panels are now checked in at:
.docker/observability/grafana/dashboards/rustfs.json(row title:Log Cleaner).
{
"uid": "rustfs-log-cleaner",
"title": "RustFS Log Cleaner",
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "10s",
"tags": ["rustfs", "observability", "log-cleaner"],
"time": {
"from": "now-6h",
"to": "now"
},
"panels": [
{
"id": 1,
"title": "Cleanup Runs / Failures",
"type": "timeseries",
"targets": [
{ "refId": "A", "expr": "sum(rate(rustfs_log_cleaner_runs_total[5m]))", "legendFormat": "runs/s" },
{ "refId": "B", "expr": "sum(rate(rustfs_log_cleaner_run_failures_total[5m]))", "legendFormat": "failures/s" }
],
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }
},
{
"id": 2,
"title": "Freed Bytes / Deleted Files",
"type": "timeseries",
"targets": [
{ "refId": "A", "expr": "sum(rate(rustfs_log_cleaner_freed_bytes_total[15m]))", "legendFormat": "bytes/s" },
{ "refId": "B", "expr": "sum(rate(rustfs_log_cleaner_deleted_files_total[15m]))", "legendFormat": "files/s" }
],
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }
},
{
"id": 3,
"title": "Compression P95 Latency",
"type": "timeseries",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(rustfs_log_cleaner_compress_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
}
],
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }
},
{
"id": 4,
"title": "Rotation Success / Failure",
"type": "timeseries",
"targets": [
{ "refId": "A", "expr": "sum(rate(rustfs_log_cleaner_rotation_total[5m]))", "legendFormat": "rotation/s" },
{ "refId": "B", "expr": "sum(rate(rustfs_log_cleaner_rotation_failures_total[5m]))", "legendFormat": "rotation_failures/s" }
],
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }
},
{
"id": 5,
"title": "Steal Success Rate",
"type": "timeseries",
"targets": [
{ "refId": "A", "expr": "max(rustfs_log_cleaner_steal_success_rate)", "legendFormat": "ratio" }
],
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }
},
{
"id": 6,
"title": "Active File Size",
"type": "timeseries",
"targets": [
{ "refId": "A", "expr": "max(rustfs_log_cleaner_active_file_size_bytes)", "legendFormat": "bytes" }
],
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }
}
]
}
PromQL Templates
Use these templates directly in Grafana panels/alerts.
- Cleanup run rate
sum(rate(rustfs_log_cleaner_runs_total[$__rate_interval]))
- Cleanup failure rate
sum(rate(rustfs_log_cleaner_run_failures_total[$__rate_interval]))
- Cleanup failure ratio
sum(rate(rustfs_log_cleaner_run_failures_total[$__rate_interval])) / clamp_min(sum(rate(rustfs_log_cleaner_runs_total[$__rate_interval])), 1e-9)
- Freed bytes throughput
sum(rate(rustfs_log_cleaner_freed_bytes_total[$__rate_interval]))
- Deleted files throughput
sum(rate(rustfs_log_cleaner_deleted_files_total[$__rate_interval]))
- Compression p95 latency
histogram_quantile(0.95, sum(rate(rustfs_log_cleaner_compress_duration_seconds_bucket[$__rate_interval])) by (le))
- Rotation failure ratio
sum(rate(rustfs_log_cleaner_rotation_failures_total[$__rate_interval])) / clamp_min(sum(rate(rustfs_log_cleaner_rotation_total[$__rate_interval])), 1e-9)
- Work-stealing efficiency (latest)
max(rustfs_log_cleaner_steal_success_rate)
- Active file size (latest)
max(rustfs_log_cleaner_active_file_size_bytes)
Suggested Alerts
- CleanupFailureRatioHigh: failure ratio > 0.05 for 10m.
- CompressionLatencyP95High: p95 above your baseline SLO for 15m.
- RotationFailuresDetected: rotation failure rate > 0 for 3 consecutive windows.
- NoCleanupActivity: runs rate == 0 for expected active environments.
Metrics Compatibility
The project is currently in active development. Metric names and labels are updated directly when architecture evolves, and no backward-compatibility shim is maintained for old names. Use the metric names documented in this README as the current source of truth.
Examples
Stdout-only (development default)
# No RUSTFS_OBS_LOG_DIRECTORY set → stdout JSON
RUSTFS_OBS_LOGGER_LEVEL=debug ./rustfs
Rolling-file logging
export RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs
export RUSTFS_OBS_LOGGER_LEVEL=info
export RUSTFS_OBS_LOG_KEEP_FILES=30
export RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES=5368709120 # 5 GiB
./rustfs
Full OTLP pipeline (production)
export RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
export RUSTFS_OBS_ENVIRONMENT=production
export RUSTFS_OBS_SAMPLE_RATIO=0.05 # 5% trace sampling
export RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs
export RUSTFS_OBS_LOG_STDOUT_ENABLED=false
./rustfs
Separate per-signal endpoints
export RUSTFS_OBS_TRACE_ENDPOINT=http://tempo:4318/v1/traces
export RUSTFS_OBS_METRIC_ENDPOINT=http://prometheus-otel:4318/v1/metrics
export RUSTFS_OBS_LOG_ENDPOINT=http://loki-otel:4318/v1/logs
./rustfs
Dry-run cleanup audit
export RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs
export RUSTFS_OBS_LOG_DRY_RUN=true
./rustfs
# Observe log output — no files will actually be deleted.
Parallel zstd cleanup (recommended production profile)
export RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs
export RUSTFS_OBS_LOG_COMPRESSION_ALGORITHM=zstd
export RUSTFS_OBS_LOG_PARALLEL_COMPRESS=true
export RUSTFS_OBS_LOG_PARALLEL_WORKERS=6
export RUSTFS_OBS_LOG_ZSTD_COMPRESSION_LEVEL=8
export RUSTFS_OBS_LOG_ZSTD_FALLBACK_TO_GZIP=true
export RUSTFS_OBS_LOG_ZSTD_WORKERS=1
./rustfs
Module Structure
rustfs-obs/src/
├── lib.rs # Crate root; public re-exports
├── config.rs # OtelConfig + AppConfig; env-var loading
├── error.rs # TelemetryError type
├── global.rs # init_obs / init_obs_with_config entry points
│
├── telemetry/ # Backend initialisation
│ ├── mod.rs # init_telemetry routing logic
│ ├── guard.rs # OtelGuard RAII lifecycle manager
│ ├── filter.rs # EnvFilter construction helpers
│ ├── resource.rs # OTel Resource builder
│ ├── local.rs # Stdout-only and rolling-file backends
│ ├── otel.rs # Full OTLP/HTTP pipeline
│ └── recorder.rs # metrics-crate → OTel bridge (Recorder)
│
├── cleaner/ # Background log-file cleanup subsystem
│ ├── mod.rs # LogCleaner public API + tests
│ ├── types.rs # Shared cleaner types (match mode, compression codec, FileInfo)
│ ├── scanner.rs # Filesystem discovery
│ ├── compress.rs # Gzip/Zstd compression helper
│ └── core.rs # Selection, compression, deletion logic
│
└── system/ # Host metrics (CPU, memory, disk, GPU)
├── mod.rs
├── attributes.rs
├── collector.rs
├── metrics.rs
└── gpu.rs # GPU metrics (feature = "gpu")
Using LogCleaner Directly
use std::path::PathBuf;
use rustfs_obs::LogCleaner;
use rustfs_obs::types::FileMatchMode;
let cleaner = LogCleaner::builder(
PathBuf::from("/var/log/rustfs"),
"rustfs.log.".to_string(), // file_pattern
"rustfs.log".to_string(), // active_filename
)
.match_mode(FileMatchMode::Prefix)
.keep_files(10)
.max_total_size_bytes(2 * 1024 * 1024 * 1024) // 2 GiB
.max_single_file_size_bytes(0) // unlimited
.compress_old_files(true)
.gzip_compression_level(6)
.compressed_file_retention_days(7)
.exclude_patterns(vec!["current.log".to_string()])
.delete_empty_files(true)
.min_file_age_seconds(3600) // 1 hour
.dry_run(false)
.build();
let (deleted, freed_bytes) = cleaner.cleanup().expect("cleanup failed");
println!("Deleted {deleted} files, freed {freed_bytes} bytes");
Feature Flags
| Flag | Description |
|---|---|
| (default) | Core logging, tracing, and metrics |
gpu |
GPU utilisation metrics via nvml |
full |
All features enabled |
# Enable GPU monitoring
rustfs-obs = { version = "0.0.5", features = ["gpu"] }
# Enable everything
rustfs-obs = { version = "0.0.5", features = ["full"] }
License
Apache 2.0 — see LICENSE.