From 1a69a9336fd0ef47586877c71ff98793a530d28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stian=20Fr=C3=B8ystein?= <26657174+stianfro@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:10:48 +0000 Subject: [PATCH] feat: export OpenTelemetry probe metrics --- CHANGELOG.md | 5 +- Cargo.lock | 315 +++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 +- README.md | 63 +++++++- deny.toml | 5 +- src/lib.rs | 129 +++++++++++++++-- src/telemetry.rs | 363 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 854 insertions(+), 31 deletions(-) create mode 100644 src/telemetry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5edc6a..c50fe52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,13 @@ All notable changes to HTTPing are documented in this file. -## [Unreleased] +## [0.2.0] - 2026-07-28 ### Added - A loopback-only live dashboard with summary cards, a latency chart, and recent probe results. - Configurable dashboard ports, browser opening, and bounded in-memory history. +- Optional OpenTelemetry metrics over OTLP HTTP/protobuf for command-line and dashboard probes. ## [0.1.0] - 2026-07-28 @@ -20,3 +21,5 @@ All notable changes to HTTPing are documented in this file. - Static Linux, macOS, and Windows release archives and installers. [0.1.0]: https://github.com/stianfro/httping/releases/tag/v0.1.0 + +[0.2.0]: https://github.com/stianfro/httping/compare/v0.1.0...v0.2.0 diff --git a/Cargo.lock b/Cargo.lock index 8c6b9e7..8c7611b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -61,6 +67,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -200,7 +223,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -291,6 +314,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -308,6 +340,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "errno" version = "0.3.14" @@ -324,6 +362,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -346,6 +394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -354,6 +403,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "futures-sink" version = "0.3.33" @@ -373,7 +450,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -391,6 +472,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -400,8 +493,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -452,13 +545,16 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "httping" -version = "0.1.0" +version = "0.2.0" dependencies = [ "axum", "chrono", "clap", "humantime", "open", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "reqwest", "serde", "serde_json", @@ -689,6 +785,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -807,6 +912,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -855,6 +970,76 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "flate2", + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -873,6 +1058,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "potential_utf" version = "0.1.5" @@ -882,6 +1073,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -891,6 +1091,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "quinn" version = "0.11.11" @@ -921,7 +1144,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -957,12 +1180,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -971,7 +1210,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[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 0.9.5", +] + +[[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]] @@ -986,7 +1244,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -997,7 +1255,9 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1251,6 +1511,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -1587,6 +1853,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1821,6 +2096,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -1850,6 +2131,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 0603688..f20967b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "httping" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.96.1" authors = ["Stian Frøystein <26657174+stianfro@users.noreply.github.com>"] @@ -22,6 +22,9 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } clap = "4.6.4" humantime = "2.4.0" open = "5.4.0" +opentelemetry = { version = "0.32.0", default-features = false, features = ["metrics"] } +opentelemetry-otlp = { version = "0.32.0", default-features = false, features = ["metrics", "http-proto", "reqwest-rustls", "gzip-http", "reqwest-blocking-client"] } +opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["metrics"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" diff --git a/README.md b/README.md index 0b4ae98..91b3b0e 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ On macOS or Linux: ```sh curl --proto '=https' --tlsv1.2 -LsSf \ - https://github.com/stianfro/httping/releases/download/v0.1.0/httping-installer.sh | sh + https://github.com/stianfro/httping/releases/download/v0.2.0/httping-installer.sh | sh ``` On Windows PowerShell: ```powershell -powershell -ExecutionPolicy Bypass -c "irm https://github.com/stianfro/httping/releases/download/v0.1.0/httping-installer.ps1 | iex" +powershell -ExecutionPolicy Bypass -c "irm https://github.com/stianfro/httping/releases/download/v0.2.0/httping-installer.ps1 | iex" ``` ### Add HTTPing to PATH @@ -57,16 +57,22 @@ after it finishes. ```text Usage: httping [OPTIONS] + httping [OPTIONS] [URL] + +Commands: + serve Show live probe statistics in a local web dashboard Arguments: HTTP or HTTPS URL to probe Options: - -c, --count Number of probes to send. By default, probes continue until Ctrl-C - -i, --interval Minimum time between probe starts [default: 1s] - -t, --timeout Maximum time for DNS setup and for each probe [default: 10s] - -h, --help Print help - -V, --version Print version + -c, --count Number of probes to send. By default, probes continue until Ctrl-C + -i, --interval Minimum time between probe starts [default: 1s] + -t, --timeout Maximum time for DNS setup and for each probe [default: 10s] + --otlp Export OpenTelemetry metrics over OTLP HTTP/protobuf + --target-name Stable target name for exported metrics + -h, --help Print help + -V, --version Print version ``` Durations accept values such as `250ms`, `1s`, and `2m`. @@ -96,6 +102,49 @@ httping serve --no-open --port 8080 --history 500 https://example.com The server stays in the foreground until Ctrl-C. Run it through a shell job, systemd, or a container when it must continue in the background. +### OpenTelemetry metrics + +Metric export is off by default. Enable OTLP HTTP/protobuf export with `--otlp`: + +```sh +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + httping --otlp --target-name public-api https://example.com/health +``` + +You can also enable it with `OTEL_METRICS_EXPORTER=otlp`. Setting only an +endpoint does not enable export. Standard OTLP variables configure the metrics +endpoint, headers, timeout, compression, export interval, temporality, service +name, and resource attributes. HTTPing rejects gRPC and HTTP/JSON protocols. + +HTTPing exports the standard `http.client.request.duration` histogram and the +`httping.probe.attempts`, `httping.probe.responses`, and +`httping.probe.transport_errors` counters. The default target name contains the +scheme, host, effective port, and path. It never contains URL user information +or query values. + +Dashboard and metric export can run together: + +```sh +httping serve --otlp --no-open https://example.com/health +``` + +A Kubernetes container can enable export without changing its arguments: + +```yaml +args: ["--interval", "10s", "https://example.com/health"] +env: + - name: OTEL_METRICS_EXPORTER + value: otlp + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: http://otel-collector:4318 + - name: OTEL_SERVICE_NAME + value: httping-public-api +``` + +Background export errors are written to stderr and do not stop probing. A +finite run exits with status 1 when its final metric flush fails. Ctrl-C still +prints the probe summary and exits with status 0. + ```console $ httping --count 2 https://example.com HTTPing https://example.com/ diff --git a/deny.toml b/deny.toml index 7efcc18..e51d69b 100644 --- a/deny.toml +++ b/deny.toml @@ -18,11 +18,14 @@ ignore = [] multiple-versions = "deny" wildcards = "deny" deny = [] -# Serde 1 uses syn 3 while Tokio and URL's ICU dependencies still use syn 2. +# Serde and OpenTelemetry use syn 3 while Tokio and URL's ICU dependencies use syn 2. # Keep this exact exception until the dependency graph converges. skip = [ { crate = "syn@2" }, { crate = "syn@3" }, + # OpenTelemetry uses rand with getrandom 0.3 while Rustls build tooling uses 0.4. + { crate = "getrandom@0.3" }, + { crate = "getrandom@0.4" }, ] skip-tree = [] diff --git a/src/lib.rs b/src/lib.rs index 9d2582a..da7204d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ -//! Command-line parsing, probing, and dashboard support for `HTTPing`. +//! Command-line parsing, probing, dashboard support, and metrics for `HTTPing`. + +mod telemetry; use std::collections::VecDeque; use std::convert::Infallible; @@ -38,6 +40,12 @@ pub struct Cli { /// HTTP or HTTPS URL to probe. pub url: Url, + + /// Enable OpenTelemetry metrics over OTLP HTTP/protobuf. + pub otlp: bool, + + /// Stable name attached to the target's exported metrics. + pub target_name: Option, } /// `HTTPing` operating mode. @@ -90,6 +98,8 @@ impl Cli { interval: required_value(serve, "interval"), timeout: required_value(serve, "timeout"), url: required_value::(serve, "url").clone(), + otlp: serve.get_flag("otlp"), + target_name: serve.get_one::("target-name").cloned(), }; } @@ -100,6 +110,8 @@ impl Cli { interval: required_value(matches, "interval"), timeout: required_value(matches, "timeout"), url: required_value::(matches, "url").clone(), + otlp: matches.get_flag("otlp"), + target_name: matches.get_one::("target-name").cloned(), } } } @@ -176,6 +188,15 @@ fn common_arguments() -> Vec { .help("Maximum time for DNS setup and for each probe") .default_value("10s") .value_parser(parse_duration), + Arg::new("otlp") + .long("otlp") + .help("Export OpenTelemetry metrics over OTLP HTTP/protobuf") + .action(clap::ArgAction::SetTrue), + Arg::new("target-name") + .long("target-name") + .value_name("NAME") + .help("Stable target name for exported metrics") + .value_parser(parse_target_name), Arg::new("url") .value_name("URL") .help("HTTP or HTTPS URL to probe") @@ -184,6 +205,13 @@ fn common_arguments() -> Vec { ] } +fn parse_target_name(value: &str) -> Result { + if value.trim().is_empty() { + return Err("target name must not be empty".to_owned()); + } + Ok(value.to_owned()) +} + fn parse_positive_usize(value: &str) -> Result { let count = value.parse::().map_err(|error| error.to_string())?; if count == 0 { @@ -516,9 +544,9 @@ fn classify_error(error: &reqwest::Error) -> ErrorKind { /// Run `HTTPing` and return its process exit status. pub async fn run(cli: Cli) -> ExitCode { - let stdout = io::stdout(); - let stderr = io::stderr(); - run_with_writers(cli, &mut stdout.lock(), &mut stderr.lock()).await + let mut stdout = io::stdout(); + let mut stderr = io::stderr(); + run_with_writers(cli, &mut stdout, &mut stderr).await } async fn run_with_writers(cli: Cli, output: &mut impl Write, errors: &mut impl Write) -> ExitCode { @@ -526,6 +554,17 @@ async fn run_with_writers(cli: Cli, output: &mut impl Write, errors: &mut impl W Ok(prepared) => prepared, Err(status) => return status, }; + let mut telemetry = match telemetry::Telemetry::from_environment( + cli.otlp, + cli.target_name.as_deref(), + &cli.url, + ) { + Ok(telemetry) => telemetry, + Err(error) => { + let _ = writeln!(errors, "httping: {error}"); + return ExitCode::FAILURE; + } + }; write_header(&cli, &prepared, output); match cli.mode { @@ -534,12 +573,18 @@ async fn run_with_writers(cli: Cli, output: &mut impl Write, errors: &mut impl W &cli, &prepared, count, - |event| write_event(event, output), + |event| { + write_event(event, output); + if let Some(telemetry) = telemetry.as_ref() { + telemetry.record(event); + } + }, errors, ) .await; let _ = write!(output, "{}", result.statistics.summary()); - if count.is_some() && result.statistics.failed { + let export_failed = finish_telemetry(telemetry.take(), errors); + if count.is_some() && (result.statistics.failed || export_failed) { ExitCode::FAILURE } else { ExitCode::SUCCESS @@ -549,10 +594,35 @@ async fn run_with_writers(cli: Cli, output: &mut impl Write, errors: &mut impl W port, no_open, history, - } => run_dashboard(&cli, &prepared, port, no_open, history, output, errors).await, + } => { + run_dashboard( + &cli, + &prepared, + DashboardOptions { + port, + no_open, + history, + }, + telemetry, + output, + errors, + ) + .await + } } } +fn finish_telemetry(telemetry: Option, errors: &mut impl Write) -> bool { + let Some(telemetry) = telemetry else { + return false; + }; + let export_errors = telemetry.finish(); + for error in &export_errors { + let _ = writeln!(errors, "httping: {error}"); + } + !export_errors.is_empty() +} + #[derive(Clone, Debug, Serialize)] struct DashboardState { target: String, @@ -627,16 +697,21 @@ async fn dashboard_events( Sse::new(stream).keep_alive(KeepAlive::default()) } -async fn run_dashboard( - cli: &Cli, - prepared: &Prepared, +struct DashboardOptions { port: u16, no_open: bool, history: usize, +} + +async fn run_dashboard( + cli: &Cli, + prepared: &Prepared, + options: DashboardOptions, + telemetry: Option, output: &mut impl Write, errors: &mut impl Write, ) -> ExitCode { - let listener = match TcpListener::bind(("127.0.0.1", port)).await { + let listener = match TcpListener::bind(("127.0.0.1", options.port)).await { Ok(listener) => listener, Err(error) => { let _ = writeln!(errors, "httping: could not bind dashboard: {error}"); @@ -650,8 +725,12 @@ async fn run_dashboard( return ExitCode::FAILURE; } }; - let dashboard = Arc::new(RwLock::new(DashboardState::new(cli, prepared, history))); - let (events, _) = broadcast::channel(history.clamp(16, 4_096)); + let dashboard = Arc::new(RwLock::new(DashboardState::new( + cli, + prepared, + options.history, + ))); + let (events, _) = broadcast::channel(options.history.clamp(16, 4_096)); let web_state = WebState { dashboard: Arc::clone(&dashboard), events: events.clone(), @@ -672,10 +751,11 @@ async fn run_dashboard( let url = format!("http://{address}/"); let _ = writeln!(output, "dashboard: {url}"); - if !no_open && open::that_detached(&url).is_err() { + if !options.no_open && open::that_detached(&url).is_err() { let _ = writeln!(errors, "httping: could not open the dashboard in a browser"); } + let mut telemetry = telemetry; let result = probe_loop( cli, prepared, @@ -687,6 +767,9 @@ async fn run_dashboard( .expect("dashboard lock is not poisoned") .record(event); let _ = events.send(event.clone()); + if let Some(telemetry) = telemetry.as_ref() { + telemetry.record(event); + } }, errors, ) @@ -702,12 +785,18 @@ async fn run_dashboard( } } let _ = write!(output, "{}", result.statistics.summary()); + let _ = finish_telemetry(telemetry.take(), errors); let _ = result.interrupted; ExitCode::SUCCESS } fn safe_target_name(url: &Url) -> String { let host = url.host_str().unwrap_or("unknown"); + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_owned() + }; let port = url.port_or_known_default().unwrap_or(0); format!("{}://{host}:{port}{}", url.scheme(), url.path()) } @@ -735,6 +824,8 @@ mod tests { assert_eq!(cli.mode, Mode::Probe { count: None }); assert_eq!(cli.interval, Duration::from_secs(1)); assert_eq!(cli.timeout, Duration::from_secs(10)); + assert!(!cli.otlp); + assert_eq!(cli.target_name, None); let cli = Cli::try_parse_from([ "httping", @@ -744,12 +835,17 @@ mod tests { "250ms", "--timeout", "2m", + "--otlp", + "--target-name", + "public-api", "http://example.com", ]) .unwrap(); assert_eq!(cli.mode, Mode::Probe { count: Some(2) }); assert_eq!(cli.interval, Duration::from_millis(250)); assert_eq!(cli.timeout, Duration::from_mins(2)); + assert!(cli.otlp); + assert_eq!(cli.target_name.as_deref(), Some("public-api")); } #[test] @@ -779,6 +875,10 @@ mod tests { safe_target_name(&cli.url), "https://example.com:443/private" ); + assert_eq!( + safe_target_name(&Url::parse("http://[::1]/health").unwrap()), + "http://[::1]:80/health" + ); } #[test] @@ -789,6 +889,7 @@ mod tests { vec!["httping", "not-a-url"], vec!["httping", "https://example.com", "extra"], vec!["httping", "serve", "--history", "0", "https://example.com"], + vec!["httping", "--target-name", "", "https://example.com"], ] { assert_ne!( Cli::try_parse_from(arguments).unwrap_err().kind(), diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..ff28af0 --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,363 @@ +//! Optional OpenTelemetry metric export. + +use std::env; +use std::time::Duration; + +use opentelemetry::metrics::{Counter, Histogram, MeterProvider as _}; +use opentelemetry::{InstrumentationScope, KeyValue}; +use opentelemetry_otlp::{Protocol, WithExportConfig as _}; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::metrics::SdkMeterProvider; +use opentelemetry_sdk::metrics::Temporality; +use opentelemetry_sdk::metrics::data::ResourceMetrics; +use opentelemetry_sdk::metrics::exporter::PushMetricExporter; + +use crate::{ErrorKind, ProbeEvent, ProbeOutcome}; + +const DURATION_BUCKETS: [f64; 14] = [ + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, +]; + +#[derive(Debug)] +struct ReportingExporter { + inner: opentelemetry_otlp::MetricExporter, +} + +impl PushMetricExporter for ReportingExporter { + async fn export(&self, metrics: &ResourceMetrics) -> OTelSdkResult { + let result = self.inner.export(metrics).await; + if let Err(error) = &result { + eprintln!("httping: background OTLP metric export failed: {error}"); + } + result + } + + fn force_flush(&self) -> OTelSdkResult { + self.inner.force_flush() + } + + fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { + self.inner.shutdown_with_timeout(timeout) + } + + fn temporality(&self) -> Temporality { + self.inner.temporality() + } +} + +pub(crate) struct Telemetry { + provider: SdkMeterProvider, + duration: Histogram, + attempts: Counter, + responses: Counter, + transport_errors: Counter, + base_attributes: Vec, +} + +impl Telemetry { + pub(crate) fn from_environment( + enabled_by_flag: bool, + target_name: Option<&str>, + url: &reqwest::Url, + ) -> Result, String> { + let exporter = env::var("OTEL_METRICS_EXPORTER").ok(); + if !telemetry_enabled(enabled_by_flag, exporter.as_deref())? { + return Ok(None); + } + validate_protocol( + env::var("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL") + .ok() + .or_else(|| env::var("OTEL_EXPORTER_OTLP_PROTOCOL").ok()) + .as_deref(), + )?; + Self::build(target_name, url, None).map(Some) + } + + fn build( + target_name: Option<&str>, + url: &reqwest::Url, + endpoint: Option<&str>, + ) -> Result { + let exporter_builder = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_protocol(Protocol::HttpBinary); + let exporter = if let Some(endpoint) = endpoint { + exporter_builder.with_endpoint(endpoint).build() + } else { + exporter_builder.build() + } + .map_err(|error| format!("could not configure OTLP metrics: {error}"))?; + let exporter = ReportingExporter { inner: exporter }; + + let mut resource = Resource::builder(); + if !environment_has_service_name() { + resource = resource.with_service_name("httping"); + } + if !environment_has_resource_attribute("service.version") { + resource = resource + .with_attribute(KeyValue::new("service.version", env!("CARGO_PKG_VERSION"))); + } + let provider = SdkMeterProvider::builder() + .with_resource(resource.build()) + .with_periodic_exporter(exporter) + .build(); + let scope = InstrumentationScope::builder("httping") + .with_version(env!("CARGO_PKG_VERSION")) + .build(); + let meter = provider.meter_with_scope(scope); + let duration = meter + .f64_histogram("http.client.request.duration") + .with_description("Time until HTTP response headers or a transport failure") + .with_unit("s") + .with_boundaries(DURATION_BUCKETS.to_vec()) + .build(); + let attempts = meter + .u64_counter("httping.probe.attempts") + .with_description("Number of HTTP probes started") + .with_unit("{probe}") + .build(); + let responses = meter + .u64_counter("httping.probe.responses") + .with_description("Number of HTTP responses received") + .with_unit("{response}") + .build(); + let transport_errors = meter + .u64_counter("httping.probe.transport_errors") + .with_description("Number of HTTP probe transport failures") + .with_unit("{error}") + .build(); + + let host = url.host_str().unwrap_or("unknown").to_owned(); + let port = i64::from(url.port_or_known_default().unwrap_or(0)); + let name = target_name.map_or_else(|| crate::safe_target_name(url), ToOwned::to_owned); + let base_attributes = vec![ + KeyValue::new("http.request.method", "GET"), + KeyValue::new("server.address", host), + KeyValue::new("server.port", port), + KeyValue::new("url.scheme", url.scheme().to_owned()), + KeyValue::new("httping.target.name", name), + ]; + + Ok(Self { + provider, + duration, + attempts, + responses, + transport_errors, + base_attributes, + }) + } + + pub(crate) fn record(&self, event: &ProbeEvent) { + self.attempts.add(1, &self.base_attributes); + let mut attributes = self.base_attributes.clone(); + match &event.outcome { + ProbeOutcome::Response { + status, + protocol_version, + .. + } => { + attributes.push(KeyValue::new( + "http.response.status_code", + i64::from(*status), + )); + attributes.push(KeyValue::new( + "network.protocol.version", + protocol_version_value(protocol_version), + )); + if *status >= 500 { + attributes.push(KeyValue::new("error.type", status.to_string())); + } + self.responses.add(1, &attributes); + } + ProbeOutcome::Error { kind, .. } => { + attributes.push(KeyValue::new("error.type", error_type(*kind))); + self.transport_errors.add(1, &attributes); + } + } + self.duration + .record(event.elapsed_ms / 1_000.0, &attributes); + } + + pub(crate) fn finish(self) -> Vec { + let mut errors = Vec::new(); + if let Err(error) = self.provider.force_flush() { + errors.push(format!("could not flush OTLP metrics: {error}")); + } + if let Err(error) = self.provider.shutdown() { + errors.push(format!("could not shut down OTLP metrics: {error}")); + } + errors + } +} + +fn telemetry_enabled(flag: bool, exporter: Option<&str>) -> Result { + if flag { + return Ok(true); + } + match exporter.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok(false), + Some(value) if value.eq_ignore_ascii_case("none") => Ok(false), + Some(value) if value.eq_ignore_ascii_case("otlp") => Ok(true), + Some(value) => Err(format!( + "unsupported OTEL_METRICS_EXPORTER value {value:?}; use otlp or none" + )), + } +} + +fn validate_protocol(protocol: Option<&str>) -> Result<(), String> { + match protocol.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok(()), + Some(value) if value.eq_ignore_ascii_case("http/protobuf") => Ok(()), + Some(value) => Err(format!( + "unsupported OTLP metrics protocol {value:?}; use http/protobuf" + )), + } +} + +fn environment_has_service_name() -> bool { + if env::var("OTEL_SERVICE_NAME").is_ok_and(|value| !value.trim().is_empty()) { + return true; + } + environment_has_resource_attribute("service.name") +} + +fn environment_has_resource_attribute(name: &str) -> bool { + env::var("OTEL_RESOURCE_ATTRIBUTES").is_ok_and(|attributes| { + attributes.split(',').any(|attribute| { + attribute + .split_once('=') + .is_some_and(|(key, _)| key.trim() == name) + }) + }) +} + +fn protocol_version_value(version: &str) -> String { + version.strip_prefix("HTTP/").unwrap_or(version).to_owned() +} + +fn error_type(kind: ErrorKind) -> &'static str { + match kind { + ErrorKind::Timeout => "timeout", + ErrorKind::Connect => "connect", + ErrorKind::Redirect => "redirect", + ErrorKind::Request => "request", + ErrorKind::Other => "_OTHER", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn telemetry_requires_explicit_enablement() { + assert!(!telemetry_enabled(false, None).unwrap()); + assert!(!telemetry_enabled(false, Some("")).unwrap()); + assert!(!telemetry_enabled(false, Some("none")).unwrap()); + assert!(!telemetry_enabled(false, Some("NONE")).unwrap()); + assert!(telemetry_enabled(false, Some("OTLP")).unwrap()); + assert!(telemetry_enabled(true, Some("none")).unwrap()); + assert!(telemetry_enabled(false, Some("prometheus")).is_err()); + } + + #[test] + fn only_http_protobuf_is_valid() { + assert!(validate_protocol(None).is_ok()); + assert!(validate_protocol(Some("http/protobuf")).is_ok()); + assert!(validate_protocol(Some("grpc")).is_err()); + assert!(validate_protocol(Some("http/json")).is_err()); + } + + #[test] + fn metric_attribute_values_are_stable() { + assert_eq!(protocol_version_value("HTTP/1.1"), "1.1"); + assert_eq!(error_type(ErrorKind::Timeout), "timeout"); + assert_eq!(error_type(ErrorKind::Other), "_OTHER"); + } + + #[test] + fn builds_instruments_without_contacting_the_collector() { + let url = reqwest::Url::parse("https://user:secret@example.com/a?token=x").unwrap(); + let telemetry = + Telemetry::build(None, &url, Some("http://127.0.0.1:9/v1/metrics")).unwrap(); + let target = telemetry + .base_attributes + .iter() + .find(|value| value.key.as_str() == "httping.target.name") + .unwrap(); + assert_eq!(target.value.as_str(), "https://example.com:443/a"); + } + + #[test] + fn exports_otlp_protobuf_metrics() { + use std::io::{Read as _, Write as _}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let request = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&request); + let collector = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = vec![0; 32_768]; + let size = stream.read(&mut buffer).unwrap(); + captured.lock().unwrap().extend_from_slice(&buffer[..size]); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + } + }); + + let url = reqwest::Url::parse("https://example.com/health").unwrap(); + let telemetry = Telemetry::build( + Some("production-api"), + &url, + Some(&format!("http://{address}/v1/metrics")), + ) + .unwrap(); + telemetry.record(&ProbeEvent { + sequence: 1, + timestamp: "2026-01-01T00:00:00Z".to_owned(), + elapsed_ms: 42.0, + outcome: ProbeOutcome::Response { + status: 204, + status_text: "204 No Content".to_owned(), + protocol_version: "HTTP/1.1".to_owned(), + }, + }); + telemetry.record(&ProbeEvent { + sequence: 2, + timestamp: "2026-01-01T00:00:01Z".to_owned(), + elapsed_ms: 100.0, + outcome: ProbeOutcome::Error { + kind: ErrorKind::Timeout, + message: "secret local error details".to_owned(), + }, + }); + let errors = telemetry.finish(); + assert!(errors.is_empty(), "{errors:?}"); + collector.join().unwrap(); + + let request = request.lock().unwrap(); + for value in [ + b"POST /v1/metrics HTTP/1.1".as_slice(), + b"application/x-protobuf", + b"http.client.request.duration", + b"httping.probe.attempts", + b"httping.probe.responses", + b"httping.probe.transport_errors", + b"production-api", + ] { + assert!(request.windows(value.len()).any(|window| window == value)); + } + assert!( + !request + .windows(b"secret local error details".len()) + .any(|window| window == b"secret local error details") + ); + } +}