From 5d4a5ff0a09a3941644a193952d8317a63da8f71 Mon Sep 17 00:00:00 2001 From: "dzianis.lituyeu" Date: Tue, 16 Jun 2026 11:21:53 +0300 Subject: [PATCH] Add metrics --- .cargo/deny.toml | 60 +++----- .github/workflows/ci.yml | 2 +- .github/workflows/pr-check.yml | 2 +- CHANGELOG.md | 44 ++++-- Cargo.lock | 263 ++++++++++++++++++++++++++++++++- Cargo.toml | 11 +- README.md | 46 +++++- deny.toml | 32 ---- src/cli/args.rs | 5 + src/lib.rs | 1 + src/main.rs | 9 ++ src/metrics/mod.rs | 25 ++++ src/metrics/recorder.rs | 84 +++++++++++ src/metrics/server.rs | 57 +++++++ src/proxy/handler.rs | 20 ++- src/proxy/proxy.rs | 10 +- src/proxy/tls.rs | 6 +- tests/metrics_integration.rs | 215 +++++++++++++++++++++++++++ 18 files changed, 795 insertions(+), 97 deletions(-) delete mode 100644 deny.toml create mode 100644 src/metrics/mod.rs create mode 100644 src/metrics/recorder.rs create mode 100644 src/metrics/server.rs create mode 100644 tests/metrics_integration.rs diff --git a/.cargo/deny.toml b/.cargo/deny.toml index f631159..7df4040 100644 --- a/.cargo/deny.toml +++ b/.cargo/deny.toml @@ -1,49 +1,35 @@ -[graph] -targets = [] -all-features = false -no-default-features = false -exclude-dev = true +# cargo-deny configuration (0.18+ format) +# +# NOTE: cargo-deny does not auto-discover this path — CI invokes it as +# `cargo deny check --config .cargo/deny.toml`. -[output] -feature-depth = 1 +[advisories] +yanked = "deny" + +# rustls-pemfile is archived — replacement is rustls-pki-types PemObject (1.9+). +# Only used in tests/dev-deps; migration tracked separately. +[[advisories.ignore]] +id = "RUSTSEC-2025-0134" [licenses] allow = [ - "Apache-2.0", "MIT", - "Unicode-3.0", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", "ISC", - "BSD-3-Clause" + "Zlib", + "0BSD", + "CC0-1.0", + "Unicode-DFS-2016", + "Unicode-3.0", ] -confidence-threshold = 0.8 -exceptions = [] - -[licenses.private] -ignore = true -registries = [] - [bans] -multiple-versions = "allow" -wildcards = "allow" -highlight = "all" -workspace-default-features = "allow" -external-default-features = "allow" -allow = [] -deny = [] -skip = [] -skip-tree = [] +multiple-versions = "warn" +wildcards = "deny" [sources] unknown-registry = "warn" -unknown-git = "warn" -allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] - -[sources.allow-org] -github = [] -gitlab = [] -bitbucket = [] - -[advisories] -ignore = [] +unknown-git = "deny" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67d7274..a49610e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: uses: taiki-e/install-action@cargo-audit - name: cargo deny check - run: cargo deny check + run: cargo deny check --config .cargo/deny.toml - name: cargo audit run: cargo audit --deny warnings diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 257fbe7..e60042a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -69,4 +69,4 @@ jobs: uses: taiki-e/install-action@cargo-deny - name: Check licenses - run: cargo deny check licenses \ No newline at end of file + run: cargo deny check --config .cargo/deny.toml licenses diff --git a/CHANGELOG.md b/CHANGELOG.md index 54410bf..c06a2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed +### Added -- Hot-reload config storage: `Arc>` → `Arc>` (lock-free reads, `Arc` snapshots instead of full `Config` clones) -- `Proxy::config_snapshot()` is now synchronous and returns `Arc` (was `async fn` returning owned `Config`) -- `Proxy::update_config()` is now synchronous (was `async fn`) +- **Prometheus metrics** (optional `metrics` feature): request counters, latency + histograms, active-request gauge, and TLS handshake counters, exposed on + `/metrics` via a dedicated admin HTTP server. + - `http_requests_total{method,status,site}` — counter + - `http_request_duration_seconds{method,status}` — histogram (11 buckets, 5ms–10s) + - `http_active_requests` — gauge (in-flight requests) + - `tls_handshakes_total{status}` — counter (`ok` / `fail`) + - CLI flag `--metrics-addr` and env var `TINY_PROXY_METRICS_ADDR` +- **Lock-free hot-reload**: config storage switched to `Arc>` + — reads are wait-free, snapshots return `Arc` instead of cloning. + `Proxy::config_snapshot()` and `Proxy::update_config()` are now synchronous. +- Integration tests: config hot-reload on keep-alive connections, Prometheus + `/metrics` endpoint (counters, histogram buckets, gauge, metadata). +- Dependencies: `arc-swap`, `metrics`, `metrics-exporter-prometheus` (optional) -### Fixed +### Changed -- Hot-reload now applies on every HTTP request, including subsequent requests on keep-alive connections -- Hot-reload on TLS listeners started via `start_with_addr` / `start_tls` now picks up routing changes without restart +- `hyper-rustls` is now a **core dependency** (was optional under `tls`). + HTTPS **backend** connections are always available; the `tls` feature now + controls only frontend TLS termination (rustls / tokio-rustls / rustls-pemfile). +- `cargo-deny` configuration moved from `deny.toml` to `.cargo/deny.toml`; + CI now invokes `cargo deny check --config .cargo/deny.toml`. -### Added +### Fixed -- Dependency: `arc-swap` -- Integration test: config hot-reload over a single keep-alive connection +- Hot-reload now applies on every HTTP request, including subsequent requests on + keep-alive connections (previously only the first request on a connection saw + config updates). +- Hot-reload on TLS listeners started via `start_with_addr` / `start_tls` now + picks up routing changes without a restart. +- `cargo build --no-default-features` no longer fails: `hyper-util` enables the + `client-legacy` feature, and `hyper-rustls` is no longer optional in the core + proxy module. +- Missing `#[cfg(feature = "tls")]` guards in `proxy.rs` for the redirect-port + helper and `HashSet` import. +- `rustdoc` warning: `Full` in a doc comment was parsed as an unclosed + HTML tag; the type names are now wrapped in backticks. ## [0.4.0] - 2026-05-25 diff --git a/Cargo.lock b/Cargo.lock index e74ee66..3d745b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,21 @@ dependencies = [ "itertools", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -384,6 +399,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.31" @@ -438,6 +459,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -532,6 +572,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -601,6 +642,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-terminal" version = "0.4.17" @@ -698,6 +745,53 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mio" version = "1.1.0" @@ -789,12 +883,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -814,6 +923,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.45" @@ -835,6 +959,62 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1082,6 +1262,18 @@ dependencies = [ "libc", ] +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1134,13 +1326,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1196,6 +1408,8 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "metrics", + "metrics-exporter-prometheus", "num_cpus", "rcgen", "rustls", @@ -1203,7 +1417,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", @@ -1258,6 +1472,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -1494,6 +1721,32 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1503,6 +1756,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 961c8ed..20dbeda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ required-features = ["cli"] [dependencies] hyper = { version = "1.3", features = ["http1", "client", "server"] } -hyper-util = { version = "0.1", features = ["client", "server", "http1", "tokio"] } +hyper-util = { version = "0.1", features = ["client", "client-legacy", "server", "http1", "tokio"] } http-body-util = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal", "process", "fs", "io-util"] } anyhow = "1.0" @@ -45,15 +45,17 @@ bytes = "1.0" num_cpus = "1.16" uuid = { version = "1", features = ["v4"] } arc-swap = "1" +hyper-rustls = { version = "0.27" } +metrics = { version = "0.24", optional = true } +metrics-exporter-prometheus = { version = "0.16", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } -hyper-rustls = { version = "0.27", optional = true } tokio-rustls = { version = "0.26", optional = true } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "logging", "std", "tls12"], optional = true } rustls-pemfile = { version = "2", optional = true } tracing = "0.1" -clap = { version = "4.5", features = ["derive"], optional = true } +clap = { version = "4.5", features = ["derive", "env"], optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"], optional = true } http-body = "1.0.1" @@ -89,7 +91,8 @@ harness = false [features] default = ["cli", "tls", "api", "logging"] +metrics = ["dep:metrics", "dep:metrics-exporter-prometheus"] cli = ["dep:clap", "dep:tracing-subscriber"] -tls = ["dep:hyper-rustls", "dep:tokio-rustls", "dep:rustls", "dep:rustls-pemfile"] +tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pemfile"] api = ["dep:serde", "dep:serde_json"] logging = [] diff --git a/README.md b/README.md index b229383..9c6cef9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Lightweight, embeddable HTTP reverse proxy written in Rust with Caddy-like confi - **Direct Responses**: Respond with custom status codes and bodies - **Authentication Module**: Token validation and header substitution - **Management API**: REST API for runtime configuration management (optional feature) +- **Prometheus Metrics**: Request counters, latency histograms, and TLS handshake metrics (optional feature) ## Installation @@ -450,17 +451,21 @@ Use placeholders in header values: ### Default Features - `cli` - Command-line interface support -- `tls` - HTTPS backend support +- `tls` - HTTPS on the frontend (TLS termination, SNI-based multi-domain) - `api` - Management API for runtime configuration +- `logging` - Structured access logs + +> **Note**: HTTPS **backend** connections (`hyper-rustls`) are always available — +> the `tls` feature only controls frontend TLS termination. ### Optional Features ```toml -# Minimal - core proxy only (for embedding in other applications) +# Minimal - core HTTP proxy with HTTPS backend support (for embedding) [dependencies] tiny-proxy = { version = "0.4", default-features = false } -# With HTTPS backend support +# With frontend TLS termination [dependencies] tiny-proxy = { version = "0.4", default-features = false, features = ["tls"] } @@ -468,6 +473,10 @@ tiny-proxy = { version = "0.4", default-features = false, features = ["tls"] } [dependencies] tiny-proxy = { version = "0.4", default-features = false, features = ["tls", "api"] } +# With Prometheus metrics +[dependencies] +tiny-proxy = { version = "0.4", default-features = false, features = ["metrics"] } + # Full standalone (same as default) [dependencies] tiny-proxy = "0.4" @@ -479,12 +488,14 @@ Enable CLI dependencies and `tiny-proxy` binary. #### `tls` (default) -Enable TLS support — both **frontend TLS termination** (HTTPS listeners) and **backend HTTPS** connections: +Enable **frontend TLS termination** (HTTPS listeners) with SNI-based multi-domain support: - Frontend: `rustls` + `tokio-rustls` for HTTPS listeners with SNI-based routing -- Backend: `hyper-rustls` for proxying to HTTPS backends - `rustls-pemfile` for loading PEM certificate chains and private keys +> HTTPS **backend** connections (`hyper-rustls`) are always available, regardless +> of this feature. + #### `api` (default) Management API for runtime configuration: @@ -498,6 +509,29 @@ let config = Arc::new(ArcSwap::from_pointee(Config::from_file("config.conf")?)); api::start_api_server("127.0.0.1:8081", config).await?; ``` +#### `metrics` (optional) + +Prometheus metrics exposed via a separate admin HTTP server on `/metrics`: + +- `http_requests_total{method,status,site}` — counter +- `http_request_duration_seconds{method,status}` — histogram +- `http_active_requests` — gauge (in-flight requests) +- `tls_handshakes_total{status}` — counter (`ok` / `fail`) + +```bash +# CLI flag or TINY_PROXY_METRICS_ADDR env var +cargo run --features metrics -- --config config.conf --metrics-addr 127.0.0.1:9090 +curl http://127.0.0.1:9090/metrics +``` + +Or from library code: + +```rust +use tiny_proxy::metrics; + +metrics::start_metrics_server("127.0.0.1:9090".parse()?)?; +``` + ## API Documentation See the [module documentation](https://docs.rs/tiny-proxy) for detailed API reference. @@ -629,7 +663,7 @@ cargo run --example background - ⏳ WebSocket support - ⏳ Rate limiting - ✅ Structured access log with X-Request-ID (method, path, host, status, duration, bytes_sent) -- ⏳ Metrics and monitoring +- ✅ Prometheus metrics (request counters, latency histograms, TLS handshake counters) ## Contributing diff --git a/deny.toml b/deny.toml deleted file mode 100644 index 973413a..0000000 --- a/deny.toml +++ /dev/null @@ -1,32 +0,0 @@ -# cargo-deny configuration (0.18+ format) - -[advisories] -yanked = "deny" - -# rustls-pemfile is archived — replacement is rustls-pki-types PemObject (1.9+). -# Only used in tests/dev-deps; migration tracked separately. -[[advisories.ignore]] -id = "RUSTSEC-2025-0134" - -[licenses] -allow = [ - "MIT", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "ISC", - "Zlib", - "0BSD", - "CC0-1.0", - "Unicode-DFS-2016", - "Unicode-3.0", -] - -[bans] -multiple-versions = "warn" -wildcards = "deny" - -[sources] -unknown-registry = "warn" -unknown-git = "deny" diff --git a/src/cli/args.rs b/src/cli/args.rs index b50dc02..bac1066 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -25,4 +25,9 @@ pub struct Cli { /// Address for API server to listen on #[arg(long, default_value = "127.0.0.1:8081")] pub api_addr: String, + + /// Address for Prometheus metrics server (requires 'metrics' feature). + /// Can also be set via the `TINY_PROXY_METRICS_ADDR` environment variable. + #[arg(long, env = "TINY_PROXY_METRICS_ADDR")] + pub metrics_addr: Option, } diff --git a/src/lib.rs b/src/lib.rs index f28bf6d..beee077 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ pub mod auth; pub mod cli; pub mod config; pub mod error; +pub mod metrics; pub mod proxy; #[cfg(feature = "api")] diff --git a/src/main.rs b/src/main.rs index 4d00543..8836e88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,9 @@ use tokio::sync::broadcast; use tiny_proxy::start_api_server; use tiny_proxy::Proxy; +#[cfg(feature = "metrics")] +use tiny_proxy::metrics; + #[tokio::main] async fn main() -> Result<(), anyhow::Error> { tracing_subscriber::fmt() @@ -29,6 +32,12 @@ async fn main() -> Result<(), anyhow::Error> { info!("Tiny Proxy Server v{}", env!("CARGO_PKG_VERSION")); info!("Loading config from: {}", cli.config); + #[cfg(feature = "metrics")] + if let Some(ref metrics_addr) = cli.metrics_addr { + let addr: std::net::SocketAddr = metrics_addr.parse()?; + metrics::start_metrics_server(addr)?; + } + let config = Config::from_file(&cli.config)?; #[cfg(feature = "api")] diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs new file mode 100644 index 0000000..769ebcb --- /dev/null +++ b/src/metrics/mod.rs @@ -0,0 +1,25 @@ +//! Prometheus metrics for tiny-proxy. +//! +//! Opt-in via feature flag `metrics`. When disabled, all calls are no-ops. +//! +//! # Metrics exposed +//! +//! | Metric | Type | Labels | +//! |--------|------|--------| +//! | `http_requests_total` | counter | `method`, `status`, `site` | +//! | `http_request_duration_seconds` | histogram | `method`, `status` | +//! | `http_active_requests` | gauge | (none) | +//! | `tls_handshakes_total` | counter | `status` (`ok` / `fail`) | +//! +//! # Usage +//! +//! ```bash +//! cargo run --features metrics -- --config config.conf --metrics-addr 127.0.0.1:9090 +//! curl http://127.0.0.1:9090/metrics +//! ``` + +mod recorder; +mod server; + +pub use recorder::{record_request, tls_handshake, MetricsGuard}; +pub use server::start_metrics_server; diff --git a/src/metrics/recorder.rs b/src/metrics/recorder.rs new file mode 100644 index 0000000..c76ac4a --- /dev/null +++ b/src/metrics/recorder.rs @@ -0,0 +1,84 @@ +//! Request/TLS recording helpers and the in-flight request guard. + +/// Record a completed HTTP request. +#[cfg(feature = "metrics")] +pub fn record_request(method: &str, status: u16, site: &str, duration: std::time::Duration) { + use metrics::{counter, histogram}; + + counter!( + "http_requests_total", + "method" => method.to_owned(), + "status" => status.to_string(), + "site" => site.to_owned(), + ) + .increment(1); + + histogram!( + "http_request_duration_seconds", + "method" => method.to_owned(), + "status" => status.to_string(), + ) + .record(duration.as_secs_f64()); +} + +/// No-op when `metrics` feature is disabled. +#[cfg(not(feature = "metrics"))] +pub fn record_request(_method: &str, _status: u16, _site: &str, _duration: std::time::Duration) {} + +/// RAII guard that tracks one in-flight request. +/// +/// On creation, increments the `http_active_requests` gauge. +/// Call `.record(status)` to record the request counter + latency histogram. +/// On drop, decrements the gauge. +pub struct MetricsGuard { + #[cfg(feature = "metrics")] + method: String, + #[cfg(feature = "metrics")] + site: String, + #[cfg(feature = "metrics")] + start: std::time::Instant, +} + +impl MetricsGuard { + pub fn new(_method: String, _site: String) -> Self { + #[cfg(feature = "metrics")] + { + metrics::gauge!("http_active_requests").increment(1.0); + } + Self { + #[cfg(feature = "metrics")] + method: _method, + #[cfg(feature = "metrics")] + site: _site, + #[cfg(feature = "metrics")] + start: std::time::Instant::now(), + } + } + + /// Record the request counter + duration histogram for this request. + pub fn record(&mut self, status: u16) { + #[cfg(feature = "metrics")] + { + record_request(&self.method, status, &self.site, self.start.elapsed()); + } + #[cfg(not(feature = "metrics"))] + let _ = status; + } +} + +#[cfg(feature = "metrics")] +impl Drop for MetricsGuard { + fn drop(&mut self) { + metrics::gauge!("http_active_requests").decrement(1.0); + } +} + +/// Record a TLS handshake result. +#[cfg(feature = "metrics")] +pub fn tls_handshake(status: &str) { + metrics::counter!("tls_handshakes_total", "status" => status.to_owned()).increment(1); +} + +/// No-op when `metrics` feature is disabled. +#[cfg(not(feature = "metrics"))] +pub fn tls_handshake(_status: &str) {} diff --git a/src/metrics/server.rs b/src/metrics/server.rs new file mode 100644 index 0000000..38e6725 --- /dev/null +++ b/src/metrics/server.rs @@ -0,0 +1,57 @@ +//! Prometheus HTTP server setup. + +use std::net::SocketAddr; + +#[cfg(feature = "metrics")] +use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; + +/// Latency histogram buckets (seconds). Chosen for proxy workloads: +/// sub-millisecond fast paths up to slow upstream timeouts. +#[cfg(feature = "metrics")] +const LATENCY_BUCKETS: &[f64] = &[ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +]; + +/// Start the Prometheus metrics HTTP server on the given address. +/// +/// Installs a global metrics recorder, registers metric descriptions (`# HELP`), +/// configures latency histogram buckets, and spawns an HTTP listener +/// that serves `/metrics` in Prometheus text format. +#[cfg(feature = "metrics")] +pub fn start_metrics_server(addr: SocketAddr) -> anyhow::Result<()> { + use metrics::{describe_counter, describe_gauge, describe_histogram}; + + PrometheusBuilder::new() + .set_buckets_for_metric( + Matcher::Full("http_request_duration_seconds".to_string()), + LATENCY_BUCKETS, + )? + .with_http_listener(addr) + .install()?; + + describe_counter!( + "http_requests_total", + "Total number of HTTP requests processed" + ); + describe_histogram!( + "http_request_duration_seconds", + "HTTP request processing duration in seconds" + ); + describe_gauge!( + "http_active_requests", + "Number of HTTP requests currently in flight" + ); + describe_counter!( + "tls_handshakes_total", + "Total number of TLS handshakes (labelled by outcome)" + ); + + tracing::info!("Metrics server listening on http://{}/metrics", addr); + Ok(()) +} + +/// No-op when `metrics` feature is disabled. +#[cfg(not(feature = "metrics"))] +pub fn start_metrics_server(_addr: SocketAddr) -> anyhow::Result<()> { + Ok(()) +} diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index bffadf4..98da365 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -27,7 +27,7 @@ use crate::proxy::directives::{ handle_strip_prefix, handle_uri_replace, }; -/// Unified response body type - can handle both streaming (Incoming) and buffered (Full) +/// Unified response body type - can handle both streaming (`Incoming`) and buffered (`Full`) /// This allows us to support SSE streaming while maintaining a simple API type ResponseBody = http_body_util::combinators::BoxBody>; @@ -158,6 +158,10 @@ pub async fn proxy( .and_then(|h| h.to_str().ok()) .unwrap_or("localhost"); + #[cfg(feature = "metrics")] + let mut metrics_guard = + crate::metrics::MetricsGuard::new(req.method().to_string(), host.to_string()); + #[cfg(feature = "logging")] let mut log_guard = AccessLogGuard::new( initial_request_id.clone(), @@ -182,6 +186,8 @@ pub async fn proxy( log_guard.set_bytes_sent(_body_len); log_guard.finish(404); } + #[cfg(feature = "metrics")] + metrics_guard.record(404); return Ok(response); } }; @@ -204,6 +210,8 @@ pub async fn proxy( log_guard.set_bytes_sent(_body_len); log_guard.finish(500); } + #[cfg(feature = "metrics")] + metrics_guard.record(500); return Ok(response); } }; @@ -235,6 +243,8 @@ pub async fn proxy( log_guard.set_bytes_sent(0); log_guard.finish(status_code.as_u16()); } + #[cfg(feature = "metrics")] + metrics_guard.record(status_code.as_u16()); Ok(response) } ActionResult::Respond { status, body } => { @@ -253,6 +263,8 @@ pub async fn proxy( log_guard.set_bytes_sent(_body_len); log_guard.finish(status_code.as_u16()); } + #[cfg(feature = "metrics")] + metrics_guard.record(status_code.as_u16()); Ok(response) } ActionResult::ReverseProxy { @@ -335,6 +347,8 @@ pub async fn proxy( let response = builder.header("X-Request-ID", &request_id).body(boxed)?; #[cfg(feature = "logging")] log_guard.finish(status.as_u16()); + #[cfg(feature = "metrics")] + metrics_guard.record(status.as_u16()); Ok(response) } Ok(Err(e)) => { @@ -355,6 +369,8 @@ pub async fn proxy( log_guard.set_bytes_sent(_body_len); log_guard.finish(502); } + #[cfg(feature = "metrics")] + metrics_guard.record(502); Ok(response) } Err(_) => { @@ -373,6 +389,8 @@ pub async fn proxy( log_guard.set_bytes_sent(_body_len); log_guard.finish(504); } + #[cfg(feature = "metrics")] + metrics_guard.record(504); Ok(response) } } diff --git a/src/proxy/proxy.rs b/src/proxy/proxy.rs index e22c57b..d5285d8 100644 --- a/src/proxy/proxy.rs +++ b/src/proxy/proxy.rs @@ -6,7 +6,9 @@ use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::client::legacy::Client; use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioIo; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(feature = "tls")] +use std::collections::HashSet; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -17,7 +19,9 @@ use tracing::{error, info, warn}; #[cfg(feature = "tls")] use crate::proxy::tls::{build_tls_acceptor, listen_http_redirect, listen_tls}; -use crate::config::{extract_hostname, resolve_listen_addr, tls_redirect_port, Config}; +#[cfg(feature = "tls")] +use crate::config::tls_redirect_port; +use crate::config::{extract_hostname, resolve_listen_addr, Config}; use crate::proxy::handler::proxy; /// HTTP Proxy server that can be embedded into other applications @@ -285,6 +289,8 @@ impl Proxy { } let mut http_handles = Vec::new(); + + #[cfg(feature = "tls")] let mut tls_redirects: HashSet<(SocketAddr, u16)> = HashSet::new(); // (redirect bind addr, tls_port) for (listen_addr, sites) in socket_groups { diff --git a/src/proxy/tls.rs b/src/proxy/tls.rs index cd9bc68..0235a03 100644 --- a/src/proxy/tls.rs +++ b/src/proxy/tls.rs @@ -239,8 +239,12 @@ where let _permit = permit; // held until task completes let tls_stream = match acceptor.accept(io.into_inner()).await { - Ok(s) => s, + Ok(s) => { + crate::metrics::tls_handshake("ok"); + s + } Err(e) => { + crate::metrics::tls_handshake("fail"); // Handshake failures are common (wrong SNI, expired cert, etc.) // Don't log at error level to avoid noise info!("TLS handshake failed from {}: {}", remote_addr, e); diff --git a/tests/metrics_integration.rs b/tests/metrics_integration.rs new file mode 100644 index 0000000..ff9695d --- /dev/null +++ b/tests/metrics_integration.rs @@ -0,0 +1,215 @@ +//! Integration tests for the Prometheus `/metrics` endpoint. +//! +//! All checks run in a single test because the Prometheus exporter installs a +//! process-global recorder whose HTTP listener is tied to the tokio runtime +//! that started it. Sharing one runtime keeps the listener alive across checks. + +#![cfg(feature = "metrics")] + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use tiny_proxy::config::{Directive, SiteConfig}; +use tiny_proxy::metrics; +use tiny_proxy::{Config, Proxy}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +fn make_config(host_port: &str) -> Config { + let mut sites = HashMap::new(); + sites.insert( + host_port.to_string(), + SiteConfig { + address: host_port.to_string(), + directives: vec![Directive::Respond { + status: 200, + body: "ok".to_string(), + }], + tls: None, + }, + ); + Config { sites } +} + +async fn get_random_port_addr() -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + addr +} + +/// Send one HTTP/1.1 request, return the full raw response. +async fn http_get_raw(stream: &mut TcpStream, host: &str, path: &str) -> String { + let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut buf = Vec::new(); + let _ = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut buf)).await; + String::from_utf8_lossy(&buf).to_string() +} + +/// GET `/metrics` from the admin port. +async fn scrape_metrics(metrics_port: u16) -> String { + let mut stream = TcpStream::connect(format!("127.0.0.1:{metrics_port}")) + .await + .unwrap(); + http_get_raw( + &mut stream, + &format!("127.0.0.1:{metrics_port}"), + "/metrics", + ) + .await +} + +/// Spawn a proxy on a random port and wait until it accepts connections. +async fn spawn_proxy() -> std::net::SocketAddr { + let addr = get_random_port_addr().await; + let host = format!("127.0.0.1:{}", addr.port()); + let shared = Arc::new(ArcSwap::from_pointee(make_config(&host))); + let proxy = Proxy::from_shared(shared); + tokio::spawn(async move { + let _ = proxy.start(&addr.to_string()).await; + }); + for _ in 0..40 { + if TcpStream::connect(addr).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + addr +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_metrics_endpoint() { + // --- bring up the metrics server on a random port --- + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let metrics_addr = listener.local_addr().unwrap(); + let metrics_port = metrics_addr.port(); + drop(listener); + metrics::start_metrics_server(metrics_addr).expect("install prometheus recorder"); + + // install() spawns the HTTP listener on a background thread; wait for it. + for _ in 0..100 { + if TcpStream::connect(metrics_addr).await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // --- bring up the proxy under test --- + let proxy_addr = spawn_proxy().await; + let proxy_host = format!("127.0.0.1:{}", proxy_addr.port()); + + // --------------------------------------------------------------- + // 1. request counter increments + labels are present + // --------------------------------------------------------------- + let before = scrape_metrics(metrics_port).await; + let before_count = extract_counter_value(&before, "http_requests_total"); + + for _ in 0..3 { + let mut s = TcpStream::connect(proxy_addr).await.unwrap(); + let _ = http_get_raw(&mut s, &proxy_host, "/test").await; + } + tokio::time::sleep(Duration::from_millis(100)).await; + + let after = scrape_metrics(metrics_port).await; + let after_count = extract_counter_value(&after, "http_requests_total"); + assert!( + after_count >= before_count + 3, + "counter should increase by >=3: before={before_count} after={after_count}" + ); + assert!( + after.contains(r#"http_requests_total{method="GET",status="200""#), + "expected labeled counter line in:\n{after}" + ); + + // --------------------------------------------------------------- + // 2. latency histogram (bucket lines, not quantiles) + // --------------------------------------------------------------- + assert!( + after.contains("http_request_duration_seconds_bucket"), + "expected histogram buckets in:\n{after}" + ); + assert!( + !after.contains("http_request_duration_seconds{"), + "metric should not be rendered as a summary (quantile form)" + ); + assert!( + after.contains(r#"le="+Inf""#), + "expected +Inf bucket in:\n{after}" + ); + + // --------------------------------------------------------------- + // 3. active-requests gauge returns to 0 once requests finish + // --------------------------------------------------------------- + let gauge_value = extract_gauge_value(&after, "http_active_requests"); + assert_eq!( + gauge_value, 0.0, + "active requests gauge should be 0 after request completes, got {gauge_value}" + ); + + // --------------------------------------------------------------- + // 4. TLS handshake counter accepts ok/fail labels + // --------------------------------------------------------------- + metrics::tls_handshake("ok"); + metrics::tls_handshake("fail"); + tokio::time::sleep(Duration::from_millis(100)).await; + let body = scrape_metrics(metrics_port).await; + assert!( + body.contains(r#"tls_handshakes_total{status="ok"}"#), + "expected tls_handshakes_total{{status=\"ok\"}} in:\n{body}" + ); + assert!( + body.contains(r#"tls_handshakes_total{status="fail"}"#), + "expected tls_handshakes_total{{status=\"fail\"}} in:\n{body}" + ); + + // --------------------------------------------------------------- + // 5. HELP / TYPE metadata for every metric (must come after each + // metric has at least one recorded sample) + // --------------------------------------------------------------- + for (name, type_str) in [ + ("http_requests_total", "counter"), + ("http_request_duration_seconds", "histogram"), + ("http_active_requests", "gauge"), + ("tls_handshakes_total", "counter"), + ] { + assert!( + body.contains(&format!("# HELP {name} ")), + "missing HELP line for {name} in:\n{body}" + ); + assert!( + body.contains(&format!("# TYPE {name} {type_str}")), + "missing TYPE line for {name} in:\n{body}" + ); + } +} + +/// Sum all sample values of a counter across all label sets. +fn extract_counter_value(metrics: &str, name: &str) -> u64 { + metrics + .lines() + .filter(|l| l.starts_with(name) && l.contains('{')) + .filter_map(|l| l.rsplit(' ').next()) + .filter_map(|v| v.parse::().ok()) + .sum() +} + +/// Parse a label-less gauge (`name 42.0`). +fn extract_gauge_value(metrics: &str, name: &str) -> f64 { + for line in metrics.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(name) { + let rest = rest.trim_start(); + if !rest.starts_with('{') { + if let Some(val) = rest.split_whitespace().next() { + if let Ok(v) = val.parse::() { + return v; + } + } + } + } + } + f64::NAN +}