diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c1bad3a..049a5e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Publish dbrest-core - run: cargo publish -p dbrest-core + run: cargo publish -p dbrest-core --allow-dirty env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -41,19 +41,15 @@ jobs: run: sleep 30 - name: Publish dbrest-postgres - run: cargo publish -p dbrest-postgres + run: cargo publish -p dbrest-postgres --allow-dirty env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Publish dbrest-sqlite - run: cargo publish -p dbrest-sqlite + run: cargo publish -p dbrest-sqlite --allow-dirty env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Wait for crates.io index update run: sleep 30 - - name: Publish dbrest - run: cargo publish -p dbrest - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index ff373bb..ec50b7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -88,6 +100,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arc-swap" version = "1.8.1" @@ -494,6 +512,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -687,7 +714,7 @@ dependencies = [ [[package]] name = "dbrest" -version = "0.1.0" +version = "0.8.6" dependencies = [ "arc-swap", "async-trait", @@ -711,9 +738,14 @@ dependencies = [ "indexmap 2.13.0", "insta", "jsonwebtoken", + "metrics", + "metrics-exporter-opentelemetry", "mockall", "moka", - "rand", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -730,12 +762,13 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] [[package]] name = "dbrest-core" -version = "0.1.0" +version = "0.9.0" dependencies = [ "arc-swap", "async-trait", @@ -754,8 +787,10 @@ dependencies = [ "indexmap 2.13.0", "insta", "jsonwebtoken", + "metrics", "mockall", "moka", + "opentelemetry", "serde", "serde_json", "serial_test", @@ -767,12 +802,13 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] [[package]] name = "dbrest-postgres" -version = "0.1.0" +version = "0.8.6" dependencies = [ "async-trait", "dbrest-core", @@ -784,7 +820,7 @@ dependencies = [ [[package]] name = "dbrest-sqlite" -version = "0.1.0" +version = "0.8.6" dependencies = [ "async-trait", "dbrest-core", @@ -815,6 +851,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -952,7 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" dependencies = [ "deunicode", - "rand", + "rand 0.8.5", ] [[package]] @@ -1382,6 +1441,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1779,6 +1851,28 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "metrics" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-opentelemetry" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500e1fa58894a8c140ee0a9282e1d9e765fc7c7853bd310d1357d2808ad8f0b0" +dependencies = [ + "derive_more", + "metrics", + "opentelemetry", + "opentelemetry_sdk", +] + [[package]] name = "mime" version = "0.3.17" @@ -1899,7 +1993,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -2008,6 +2102,82 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.2", + "thiserror", + "tokio", + "tokio-stream", +] + [[package]] name = "parking" version = "2.2.1" @@ -2087,6 +2257,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2219,6 +2409,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.44" @@ -2241,8 +2454,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -2252,7 +2475,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[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]] @@ -2264,6 +2497,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[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 = "rayon" version = "1.11.0" @@ -2369,7 +2611,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2427,13 +2671,22 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2613,6 +2866,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -2801,7 +3060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2986,7 +3245,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -3024,7 +3283,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -3426,6 +3685,43 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -3434,9 +3730,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.13.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3521,6 +3820,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -3578,6 +3893,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -3747,6 +4074,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 657bf43..eb7c82f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ categories = ["web-programming::http-server", "database"] [dependencies] # Workspace crates -dbrest-core = { path = "crates/dbrest-core", version = "0.8.6" } +dbrest-core = { path = "crates/dbrest-core", version = "0.9.0" } dbrest-postgres = { path = "crates/dbrest-postgres", version = "0.8.6" } dbrest-sqlite = { path = "crates/dbrest-sqlite", version = "0.8.6" } @@ -83,6 +83,14 @@ clap = { version = "4", features = ["derive", "env"] } # Streaming futures = "0.3" +# Metrics / Observability +metrics = "0.24" +metrics-exporter-opentelemetry = "0.2" +opentelemetry = "0.31" +opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] } +tracing-opentelemetry = "0.32" + [dev-dependencies] tokio-test = "0.4" mockall = "0.13" diff --git a/Makefile b/Makefile index e310a14..4692b45 100644 --- a/Makefile +++ b/Makefile @@ -282,7 +282,11 @@ bump-version: ## Bump version across all crates (usage: make bump-version V=0.2. @sed -i 's/\(dbrest-postgres = {.*version = "\)[^"]*/\1$(V)/' Cargo.toml @sed -i 's/\(dbrest-sqlite = {.*version = "\)[^"]*/\1$(V)/' Cargo.toml @echo "$(GREEN)Version bumped to $(V) in all Cargo.toml files$(NC)" - @echo "$(YELLOW)Don't forget to commit and tag: git tag v$(V)$(NC)" + @git add Cargo.toml crates/dbrest-core/Cargo.toml crates/dbrest-postgres/Cargo.toml crates/dbrest-sqlite/Cargo.toml + @git commit -m "release: v$(V)" + @git tag v$(V) + @echo "$(GREEN)Committed and tagged v$(V)$(NC)" + @read -p "Push commit and tag to origin? [y/N] " ans && [ "$$ans" = "y" ] && git push origin main --tags || echo "$(YELLOW)Skipped push. Run manually: git push origin main --tags$(NC)" install-hooks: ## Install git hooks from .githooks/ @echo "$(BLUE)Installing git hooks...$(NC)" diff --git a/crates/dbrest-core/Cargo.toml b/crates/dbrest-core/Cargo.toml index dbfbaee..0ad5d16 100644 --- a/crates/dbrest-core/Cargo.toml +++ b/crates/dbrest-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dbrest-core" -version = "0.8.6" +version = "0.9.0" edition = "2024" rust-version = "1.91" description = "Database-agnostic core for the dbrest REST API" @@ -31,6 +31,11 @@ compact_str = { version = "0.8", features = ["serde"] } smallvec = { version = "1", features = ["serde"] } bytes = "1" +# Metrics & Observability +metrics = "0.24" +opentelemetry = "0.31" +tracing-opentelemetry = "0.32" + # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/dbrest-core/src/app/admin.rs b/crates/dbrest-core/src/app/admin.rs index a405c24..c6ad63c 100644 --- a/crates/dbrest-core/src/app/admin.rs +++ b/crates/dbrest-core/src/app/admin.rs @@ -1,7 +1,7 @@ //! Admin server //! -//! Provides health check, readiness, metrics, and config endpoints on a -//! separate port for operational monitoring. +//! Provides health check, readiness, and config endpoints on a separate +//! port for operational monitoring. //! //! # Endpoints //! @@ -9,11 +9,8 @@ //! |-------------|--------|--------------------------------------| //! | `/live` | GET | Liveness probe (always 200) | //! | `/ready` | GET | Readiness probe (200 if schema ready)| -//! | `/metrics` | GET | Basic metrics (JSON) | //! | `/config` | GET | Current config (redacted secrets) | -use std::sync::atomic::Ordering; - use axum::{ Router, body::Body, @@ -44,7 +41,6 @@ pub fn create_admin_router(state: AppState) -> Router { Router::new() .route("/live", get(liveness)) .route("/ready", get(readiness)) - .route("/metrics", get(metrics)) .route("/config", get(config_handler)) .with_state(state) } @@ -92,25 +88,6 @@ pub fn redacted_config(config: &crate::config::AppConfig) -> serde_json::Value { }) } -/// Basic metrics endpoint (JSON). -async fn metrics(State(state): State) -> Response { - let m = &state.metrics; - let body = serde_json::json!({ - "requests_total": m.requests_total.load(Ordering::Relaxed), - "requests_success": m.requests_success.load(Ordering::Relaxed), - "requests_error": m.requests_error.load(Ordering::Relaxed), - "db_queries_total": m.db_queries_total.load(Ordering::Relaxed), - "schema_cache_reloads": m.schema_cache_reloads.load(Ordering::Relaxed), - "jwt_cache_hits": m.jwt_cache_hits.load(Ordering::Relaxed), - "jwt_cache_misses": m.jwt_cache_misses.load(Ordering::Relaxed), - "jwt_cache_entries": state.jwt_cache.entry_count(), - "pg_version": format!("{}.{}.{}", state.pg_version.major, state.pg_version.minor, state.pg_version.patch), - }); - - let json = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".to_string()); - json_response(json) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/dbrest-core/src/app/handlers.rs b/crates/dbrest-core/src/app/handlers.rs index 5302325..0023f2c 100644 --- a/crates/dbrest-core/src/app/handlers.rs +++ b/crates/dbrest-core/src/app/handlers.rs @@ -85,16 +85,23 @@ fn flatten_headers(headers: &HeaderMap) -> Vec<(String, String)> { /// /// Runs tx_vars, pre_req, and main query in order within a single /// transaction, returning the CTE result set from the main query. +#[tracing::instrument(name = "db.query", skip(state, mq), fields(operation))] async fn execute_main_query( state: &AppState, mq: &query::MainQuery, + operation: &'static str, ) -> Result { - state - .metrics - .db_queries_total - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + metrics::counter!("db.query.total", "operation" => operation).increment(1); - state + // Log the SQL query if configured + if state.config().log_query + && let Some(ref main) = mq.main + { + tracing::info!(operation, sql = main.sql(), "Executing query"); + } + + let start = std::time::Instant::now(); + let result = state .db .exec_in_transaction( mq.tx_vars.as_ref(), @@ -102,7 +109,20 @@ async fn execute_main_query( mq.mutation.as_ref(), mq.main.as_ref(), ) - .await + .await; + let duration = start.elapsed().as_secs_f64(); + metrics::histogram!("db.query.duration", "operation" => operation).record(duration); + + if let Err(ref e) = result { + tracing::error!( + operation, + duration_ms = format_args!("{:.1}", duration * 1000.0), + "Query failed: {}", + e + ); + } + + result } // Error mapping has been moved to the backend module. @@ -186,6 +206,8 @@ fn apply_guc_overrides( /// Process a single API request through the full pipeline. /// /// This is the shared core used by all resource handlers (read, mutate, rpc). +#[tracing::instrument(name = "process_request", skip(state, headers, body, query_str), + fields(method, path, role = auth.role.as_str()))] async fn process_request( state: &AppState, auth: &AuthResult, @@ -256,7 +278,19 @@ async fn process_request( }; // 5. Execute - let result = execute_main_query(state, &mq).await?; + let operation = if path.starts_with("/rpc/") { + "rpc" + } else { + match method { + "GET" | "HEAD" => "read", + "POST" => "create", + "PATCH" => "update", + "PUT" => "upsert", + "DELETE" => "delete", + _ => "unknown", + } + }; + let result = execute_main_query(state, &mq, operation).await?; Ok((result, prefs, media_type)) } diff --git a/crates/dbrest-core/src/app/metrics.rs b/crates/dbrest-core/src/app/metrics.rs new file mode 100644 index 0000000..613f97b --- /dev/null +++ b/crates/dbrest-core/src/app/metrics.rs @@ -0,0 +1,25 @@ +//! Metrics helpers for background reporting. + +use std::sync::Arc; + +use crate::backend::DatabaseBackend; + +/// Spawn a background task that periodically reports connection pool gauges. +/// +/// Reports `db.pool.connections.active`, `db.pool.connections.idle`, and +/// `db.pool.connections.max` every 15 seconds via the `metrics` facade. +/// +/// Does nothing if the backend's `pool_status()` returns `None`. +pub fn start_pool_metrics_reporter(db: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + interval.tick().await; + if let Some(status) = db.pool_status() { + metrics::gauge!("db.pool.connections.active").set(status.active as f64); + metrics::gauge!("db.pool.connections.idle").set(status.idle as f64); + metrics::gauge!("db.pool.connections.max").set(status.max_size as f64); + } + } + }); +} diff --git a/crates/dbrest-core/src/app/mod.rs b/crates/dbrest-core/src/app/mod.rs index c2365b2..8cf3024 100644 --- a/crates/dbrest-core/src/app/mod.rs +++ b/crates/dbrest-core/src/app/mod.rs @@ -6,6 +6,7 @@ pub mod admin; pub mod builder; pub mod handlers; +pub mod metrics; pub mod router; pub mod server; pub mod state; diff --git a/crates/dbrest-core/src/app/router.rs b/crates/dbrest-core/src/app/router.rs index 76a29bc..e011197 100644 --- a/crates/dbrest-core/src/app/router.rs +++ b/crates/dbrest-core/src/app/router.rs @@ -5,7 +5,6 @@ //! layered on top. use std::sync::Arc; -use std::sync::atomic::Ordering; use arc_swap::ArcSwap; use axum::{ @@ -27,6 +26,19 @@ use crate::config::AppConfig; use super::handlers; use super::state::AppState; +/// Adapter for extracting W3C traceparent from `http::HeaderMap`. +struct HeaderMapExtractor<'a>(&'a http::HeaderMap); + +impl opentelemetry::propagation::Extractor for HeaderMapExtractor<'_> { + fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(|v| v.to_str().ok()) + } + + fn keys(&self) -> Vec<&str> { + self.0.keys().map(|k| k.as_str()).collect() + } +} + /// Create the main application router with all routes and middleware. /// /// # Routes @@ -77,20 +89,27 @@ pub fn create_router(state: AppState) -> Router { async move { auth_middleware_inner(auth, req, next).await } }); - // Build metrics middleware layer - let metrics = state.metrics.clone(); - let metrics_layer = middleware::from_fn(move |req: Request, next: Next| { - let m = metrics.clone(); - async move { - m.requests_total.fetch_add(1, Ordering::Relaxed); - let response = next.run(req).await; - if response.status().is_success() { - m.requests_success.fetch_add(1, Ordering::Relaxed); - } else { - m.requests_error.fetch_add(1, Ordering::Relaxed); - } - response - } + // Build metrics middleware layer (uses `metrics` crate global facade) + let metrics_layer = middleware::from_fn(move |req: Request, next: Next| async move { + let method = req.method().as_str().to_owned(); + let path = req.uri().path().to_owned(); + let start = std::time::Instant::now(); + + let response = next.run(req).await; + + let status = response.status().as_u16().to_string(); + let duration = start.elapsed().as_secs_f64(); + + metrics::counter!("http.server.request.total", + "method" => method.clone(), "status" => status.clone() + ) + .increment(1); + metrics::histogram!("http.server.request.duration", + "method" => method, "status" => status, "path" => path + ) + .record(duration); + + response }); // Build CORS layer from config @@ -117,6 +136,17 @@ pub fn create_router(state: AppState) -> Router { .layer(CompressionLayer::new()) .layer(cors_layer) .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn(|req: Request, next: Next| async move { + // Extract W3C traceparent from incoming headers and set it as + // the parent context on the current span. This links the + // TraceLayer's HTTP request span to the caller's trace. + let parent_cx = opentelemetry::global::get_text_map_propagator(|prop| { + prop.extract(&HeaderMapExtractor(req.headers())) + }); + use tracing_opentelemetry::OpenTelemetrySpanExt; + let _ = tracing::Span::current().set_parent(parent_cx); + next.run(req).await + })) .layer(DefaultBodyLimit::max(config.server_max_body_size)) .with_state(state) } diff --git a/crates/dbrest-core/src/app/state.rs b/crates/dbrest-core/src/app/state.rs index f99f281..0cc9e8f 100644 --- a/crates/dbrest-core/src/app/state.rs +++ b/crates/dbrest-core/src/app/state.rs @@ -6,7 +6,6 @@ //! `Arc`-wrapped) and passed to every axum handler via `State`. use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; use arc_swap::ArcSwap; @@ -44,18 +43,6 @@ impl From<&DbVersion> for PgVersion { } } -/// Application-level metrics (atomic counters). -#[derive(Debug, Default)] -pub struct Metrics { - pub requests_total: AtomicU64, - pub requests_success: AtomicU64, - pub requests_error: AtomicU64, - pub db_queries_total: AtomicU64, - pub schema_cache_reloads: AtomicU64, - pub jwt_cache_hits: AtomicU64, - pub jwt_cache_misses: AtomicU64, -} - /// Central application state. /// /// Constructed once at startup and shared across all handlers. @@ -75,8 +62,6 @@ pub struct AppState { pub auth: AuthState, /// JWT validation cache (shared with AuthState). pub jwt_cache: JwtCache, - /// Application metrics. - pub metrics: Arc, /// Database version. pub db_version: DbVersion, /// PostgreSQL version (legacy — prefer `db_version` field). @@ -105,7 +90,6 @@ impl AppState { schema_cache: Arc::new(ArcSwap::new(Arc::new(None))), auth, jwt_cache, - metrics: Arc::new(Metrics::default()), db_version, pg_version, } @@ -122,6 +106,7 @@ impl AppState { } /// Reload configuration from file and environment. + #[tracing::instrument(name = "reload_config", skip(self))] pub async fn reload_config(&self) -> Result<(), Error> { let current = self.config.load(); let file_path = current.config_file_path.clone(); @@ -139,14 +124,13 @@ impl AppState { } /// Load or reload the schema cache from the database. + #[tracing::instrument(name = "reload_schema_cache", skip(self))] pub async fn reload_schema_cache(&self) -> Result<(), Error> { let config = self.config.load(); let introspector = self.db.introspector(); let cache = SchemaCache::load(&*introspector, &config).await?; - self.metrics - .schema_cache_reloads - .fetch_add(1, Ordering::Relaxed); + metrics::counter!("schema_cache.reload.total").increment(1); self.schema_cache.store(Arc::new(Some(cache))); tracing::info!("Schema cache reloaded successfully"); diff --git a/crates/dbrest-core/src/auth/middleware.rs b/crates/dbrest-core/src/auth/middleware.rs index fb9f51f..17e28df 100644 --- a/crates/dbrest-core/src/auth/middleware.rs +++ b/crates/dbrest-core/src/auth/middleware.rs @@ -119,6 +119,7 @@ pub async fn authenticate(state: &AuthState, request: &Request) -> Result, @@ -128,6 +129,7 @@ pub async fn authenticate_token( Some(token) => { // Check cache first if let Some(cached) = state.cache.get(token).await { + metrics::counter!("jwt.cache.hit.total").increment(1); return Ok((*cached).clone()); } @@ -136,6 +138,7 @@ pub async fn authenticate_token( // Cache the result state.cache.insert(token, result.clone()).await; + metrics::counter!("jwt.cache.miss.total").increment(1); Ok(result) } @@ -171,6 +174,13 @@ fn extract_bearer_token(request: &Request) -> Option<&str> { /// Build an HTTP error response from a JWT error. pub fn jwt_error_response(err: JwtError) -> Response { + tracing::warn!( + error_code = err.code(), + http_status = err.status().as_u16(), + "Auth rejected: {}", + err + ); + let status = err.status(); let www_auth = err.www_authenticate(); diff --git a/crates/dbrest-core/src/backend/mod.rs b/crates/dbrest-core/src/backend/mod.rs index 9a82e65..98e3aad 100644 --- a/crates/dbrest-core/src/backend/mod.rs +++ b/crates/dbrest-core/src/backend/mod.rs @@ -81,6 +81,21 @@ impl StatementResult { } } +// ========================================================================== +// PoolStatus — connection pool metrics +// ========================================================================== + +/// Connection pool status for metrics reporting. +#[derive(Debug, Clone)] +pub struct PoolStatus { + /// Number of connections currently in use. + pub active: u32, + /// Number of idle connections in the pool. + pub idle: u32, + /// Maximum pool size. + pub max_size: u32, +} + // ========================================================================== // DatabaseBackend — the main abstraction trait // ========================================================================== @@ -158,6 +173,13 @@ pub trait DatabaseBackend: Send + Sync + 'static { /// Map a backend-specific database error into our Error type. fn map_error(&self, err: Box) -> Error; + + /// Return current connection pool status for metrics reporting. + /// + /// Returns `None` if the backend does not track pool statistics. + fn pool_status(&self) -> Option { + None + } } // ========================================================================== diff --git a/crates/dbrest-core/src/config/parser.rs b/crates/dbrest-core/src/config/parser.rs index 39321f5..dcbc2d3 100644 --- a/crates/dbrest-core/src/config/parser.rs +++ b/crates/dbrest-core/src/config/parser.rs @@ -368,6 +368,30 @@ pub fn apply_config_value( })?; } + // Metrics / Observability + "metrics-enabled" => { + config.metrics_enabled = parse_bool(value)?; + } + "metrics-otlp-endpoint" => config.metrics_otlp_endpoint = value.to_string(), + "metrics-otlp-protocol" => config.metrics_otlp_protocol = value.to_string(), + "metrics-export-interval-secs" => { + config.metrics_export_interval_secs = parse_int(key, value)?; + } + "metrics-service-name" => config.metrics_service_name = value.to_string(), + "tracing-enabled" => { + config.tracing_enabled = parse_bool(value)?; + } + "tracing-sampling-ratio" => { + config.tracing_sampling_ratio = + value + .parse::() + .map_err(|_| ConfigError::InvalidValue { + key: key.to_string(), + value: value.to_string(), + expected: Some("float between 0.0 and 1.0".to_string()), + })?; + } + // App settings (app.settings.*) key if key.starts_with("app.settings.") => { if let Some(setting_key) = key.strip_prefix("app.settings.") { diff --git a/crates/dbrest-core/src/config/types.rs b/crates/dbrest-core/src/config/types.rs index 61e1f48..bc5c1a1 100644 --- a/crates/dbrest-core/src/config/types.rs +++ b/crates/dbrest-core/src/config/types.rs @@ -173,6 +173,30 @@ pub struct AppConfig { /// Responses larger than this will be streamed pub server_streaming_threshold: u64, + // ========================================= + // Metrics / Observability settings + // ========================================= + /// Enable OTLP metrics export + pub metrics_enabled: bool, + + /// OTLP endpoint URL + pub metrics_otlp_endpoint: String, + + /// OTLP protocol: "grpc" or "http" + pub metrics_otlp_protocol: String, + + /// Metrics export interval in seconds + pub metrics_export_interval_secs: u64, + + /// Service name for OTLP resource attribute + pub metrics_service_name: String, + + /// Enable OTLP distributed tracing export + pub tracing_enabled: bool, + + /// Trace sampling ratio (0.0 to 1.0, default 1.0 = sample all) + pub tracing_sampling_ratio: f64, + // ========================================= // App settings (custom key-value pairs) // ========================================= @@ -259,6 +283,15 @@ impl Default for AppConfig { server_streaming_enabled: true, server_streaming_threshold: 10 * 1024 * 1024, // 10MB + // Metrics + metrics_enabled: false, + metrics_otlp_endpoint: "http://localhost:4317".to_string(), + metrics_otlp_protocol: "grpc".to_string(), + metrics_export_interval_secs: 60, + metrics_service_name: "dbrest".to_string(), + tracing_enabled: false, + tracing_sampling_ratio: 1.0, + // App settings app_settings: HashMap::new(), diff --git a/crates/dbrest-core/src/error/response.rs b/crates/dbrest-core/src/error/response.rs index 7f6e26f..b26d9a4 100644 --- a/crates/dbrest-core/src/error/response.rs +++ b/crates/dbrest-core/src/error/response.rs @@ -58,6 +58,23 @@ impl IntoResponse for Error { let status = self.status(); let body = ErrorResponse::from(&self); + if status.is_server_error() { + tracing::error!( + error_code = body.code, + http_status = status.as_u16(), + details = body.details.as_deref().unwrap_or(""), + "{}", + body.message + ); + } else if status.is_client_error() { + tracing::warn!( + error_code = body.code, + http_status = status.as_u16(), + "{}", + body.message + ); + } + let mut response = (status, Json(body)).into_response(); // Propagate WWW-Authenticate header for JWT errors diff --git a/crates/dbrest-core/src/lib.rs b/crates/dbrest-core/src/lib.rs index 7301252..4e1fbb6 100644 --- a/crates/dbrest-core/src/lib.rs +++ b/crates/dbrest-core/src/lib.rs @@ -29,7 +29,7 @@ pub mod test_helpers; pub use api_request::ApiRequest; pub use app::{AppState, Datasource, DbrestApp, DbrestRouters, start_server}; pub use auth::{AuthResult, AuthState, JwtCache}; -pub use backend::{DatabaseBackend, DbVersion, SqlDialect}; +pub use backend::{DatabaseBackend, DbVersion, PoolStatus, SqlDialect}; pub use config::{AppConfig, load_config}; pub use error::Error; pub use plan::action_plan; diff --git a/crates/dbrest-postgres/Cargo.toml b/crates/dbrest-postgres/Cargo.toml index d74aa2e..4e1e4b6 100644 --- a/crates/dbrest-postgres/Cargo.toml +++ b/crates/dbrest-postgres/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["rest", "api", "database", "postgres"] categories = ["web-programming::http-server", "database"] [dependencies] -dbrest-core = { path = "../dbrest-core", version = "0.8.6" } +dbrest-core = { path = "../dbrest-core", version = "0.9.0" } # Database sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "json", "macros"] } diff --git a/crates/dbrest-postgres/src/executor.rs b/crates/dbrest-postgres/src/executor.rs index e8988be..49b6de0 100644 --- a/crates/dbrest-postgres/src/executor.rs +++ b/crates/dbrest-postgres/src/executor.rs @@ -7,7 +7,7 @@ use sqlx::Row; use sqlx::postgres::PgPoolOptions; use crate::introspector::SqlxIntrospector; -use dbrest_core::backend::{DatabaseBackend, DbVersion, StatementResult}; +use dbrest_core::backend::{DatabaseBackend, DbVersion, PoolStatus, StatementResult}; use dbrest_core::error::Error; use dbrest_core::query::sql_builder::{SqlBuilder, SqlParam}; use dbrest_core::schema_cache::db::DbIntrospector; @@ -469,4 +469,12 @@ impl DatabaseBackend for PgBackend { Error::Internal("Unknown database error".to_string()) } } + + fn pool_status(&self) -> Option { + Some(PoolStatus { + active: self.pool.size().saturating_sub(self.pool.num_idle() as u32), + idle: self.pool.num_idle() as u32, + max_size: self.pool.options().get_max_connections(), + }) + } } diff --git a/crates/dbrest-sqlite/Cargo.toml b/crates/dbrest-sqlite/Cargo.toml index cfdefd9..77ace99 100644 --- a/crates/dbrest-sqlite/Cargo.toml +++ b/crates/dbrest-sqlite/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["rest", "api", "database", "sqlite"] categories = ["web-programming::http-server", "database"] [dependencies] -dbrest-core = { path = "../dbrest-core", version = "0.8.6" } +dbrest-core = { path = "../dbrest-core", version = "0.9.0" } # Database sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "json"] } diff --git a/crates/dbrest-sqlite/src/executor.rs b/crates/dbrest-sqlite/src/executor.rs index f776e8b..a18bcc9 100644 --- a/crates/dbrest-sqlite/src/executor.rs +++ b/crates/dbrest-sqlite/src/executor.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use sqlx::sqlite::SqlitePoolOptions; use sqlx::{Column, Row}; -use dbrest_core::backend::{DatabaseBackend, DbVersion, StatementResult}; +use dbrest_core::backend::{DatabaseBackend, DbVersion, PoolStatus, StatementResult}; use dbrest_core::error::Error; use dbrest_core::query::sql_builder::{SqlBuilder, SqlParam}; use dbrest_core::schema_cache::db::DbIntrospector; @@ -368,4 +368,12 @@ impl DatabaseBackend for SqliteBackend { Error::Internal("Unknown database error".to_string()) } } + + fn pool_status(&self) -> Option { + Some(PoolStatus { + active: self.pool.size().saturating_sub(self.pool.num_idle() as u32), + idle: self.pool.num_idle() as u32, + max_size: self.pool.options().get_max_connections(), + }) + } } diff --git a/src/main.rs b/src/main.rs index e1ec447..17ef359 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use clap::Parser; @@ -37,9 +38,6 @@ struct Args { #[tokio::main] async fn main() { - // Initialise logging - tracing_subscriber::fmt::init(); - let args = Args::parse(); // Load config (from file or defaults) @@ -60,6 +58,12 @@ async fn main() { config.server_port = port; } + // Initialize tracing subscriber (fmt + optional OTLP span export) + let _tracer_provider = init_tracing(&config); + + // Initialize metrics exporter (OTLP push) if enabled + let _meter_provider = init_metrics(&config); + // Detect backend from URI and start let result = if is_sqlite_uri(&config.db_uri) { start_sqlite_server(config).await @@ -73,6 +77,111 @@ async fn main() { } } +/// Initialize the tracing subscriber with optional OTLP span export. +/// +/// When `tracing_enabled` is true, spans are exported via OTLP alongside +/// the console fmt output. When false, only console logging is active. +fn init_tracing(config: &AppConfig) -> Option { + use tracing_subscriber::EnvFilter; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let fmt_layer = tracing_subscriber::fmt::layer(); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + if !config.tracing_enabled { + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .init(); + return None; + } + + use opentelemetry::trace::TracerProvider; + use opentelemetry_otlp::WithExportConfig; + use opentelemetry_sdk::trace::Sampler; + + // Set W3C TraceContext propagator for traceparent header extraction + opentelemetry::global::set_text_map_propagator( + opentelemetry_sdk::propagation::TraceContextPropagator::new(), + ); + + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(&config.metrics_otlp_endpoint) + .build() + .expect("Failed to build OTLP span exporter"); + + let service_name: &'static str = + Box::leak(config.metrics_service_name.clone().into_boxed_str()); + + let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .with_sampler(Sampler::TraceIdRatioBased(config.tracing_sampling_ratio)) + .with_resource( + opentelemetry_sdk::Resource::builder() + .with_service_name(service_name) + .build(), + ) + .build(); + + opentelemetry::global::set_tracer_provider(tracer_provider.clone()); + + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("dbrest")); + + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(otel_layer) + .init(); + + tracing::info!( + endpoint = %config.metrics_otlp_endpoint, + sampling_ratio = config.tracing_sampling_ratio, + "OTLP tracing exporter initialized" + ); + + Some(tracer_provider) +} + +/// Initialize the `metrics` global recorder backed by an OTLP exporter. +/// +/// Returns the `SdkMeterProvider` so it can be kept alive (and flushed on +/// shutdown). Returns `None` when metrics are disabled. +fn init_metrics(config: &AppConfig) -> Option { + use opentelemetry_otlp::WithExportConfig; + + if !config.metrics_enabled { + return None; + } + + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_tonic() + .with_endpoint(&config.metrics_otlp_endpoint) + .build() + .expect("Failed to build OTLP metric exporter"); + + let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter) + .with_interval(Duration::from_secs(config.metrics_export_interval_secs)) + .build(); + + let service_name: &'static str = + Box::leak(config.metrics_service_name.clone().into_boxed_str()); + let (provider, _recorder) = metrics_exporter_opentelemetry::Recorder::builder(service_name) + .with_meter_provider(|builder| builder.with_reader(reader)) + .install() + .expect("Failed to install metrics recorder"); + + tracing::info!( + endpoint = %config.metrics_otlp_endpoint, + interval_secs = config.metrics_export_interval_secs, + service_name = %config.metrics_service_name, + "OTLP metrics exporter initialized" + ); + + Some(provider) +} + /// Detect whether a URI targets SQLite. fn is_sqlite_uri(uri: &str) -> bool { uri.starts_with("sqlite:") || uri.ends_with(".sqlite") || uri.ends_with(".db") @@ -106,6 +215,10 @@ async fn start_pg_server(config: AppConfig) -> Result<(), Error> { let db: Arc = Arc::new(backend); let dialect: Arc = Arc::new(PgDialect); + if config.metrics_enabled { + dbrest_core::app::metrics::start_pool_metrics_reporter(db.clone()); + } + dbrest_core::app::server::start_server_with_backend(db, dialect, db_version, config).await } @@ -137,5 +250,9 @@ async fn start_sqlite_server(config: AppConfig) -> Result<(), Error> { let db: Arc = Arc::new(backend); let dialect: Arc = Arc::new(SqliteDialect); + if config.metrics_enabled { + dbrest_core::app::metrics::start_pool_metrics_reporter(db.clone()); + } + dbrest_core::app::server::start_server_with_backend(db, dialect, db_version, config).await }