From ae5680ea174754b550c1db1385780c2941e44c44 Mon Sep 17 00:00:00 2001 From: dweg0 Date: Fri, 17 Jul 2026 23:02:51 -0300 Subject: [PATCH 1/4] feat: create the first version of logtap cli --- Cargo.lock | 127 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- src/main.rs | 27 ++++++++++ tests/cli_test.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 src/main.rs create mode 100644 tests/cli_test.rs diff --git a/Cargo.lock b/Cargo.lock index c68720e..a9ae534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -63,6 +113,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "core-foundation" version = "0.9.4" @@ -245,6 +341,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "0.2.12" @@ -456,6 +558,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -525,6 +633,7 @@ name = "logtap" version = "0.1.0" dependencies = [ "anyhow", + "clap", "notify", "regex", "reqwest", @@ -594,6 +703,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -935,6 +1050,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -1145,6 +1266,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 43bf336..ae17237 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,5 @@ serde_json = "1" regex = "1" reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } anyhow = "1" -toml = "1.1.3+spec-1.1.0" \ No newline at end of file +toml = "1.1.3+spec-1.1.0" +clap = { version = "4.6.2", features = ["derive"] } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..20374d6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,27 @@ +use clap::Parser; +use logtap::Config; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(version, about = "Logtap CLI")] +struct Args { + #[arg(short, long, value_name = "logtap.toml")] + config_path: String, +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + let path = PathBuf::from(&args.config_path); + + if !path.exists() { + eprintln!("Config file not found: {:?}", args.config_path); + return; + } + + let config = Config::load(&args.config_path).expect("failed to load config file"); + + println!("Loaded config: {:?}", config); + + logtap::run(config).await.expect("logtap failed to run"); +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..cc972e3 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,130 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +async fn read_http_request_body(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 1024]; + + loop { + let read = stream.read(&mut temp).await.unwrap(); + assert!(read > 0, "connection closed before headers were read"); + + buffer.extend_from_slice(&temp[..read]); + + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let headers_end = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + + let headers = String::from_utf8_lossy(&buffer[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::().unwrap()) + }) + .unwrap(); + + while buffer.len() < headers_end + content_length { + let read = stream.read(&mut temp).await.unwrap(); + assert!(read > 0, "connection closed before body was read"); + + buffer.extend_from_slice(&temp[..read]); + } + + String::from_utf8(buffer[headers_end..headers_end + content_length].to_vec()).unwrap() +} + +// This spawns the actual compiled `logtap` binary as a subprocess — unlike +// the other integration tests, which call `logtap::run` directly, this is +// the only test that goes through `main.rs` itself (arg parsing, config +// path handling), so it's the only one that would catch the CLI wiring +// breaking even if every library-level test still passed. +#[tokio::test] +async fn cli_reads_config_file_and_delivers_a_log_line() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + + let body = read_http_request_body(&mut stream).await; + + stream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + .await + .unwrap(); + + body + }); + + let source_path = PathBuf::from("cli_test.log"); + fs::write(&source_path, "").unwrap(); + + let config_path = PathBuf::from("cli_test.toml"); + let config_toml = format!( + r#" +source_path = "{source}" +sink_url = "http://{address}/logs" +batch_size = 1 +flush_interval_secs = 60 +max_retries = 0 +"#, + source = source_path.display(), + ); + fs::write(&config_path, config_toml).unwrap(); + + let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_logtap")) + .arg("--config-path") + .arg(&config_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to start the logtap binary"); + + // Spawning a whole new OS process (not just a task inside the test + // binary) has more startup overhead than the other integration tests — + // give it real time to parse args, load the config, and register the + // file watcher before writing the line it needs to pick up. + tokio::time::sleep(Duration::from_secs(1)).await; + + { + let mut file = fs::OpenOptions::new() + .append(true) + .open(&source_path) + .unwrap(); + + writeln!(file, r#"{{"level":"info","message":"cli test worked"}}"#).unwrap(); + } + + let body = tokio::time::timeout(Duration::from_secs(3), server) + .await + .expect("timeout: the logtap binary never delivered the log line") + .unwrap(); + + let _ = child.kill().await; + let _ = child.wait().await; + + fs::remove_file(&source_path).ok(); + fs::remove_file(&config_path).ok(); + + let received: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!( + received, + serde_json::json!([{ "level": "info", "message": "cli test worked" }]) + ); +} From b3f2c66bdf8c12de3031367bf0296360b5bfb984 Mon Sep 17 00:00:00 2001 From: dweg0 Date: Fri, 17 Jul 2026 23:16:44 -0300 Subject: [PATCH 2/4] refactor: make error handling more reliable and remove unnecessary code --- src/main.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 20374d6..85d560b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,22 @@ use clap::Parser; use logtap::Config; -use std::path::PathBuf; #[derive(Parser, Debug)] #[command(version, about = "Logtap CLI")] struct Args { - #[arg(short, long, value_name = "logtap.toml")] + #[arg(short, long, value_name = "logtap.toml", default_value = "logtap.toml")] config_path: String, } #[tokio::main] -async fn main() { +async fn main() -> Result<(), String> { let args = Args::parse(); - let path = PathBuf::from(&args.config_path); - if !path.exists() { - eprintln!("Config file not found: {:?}", args.config_path); - return; - } - - let config = Config::load(&args.config_path).expect("failed to load config file"); + let config = Config::load(&args.config_path).map_err(|e| e.to_string())?; println!("Loaded config: {:?}", config); - logtap::run(config).await.expect("logtap failed to run"); + logtap::run(config).await.map_err(|e| e.to_string())?; + + Ok(()) } From b460af126c75bc7353dce6f359d48f6d9bba610f Mon Sep 17 00:00:00 2001 From: dweg0 Date: Fri, 17 Jul 2026 23:30:58 -0300 Subject: [PATCH 3/4] feat: add Dockerfile for containerized deployment Multi-stage build (rust:slim -> debian:slim, non-root user), relying on reqwest's bundled webpki-roots so no ca-certificates package is needed at runtime. Also tunes the release profile (strip, thin LTO) since the resulting binary size now matters for image size. --- .dockerignore | 9 +++++++++ Cargo.toml | 6 +++++- Dockerfile | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c5a33c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target +.git +.github +.idea +.vscode +.claude +tests +*.log +*.jsonl diff --git a/Cargo.toml b/Cargo.toml index ae17237..3569299 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,8 @@ regex = "1" reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } anyhow = "1" toml = "1.1.3+spec-1.1.0" -clap = { version = "4.6.2", features = ["derive"] } \ No newline at end of file +clap = { version = "4.6.2", features = ["derive"] } + +[profile.release] +strip = true +lto = "thin" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b3429d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# --- build stage --- +FROM rust:1-slim-bookworm AS builder + +WORKDIR /app + +# Cache dependency compilation separately from source changes: with only +# Cargo.toml/Cargo.lock copied, `cargo build` here only re-runs when +# dependencies actually change, not on every source edit. +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src \ + && echo "fn main() {}" > src/main.rs \ + && echo "" > src/lib.rs \ + && cargo build --release \ + && rm -rf src + +COPY src ./src +# BuildKit normalizes COPY'd file timestamps, which defeats cargo's +# mtime-based rebuild detection — without this, cargo can decide nothing +# changed and silently keep linking the dummy binary from the step above. +RUN find src -name '*.rs' -exec touch {} + && cargo build --release + +# --- runtime stage --- +FROM debian:bookworm-slim + +RUN useradd --system --create-home --home-dir /app logtap +WORKDIR /app + +COPY --from=builder /app/target/release/logtap /usr/local/bin/logtap + +USER logtap + +# logtap.toml and the source log file are expected to be mounted in at +# runtime (see README) — the binary itself carries no config or state. +ENTRYPOINT ["logtap"] From 64671cfd47a1e0cb6ee32ecdd587ba7a726b9ba3 Mon Sep 17 00:00:00 2001 From: dweg0 Date: Fri, 17 Jul 2026 23:38:20 -0300 Subject: [PATCH 4/4] docs: document CLI usage, Docker, and dead-letter reprocessing README's Getting started still described a library-only workflow with no CLI binary, which is out of date now that main.rs and the Dockerfile exist. Replaces it with the actual build/run steps, exit codes, Docker usage, and the full current config field reference (retry/backoff, mask_common_patterns, dead-letter rotation). Also documents the manual dead-letter replay recipe and checks off the roadmap items that are now actually done. Fills in the previously-empty root logtap.toml with a working example. --- README.md | 98 ++++++++++++++++++++++++++++++++++++----------------- logtap.toml | 11 ++++++ 2 files changed, 78 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1ad3085..b35dab3 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,13 @@ Once there's a second source (`stdin`, alongside the file tail) or a second dest ### 3. Reliability (the system must not silently lose data) -This is the most important axis, and the first one that needs work. Today, if a batch fails to send, it's dropped — no retry, no persistence. That's acceptable for an MVP, but it's the first thing that needs to change before `logtap` is trusted with data that actually matters: +This is the most important axis. Most of it is already in place: - **Retry with exponential backoff** in the sink before giving up on a batch. -- **A local dead-letter file** — batches that fail repeatedly get written to disk (e.g. `logtap.failed.jsonl`) instead of lost, so they can be reprocessed later. -- **Log rotation detection** — when the watched file is renamed and recreated (standard `logrotate` behavior in production), the source needs to notice and start reading the new file, instead of continuing to hold a handle to a file that no longer exists. +- **A local dead-letter file** — batches that exhaust their retries get written to `logtap.failed.jsonl` instead of lost, one record per line, in the same shape `source` already produces so it can be replayed through `logtap` itself (see [Reprocessing the dead-letter file](#reprocessing-the-dead-letter-file)). +- **Size-capped, rotating dead-letter files** — `logtap.failed.jsonl` rotates into `.1`, `.2`, etc. once it crosses `dead_letter_max_bytes`, so a prolonged outage fills disk predictably instead of without limit. Discarding the oldest rotated file is real data loss, so it's always logged loudly, never silently. + +What's still missing: **log rotation detection** — when the watched file is renamed and recreated (standard `logrotate` behavior in production), `source` needs to notice and start reading the new file, instead of continuing to hold a handle to a file that no longer exists. ### 4. Operability (visibility into the pipeline itself) @@ -89,29 +91,39 @@ That discipline, more than any specific feature, is what determines whether the ## Getting started -`logtap` is currently a library crate — there is no CLI binary yet (see [Roadmap](#roadmap)). It's driven through `logtap::run`: +Build the binary and point it at a config file: + +```bash +cargo build --release +./target/release/logtap --config-path logtap.toml +``` + +`--config-path` defaults to `logtap.toml` in the current directory, so with a config file already in place, `./target/release/logtap` on its own is enough. See [`logtap.toml`](logtap.toml) in this repo for a working example. + +### Exit codes -```rust -use logtap::Config; +| Code | Meaning | +|---|---| +| `0` | Clean run. (Today the pipeline runs until killed — this is reserved for a future graceful shutdown.) | +| `1` | An expected, fixable error: missing config file, invalid TOML, a malformed field. | +| `101` | An unexpected internal panic — a bug, not a config problem. | -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cfg = Config { - source_path: "app.log".into(), - sink_url: "http://localhost:8080/logs".to_string(), - batch_size: 100, - flush_interval_secs: 5, - channel_capacity: 1000, - filter_rules: vec![], - }; +### Docker + +```bash +docker build -t logtap . - logtap::run(cfg).await -} +docker run --rm \ + -v $(pwd)/logtap.toml:/app/logtap.toml \ + -v /path/to/your/app.log:/data/app.log \ + logtap ``` +The image ships only the binary — `logtap.toml` and the log file it tails are expected to be mounted in at runtime. `source_path` inside `logtap.toml` should point at wherever the log file lands *inside* the container (`/data/app.log` above), not at its path on the host. + ### Configuration (`logtap.toml`) -`Config` is deserializable from TOML via `serde`: +`Config` is deserializable from TOML via `serde`. Only `source_path` and `sink_url` are required — everything else has a default: ```toml source_path = "app.log" @@ -119,6 +131,12 @@ sink_url = "http://localhost:8080/logs" batch_size = 100 flush_interval_secs = 5 channel_capacity = 1000 +max_retries = 5 +retry_backoff_initial_ms = 500 +retry_backoff_max_secs = 30 +mask_common_patterns = true +dead_letter_max_bytes = 1073741824 +dead_letter_max_files = 5 [[filter_rules]] field = "level" @@ -133,17 +151,35 @@ value = "^[^@]+@[^@]+\\.[^@]+$" action = "mask" ``` -| Field | Type | Description | -|---|---|---| -| `source_path` | path | File to tail. | -| `sink_url` | string | HTTP endpoint the batches are `POST`ed to as a JSON array. | -| `batch_size` | integer | Max records buffered before a flush is triggered. | -| `flush_interval_secs` | integer | Max time between flushes, even if `batch_size` isn't reached. | -| `channel_capacity` | integer | Bound applied to every internal channel — the backpressure knob. | -| `filter_rules` | array | Ordered `FilterRule` entries (`field`, `op`, `value`, `action`); optional, defaults to empty. | +| Field | Type | Default | Description | +|---|---|---|---| +| `source_path` | path | — (required) | File to tail. | +| `sink_url` | string | — (required) | HTTP endpoint the batches are `POST`ed to as a JSON array. | +| `batch_size` | integer | `50` | Max records buffered before a flush is triggered. | +| `flush_interval_secs` | integer | `5` | Max time between flushes, even if `batch_size` isn't reached. | +| `channel_capacity` | integer | `1000` | Bound applied to every internal channel — the backpressure knob. | +| `max_retries` | integer | `5` | Attempts per batch before giving up and writing it to the dead-letter file. | +| `retry_backoff_initial_ms` | integer | `500` | Backoff before the first retry; doubles on each subsequent attempt. | +| `retry_backoff_max_secs` | integer | `30` | Ceiling on the backoff, however many retries have piled up. | +| `mask_common_patterns` | boolean | `true` | Auto-mask emails, card numbers, and `sk-...`-style API keys, independent of `filter_rules`. | +| `dead_letter_max_bytes` | integer | `1073741824` (1 GiB) | Size cap on `logtap.failed.jsonl` before it's rotated into `.1`, `.2`, etc. | +| `dead_letter_max_files` | integer | `5` | How many rotated dead-letter files to keep before the oldest is discarded. | +| `filter_rules` | array | `[]` | Ordered `FilterRule` entries (`field`, `op`, `value`, `action`). | `op` accepts `equals`, `contains`, or `regex`. `action` accepts `drop` or `mask`. +### Reprocessing the dead-letter file + +Batches that exhaust `max_retries` are appended to `logtap.failed.jsonl` instead of being lost — one JSON record per line, in the same shape `source` already produces, so it can be replayed through `logtap` itself rather than needing a separate tool: + +```bash +touch replay.log +./target/release/logtap --config-path replay-logtap.toml & # points source_path at replay.log +cat logtap.failed.jsonl >> replay.log +``` + +`replay-logtap.toml` is a throwaway config pointing `source_path` at the empty `replay.log` and `sink_url` at the real destination. Since `logtap` tails from the end of the file it opens, appending the dead-letter contents into `replay.log` after it's already running makes it pick them up exactly like live traffic. Keep `batch_size` small (or `flush_interval_secs` short) in that config — otherwise the replayed lines just sit buffered until a batch actually fills up or the interval ticks. Once the file stops growing and everything's been delivered, stop the process and delete `logtap.failed.jsonl` and `replay.log`. + ## Development ```bash @@ -160,14 +196,14 @@ CI runs all three on every pull request and on pushes to `main`. v1 is done when all of the following hold: -- [ ] No batch is ever lost silently — every failed send is retried or persisted locally. +- [x] No batch is ever lost silently — every failed send is retried or persisted locally. - [ ] The process survives log rotation without manual intervention. - [ ] No channel grows unbounded under any tested load condition. - [ ] Every config field is validated with a clear error message at startup — never fails silently at runtime. -- [ ] Sensitive data is masked by default, even with no user-configured rules. +- [x] Sensitive data is masked by default, even with no user-configured rules. - [ ] External visibility (metrics) into what the pipeline is doing, without reading logtap's own stderr output. -- [ ] End-to-end integration test covering the full pipeline, plus unit tests per stage. -- [ ] Installing and running the project requires no source reading — README and example config are enough. +- [x] End-to-end integration test covering the full pipeline, plus unit tests per stage. +- [x] Installing and running the project requires no source reading — README and example config are enough. ## License diff --git a/logtap.toml b/logtap.toml index e69de29..b94de59 100644 --- a/logtap.toml +++ b/logtap.toml @@ -0,0 +1,11 @@ +# Minimal working example — see the README's Configuration section for the +# full field reference (defaults, dead-letter rotation, filter rule syntax). + +source_path = "app.log" +sink_url = "http://localhost:8080/logs" + +[[filter_rules]] +field = "level" +op = "equals" +value = "debug" +action = "drop"