diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8ccc08e55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/ +desktop/ +.git/ +.scratch/ +*.md +!README.md +.env* diff --git a/Cargo.lock b/Cargo.lock index da497d7de..891f88018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,18 @@ dependencies = [ "cpufeatures", ] +[[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" @@ -199,6 +211,28 @@ dependencies = [ "url", ] +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "aws-region" version = "0.25.5" @@ -456,6 +490,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -571,6 +607,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -903,6 +948,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1016,6 +1067,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1040,6 +1097,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -1199,6 +1262,25 @@ dependencies = [ "weezl", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1213,7 +1295,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1221,6 +1303,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1391,6 +1476,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -1413,6 +1499,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1708,6 +1795,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1863,6 +1960,54 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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-prometheus" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda" +dependencies = [ + "base64", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "rustls", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.2", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -2361,6 +2506,21 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[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 = "quick-error" version = "2.0.1" @@ -2512,6 +2672,24 @@ 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 0.9.5", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redis" version = "0.27.6" @@ -2772,6 +2950,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2780,6 +2959,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2796,6 +2987,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3123,6 +3315,12 @@ dependencies = [ "time", ] +[[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" @@ -3422,6 +3620,8 @@ dependencies = [ "futures-util", "hex", "infer", + "metrics", + "metrics-exporter-prometheus", "moka", "nostr", "redis", @@ -4528,6 +4728,28 @@ dependencies = [ "wasite", ] +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 4d9c560ea..b789e4e22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ cron = "0.12" # Observability tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +metrics = "0.24" +metrics-exporter-prometheus = "0.18" # Error handling thiserror = "2" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..317b31cac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ── Build stage ────────────────────────────────────────────── +# Hard-code --platform to prevent exec format error on ARM Macs. +FROM --platform=linux/amd64 rust:1.88-bookworm AS builder +WORKDIR /build +COPY . . +RUN cargo build --release -p sprout-relay \ + && strip target/release/sprout-relay + +# ── Runtime stage ──────────────────────────────────────────── +FROM --platform=linux/amd64 debian:bookworm-slim + +# CAKE: non-root UID 1000 (numeric, not username) +RUN groupadd -g 1000 sprout && useradd -u 1000 -g sprout -m sprout + +# CAKE: writable dirs +RUN mkdir -p /cache /tmp && chown sprout:sprout /cache /tmp + +# socat for Istio abstract→file socket bridge +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates socat && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/sprout-relay /code/sprout-relay +COPY script/start /code/start +RUN chmod +x /code/start + +# CAKE: required Envoy env vars (overridden at runtime by CAKE). +ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \ + ENVOY_INGRESS_PORT="20001" \ + ENVOY_HTTP_EGRESS_SOCKET_PATH="@egress.sock" \ + ENVOY_DATADOG_PORT="3030" \ + CASH_FRAMEWORK="rust" + +USER 1000 +ENTRYPOINT ["/code/start"] diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index 34957eeff..8a81cfcf5 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -47,6 +47,8 @@ sha2 = { workspace = true } hex = { workspace = true } url = { workspace = true } moka = { workspace = true } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } [features] dev = ["sprout-auth/dev"] diff --git a/crates/sprout-relay/src/api/media.rs b/crates/sprout-relay/src/api/media.rs index 5107294f3..dc1648e2d 100644 --- a/crates/sprout-relay/src/api/media.rs +++ b/crates/sprout-relay/src/api/media.rs @@ -117,6 +117,13 @@ pub async fn upload_blob( ) .await?; + // Normalize MIME to a known set to bound label cardinality. + let mime_label = match descriptor.mime_type.as_str() { + "image/jpeg" | "image/png" | "image/gif" | "image/webp" => &descriptor.mime_type, + _ => "other", + }; + metrics::counter!("sprout_media_uploads_total", "mime" => mime_label.to_owned()).increment(1); + // Fire-and-forget audit — never block the response on audit I/O. let audit = state.audit.clone(); let desc = descriptor.clone(); diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 327aff29b..796722f50 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -48,6 +48,12 @@ pub struct Config { /// Optional Unix Domain Socket path. When set, the relay also listens on this /// UDS for traffic (e.g. service mesh sidecar). Health probes still use TCP. pub uds_path: Option, + /// TCP port for the health-only router (`/_liveness`, `/_readiness`, `/_status`). + /// Separate from the app router so K8s probes bypass Istio and auth middleware. + pub health_port: u16, + /// TCP port for the Prometheus metrics exporter (`GET /metrics`). + pub metrics_port: u16, + /// When true, NIP-42 pubkey-only authentication (no JWT or API token) is /// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid /// API tokens or Okta JWTs bypass the allowlist entirely. @@ -137,6 +143,16 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); + let health_port = std::env::var("SPROUT_HEALTH_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8080); + + let metrics_port = std::env::var("SPROUT_METRICS_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(9102); + let media = sprout_media::MediaConfig { s3_endpoint: std::env::var("SPROUT_S3_ENDPOINT") .unwrap_or_else(|_| "http://localhost:9000".to_string()), @@ -193,6 +209,8 @@ impl Config { cors_origins, relay_private_key, uds_path, + health_port, + metrics_port, pubkey_allowlist_enabled, media, }) diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index bc991a637..721286a85 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -82,6 +82,7 @@ impl ConnectionState { let count = self.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; if count >= SLOW_CLIENT_GRACE_LIMIT { warn!(conn_id = %self.conn_id, count, "sustained backpressure — closing slow client"); + metrics::counter!("sprout_ws_backpressure_disconnects_total").increment(1); self.cancel.cancel(); } else { warn!(conn_id = %self.conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); @@ -134,6 +135,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); + metrics::counter!("sprout_ws_connections_total").increment(1); let challenge_msg = RelayMessage::auth_challenge(&challenge); if tx @@ -145,6 +147,10 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So return; } + // Gauge incremented AFTER challenge send succeeds — early disconnects + // don't leak. Decremented in the cleanup path below. + metrics::gauge!("sprout_ws_connections_active").increment(1.0); + // Register after challenge succeeds — avoids leaked entries on early disconnect. state.conn_manager.register( conn_id, @@ -181,6 +187,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So state.sub_registry.remove_connection(conn.conn_id); state.conn_manager.deregister(conn.conn_id); + metrics::gauge!("sprout_ws_connections_active").decrement(1.0); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection closed"); drop(permit); diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 056c10999..c3f4e7e57 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -52,6 +52,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }); + metrics::counter!("sprout_auth_attempts_total", "method" => if auth_token.as_ref().is_some_and(|t| t.starts_with("sprout_")) { "api_token" } else { "nip42" }).increment(1); + if let Some(ref token) = auth_token { if token.starts_with("sprout_") { // ── API token path ────────────────────────────────────────────── @@ -119,6 +121,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } Err(e) => { warn!(conn_id = %conn_id, error = %e, "API token verification failed"); + metrics::counter!("sprout_auth_failures_total", "reason" => "api_token_invalid").increment(1); *conn.auth_state.write().await = AuthState::Failed; conn.send(RelayMessage::ok( &event_id_hex, @@ -154,6 +157,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: }; if !allowed { warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); + metrics::counter!("sprout_auth_failures_total", "reason" => "allowlist_denied") + .increment(1); *conn.auth_state.write().await = AuthState::Failed; conn.send(RelayMessage::ok( &event_id_hex, @@ -169,6 +174,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } Err(e) => { warn!(conn_id = %conn_id, error = %e, "NIP-42 auth failed"); + metrics::counter!("sprout_auth_failures_total", "reason" => "nip42_invalid") + .increment(1); *conn.auth_state.write().await = AuthState::Failed; conn.send(RelayMessage::ok( &event_id_hex, diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index da09c59d1..f6a826fda 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -25,6 +25,34 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; +/// Increment the rejection counter with a bounded reason label. +fn reject(reason: &'static str) { + metrics::counter!("sprout_events_rejected_total", "reason" => reason).increment(1); +} + +/// Bound the `kind` label to prevent cardinality explosion from arbitrary Nostr kinds. +/// Known Sprout kind ranges pass through; everything else becomes "other". +/// See `sprout_core::kind` for the authoritative registry. +fn bounded_kind_label(kind: u32) -> String { + match kind { + 0..=9 | 1059 | 1063 => kind.to_string(), // NIP-01 core + gift-wrap + file-meta + stream msg + 9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(), // NIP-29 admin + system + 20000..=29999 => kind.to_string(), // Ephemeral (presence, typing) + AUTH (22242) + 30023 | 39000..=39003 => kind.to_string(), // NIP-23 + NIP-29 addressable state + 40002..=40100 => kind.to_string(), // Stream messaging + canvas + system msg + 41001..=41003 => kind.to_string(), // DMs + 42001..=42003 => kind.to_string(), // Topics + 43001..=43006 => kind.to_string(), // Agent jobs + 44001..=44004 | 44100..=44101 => kind.to_string(), // Subscriptions + notifications + 45001..=45003 => kind.to_string(), // Forum + 46001..=46012 => kind.to_string(), // Workflow engine + 47001..=47003 => kind.to_string(), // User groups + 48001..=48005 | 48100..=48105 => kind.to_string(), // Admin + huddle + 49001 => kind.to_string(), // Media upload + _ => "other".to_string(), + } +} + /// Publish a stored event to subscribers and kick off async side effects. pub(crate) async fn dispatch_persistent_event( state: &Arc, @@ -76,8 +104,16 @@ pub(crate) async fn dispatch_persistent_event( let search = Arc::clone(&state.search); let stored_for_search = stored_event.clone(); tokio::spawn(async move { - if let Err(e) = search.index_event(&stored_for_search).await { - error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}"); + let t = std::time::Instant::now(); + match search.index_event(&stored_for_search).await { + Ok(()) => { + metrics::histogram!("sprout_search_index_seconds") + .record(t.elapsed().as_secs_f64()); + } + Err(e) => { + metrics::counter!("sprout_search_index_errors_total").increment(1); + error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}"); + } } }); } @@ -95,8 +131,11 @@ pub(crate) async fn dispatch_persistent_event( channel_id: audit_channel_id, metadata: serde_json::Value::Null, }; + let t = std::time::Instant::now(); if let Err(e) = audit.log(entry).await { error!(event_id = %audit_event_id, "Audit log failed: {e}"); + } else { + metrics::histogram!("sprout_audit_log_seconds").record(t.elapsed().as_secs_f64()); } }); @@ -115,9 +154,13 @@ pub(crate) async fn dispatch_persistent_event( { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); + let trigger_kind = kind_u32.to_string(); tokio::spawn(async move { if let Err(e) = workflow_engine.on_event(&workflow_event).await { tracing::error!(event_id = ?workflow_event.event.id, "Workflow trigger failed: {e}"); + } else { + metrics::counter!("sprout_workflow_runs_total", "trigger" => trigger_kind) + .increment(1); } }); } @@ -127,9 +170,12 @@ pub(crate) async fn dispatch_persistent_event( /// Handle an EVENT message: authenticate, verify, store, fan-out, index, and audit the event. pub async fn handle_event(event: Event, conn: Arc, state: Arc) { + let start = std::time::Instant::now(); let event_id_hex = event.id.to_hex(); let kind_u32 = event_kind_u32(&event); + let kind_str = bounded_kind_label(kind_u32); debug!(event_id = %event_id_hex, kind = kind_u32, "EVENT"); + metrics::counter!("sprout_events_received_total", "kind" => kind_str.clone()).increment(1); let (conn_id, pubkey_hex, pubkey_bytes, auth_pubkey, has_proxy_scope) = { let auth = conn.auth_state.read().await; @@ -155,6 +201,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + reject("auth"); conn.send(RelayMessage::ok( &event_id_hex, false, @@ -172,6 +219,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, state: Arc, state: Arc {} Ok(Err(e)) => { + reject("invalid"); warn!(conn_id = %conn_id, event_id = %event_id_hex, "Verification failed: {e}"); conn.send(RelayMessage::ok( &event_id_hex, @@ -307,6 +358,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc { + reject("error"); error!(conn_id = %conn_id, event_id = %event_id_hex, "DB insert failed: {e}"); conn.send(RelayMessage::ok( &event_id_hex, @@ -473,6 +526,10 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc kind_str).increment(1); + metrics::histogram!("sprout_event_processing_seconds").record(start.elapsed().as_secs_f64()); + metrics::histogram!("sprout_fanout_recipients").record(fan_out as f64); + conn.send(RelayMessage::ok(&event_id_hex, true, "")); info!( diff --git a/crates/sprout-relay/src/lib.rs b/crates/sprout-relay/src/lib.rs index 1868ac7d9..1f2ca8fa8 100644 --- a/crates/sprout-relay/src/lib.rs +++ b/crates/sprout-relay/src/lib.rs @@ -12,6 +12,8 @@ pub mod connection; pub mod error; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; +/// Prometheus metrics: recorder, upkeep, HTTP middleware. +pub mod metrics; /// NIP-11 relay information document. pub mod nip11; /// NIP-01 client/relay message parsing. diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index ee9c8c927..edccebf5f 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::Ordering; use std::sync::Arc; use tracing::{error, info}; @@ -9,13 +10,17 @@ use sprout_db::{Db, DbConfig}; use sprout_pubsub::PubSubManager; use sprout_search::{SearchConfig, SearchService}; -use sprout_relay::{config::Config, router::build_router, state::AppState}; +use sprout_relay::config::Config; +use sprout_relay::metrics as relay_metrics; +use sprout_relay::router::{build_health_router, build_router}; +use sprout_relay::state::AppState; use sprout_workflow::WorkflowEngine; #[tokio::main] async fn main() -> anyhow::Result<()> { + // JSON-only structured logs — simple, machine-parseable, CAKE-compatible. tracing_subscriber::registry() - .with(fmt::layer()) + .with(fmt::layer().json().flatten_event(true)) .with(EnvFilter::from_default_env().add_directive("sprout_relay=info".parse()?)) .init(); @@ -25,7 +30,20 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; - info!(bind_addr = %config.bind_addr, relay_url = %config.relay_url, "Config loaded"); + info!( + bind_addr = %config.bind_addr, + relay_url = %config.relay_url, + health_port = config.health_port, + metrics_port = config.metrics_port, + "Config loaded" + ); + + // ── Metrics recorder (Prometheus exporter on :9102) ────────────────────── + relay_metrics::install(config.metrics_port); + info!( + port = config.metrics_port, + "Prometheus metrics exporter started" + ); let db_config = DbConfig { database_url: config.database_url.clone(), @@ -153,6 +171,7 @@ async fn main() -> anyhow::Result<()> { } let matches = state_for_sub.sub_registry.fan_out(&stored); + metrics::counter!("sprout_multinode_fanout_total").increment(1); if matches.is_empty() { continue; } @@ -182,6 +201,7 @@ async fn main() -> anyhow::Result<()> { } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("sprout_multinode_fanout_lag_total").increment(n); tracing::warn!("Multi-node fan-out lagged by {n} messages"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { @@ -194,21 +214,68 @@ async fn main() -> anyhow::Result<()> { } let router = build_router(Arc::clone(&state)); + let health_router = build_health_router(Arc::clone(&state)); - serve_dual(router, &config).await + serve(router, health_router, Arc::clone(&state)).await } -/// Bind TCP (always) and optionally UDS, then run both concurrently. -/// If either listener exits, the function returns and the process exits. -async fn serve_dual(router: axum::Router, config: &Config) -> anyhow::Result<()> { +/// Bind all listeners and run with graceful shutdown. +/// +/// ```text +/// ┌─────────────────────────────────────────────────────────┐ +/// │ Listener 1: TCP SPROUT_BIND_ADDR:3000 (app router) │ +/// │ Listener 2: UDS SPROUT_UDS_PATH (app, optional)│ +/// │ Listener 3: TCP 0.0.0.0:8080 (health only) │ +/// │ Listener 4: TCP 0.0.0.0:9102 (metrics, via │ +/// │ PrometheusBuilder — already bound) │ +/// │ │ +/// │ SIGTERM → shutting_down=true → readiness 503 │ +/// │ → graceful drain (30s) → exit │ +/// └─────────────────────────────────────────────────────────┘ +/// ``` +async fn serve( + router: axum::Router, + health_router: axum::Router, + state: Arc, +) -> anyhow::Result<()> { + let config = &state.config; + + // ── Health listener (port 8080) ────────────────────────────────────────── + let health_listener = tokio::net::TcpListener::bind(("0.0.0.0", config.health_port)) + .await + .map_err(|e| anyhow::anyhow!("Failed to bind health port {}: {e}", config.health_port))?; + info!(port = config.health_port, "Health probe listener started"); + tokio::spawn(async move { + axum::serve(health_listener, health_router).await.ok(); + }); + + // ── Shutdown coordination ──────────────────────────────────────────────── + let (shutdown_tx, _) = tokio::sync::watch::channel(false); + let shutdown_flag = Arc::clone(&state.shutting_down); + let tx = shutdown_tx.clone(); + tokio::spawn(async move { + shutdown_signal().await; + shutdown_flag.store(true, Ordering::Relaxed); + info!("Shutdown signal received — readiness now returns 503"); + // 5s grace: let K8s stop routing new traffic before we close listeners. + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + info!("Starting graceful drain (30s timeout)"); + let _ = tx.send(true); + // Hard timeout: force exit if connections don't drain within 30s. + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + tracing::error!("Drain timeout exceeded — forcing exit"); + std::process::exit(1); + }); + + // ── App listener (TCP) ─────────────────────────────────────────────────── let tcp_listener = tokio::net::TcpListener::bind(&config.bind_addr) .await .map_err(|e| anyhow::anyhow!("Failed to bind {}: {e}", config.bind_addr))?; info!(addr = %config.bind_addr, "sprout-relay TCP listening"); + // ── App listener (UDS, optional) ───────────────────────────────────────── #[cfg(unix)] if let Some(ref uds_path) = config.uds_path { - // Only remove stale socket files — refuse to delete non-socket paths. use std::os::unix::fs::FileTypeExt as _; match std::fs::symlink_metadata(uds_path) { Ok(meta) if meta.file_type().is_socket() => { @@ -216,42 +283,74 @@ async fn serve_dual(router: axum::Router, config: &Config) -> anyhow::Result<()> } Ok(_) => { return Err(anyhow::anyhow!( - "SPROUT_UDS_PATH {uds_path} exists but is not a socket — refusing to delete" + "SPROUT_UDS_PATH {uds_path} exists but is not a socket" )); } - Err(_) => {} // Path doesn't exist — fine, we'll create it + Err(_) => {} } let uds_listener = tokio::net::UnixListener::bind(uds_path) .map_err(|e| anyhow::anyhow!("Failed to bind UDS {uds_path}: {e}"))?; info!(path = %uds_path, "sprout-relay UDS listening"); let router_uds = router.clone(); - tokio::select! { - r = axum::serve( - tcp_listener, - router.into_make_service_with_connect_info::(), - ) => r.map_err(|e| anyhow::anyhow!("TCP server error: {e}")), - r = axum::serve(uds_listener, router_uds.into_make_service()) => - r.map_err(|e| anyhow::anyhow!("UDS server error: {e}")), - }?; + let mut uds_rx = shutdown_tx.subscribe(); + let uds_handle = tokio::spawn(async move { + axum::serve(uds_listener, router_uds.into_make_service()) + .with_graceful_shutdown(async move { + uds_rx.changed().await.ok(); + }) + .await + .ok(); + }); + + let mut tcp_rx = shutdown_tx.subscribe(); + axum::serve( + tcp_listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + tcp_rx.changed().await.ok(); + }) + .await + .map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?; + + uds_handle.abort(); return Ok(()); } #[cfg(not(unix))] if config.uds_path.is_some() { - tracing::warn!( - "SPROUT_UDS_PATH is set but UDS is not supported on this platform — \ - falling back to TCP only" - ); + tracing::warn!("SPROUT_UDS_PATH set but UDS not supported on this platform"); } - // TCP-only path (non-unix, or unix without uds_path set). + // TCP-only path. + let mut tcp_rx = shutdown_tx.subscribe(); axum::serve( tcp_listener, router.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(async move { + tcp_rx.changed().await.ok(); + }) .await .map_err(|e| anyhow::anyhow!("Server error: {e}"))?; Ok(()) } + +/// Wait for SIGTERM (Unix) or Ctrl+C. +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.ok(); + } +} diff --git a/crates/sprout-relay/src/metrics.rs b/crates/sprout-relay/src/metrics.rs new file mode 100644 index 000000000..940397bd3 --- /dev/null +++ b/crates/sprout-relay/src/metrics.rs @@ -0,0 +1,125 @@ +//! Prometheus metrics: recorder setup, upkeep task, and HTTP middleware. +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────┐ +//! │ metrics-rs facade (metrics::counter!, histogram!, etc.) │ +//! │ ↓ │ +//! │ PrometheusBuilder → HTTP listener on :9102 │ +//! │ ↓ │ +//! │ GET /metrics → Prometheus text format │ +//! └──────────────────────────────────────────────────────────┘ +//! ``` +//! +//! Framework metrics (`http_requests_total`, `http_request_latency_ms`) are +//! recorded by [`track_metrics`] middleware on the app router. Sprout-specific +//! metrics are recorded inline at their call sites. + +use std::time::Instant; + +use axum::{ + extract::{MatchedPath, Request}, + middleware::Next, + response::Response, +}; +use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; + +/// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. +const LATENCY_BUCKETS_MS: [f64; 11] = [ + 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0, +]; + +/// Seconds-scale buckets for internal processing histograms (event, search, audit). +const DURATION_BUCKETS_S: [f64; 10] = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]; + +/// Integer-count buckets for fan-out recipient histograms. +const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0]; + +/// Install the global metrics recorder and spawn the Prometheus HTTP exporter. +/// +/// `build()` returns the recorder + exporter future and internally spawns +/// the upkeep task, so no separate upkeep call is needed. +/// +/// Must be called from within a Tokio runtime. +/// Panics if a recorder is already installed or the port is in use. +pub fn install(port: u16) { + let (recorder, exporter) = PrometheusBuilder::new() + .with_http_listener(([0, 0, 0, 0], port)) + // Per-metric buckets: ms for HTTP latency, seconds for internal processing. + .set_buckets_for_metric( + Matcher::Full("http_request_latency_ms".to_owned()), + &LATENCY_BUCKETS_MS, + ) + .expect("valid ms bucket boundaries") + .set_buckets_for_metric(Matcher::Suffix("_seconds".to_owned()), &DURATION_BUCKETS_S) + .expect("valid seconds bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("sprout_fanout_recipients".to_owned()), + &FANOUT_BUCKETS, + ) + .expect("valid fanout bucket boundaries") + .build() + .expect("metrics exporter must build exactly once"); + + metrics::set_global_recorder(recorder).expect("global recorder must be set exactly once"); + tokio::spawn(exporter); +} + +/// Axum middleware that records CAKE framework HTTP metrics. +/// +/// Emits: +/// - `http_requests_total{code, caller, action}` — counter +/// - `http_request_latency_ms{code, caller, action}` — histogram +/// +/// Skips health/metrics paths (`/_*`, `/health`) to avoid polluting dashboards. +/// +/// Labels: +/// - `code`: exact HTTP status code (e.g. "200", "404") +/// - `caller`: upstream service from Istio `x-envoy-downstream-service-cluster` header +/// - `action`: matched route pattern (e.g. `/api/channels/{channel_id}`) +pub async fn track_metrics(req: Request, next: Next) -> Response { + // Use the route pattern (e.g. "/api/channels/{channel_id}"), NOT the raw URI. + // Falling back to raw URI on 404s would create unbounded cardinality from scanners. + let path = req + .extensions() + .get::() + .map(|p| p.as_str().to_owned()); + + // Skip health probes, metrics endpoint, and unmatched paths (404 scanners). + match path.as_deref() { + Some(p) if p.starts_with("/_") || p == "/health" || p == "/metrics" => { + return next.run(req).await; + } + None => { + // No matched route — 404/scanner traffic. Skip to avoid cardinality bomb. + return next.run(req).await; + } + _ => {} + } + let action = path.unwrap(); // safe: None case returned above + + // Caller from Istio header. In CAKE, this is set by the mesh (trusted). + // On the public TCP listener it's client-controlled, so validate format: + // only accept short alphanumeric-with-hyphens service names. + let caller = req + .headers() + .get("x-envoy-downstream-service-cluster") + .and_then(|v| v.to_str().ok()) + .filter(|s| { + s.len() <= 64 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) + .unwrap_or("unknown") + .to_owned(); + + let start = Instant::now(); + let response = next.run(req).await; + let status = response.status().as_u16().to_string(); + let latency_ms = start.elapsed().as_secs_f64() * 1000.0; + + let labels = [("code", status), ("caller", caller), ("action", action)]; + metrics::counter!("http_requests_total", &labels).increment(1); + metrics::histogram!("http_request_latency_ms", &labels).record(latency_ms); + + response +} diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 79956af1a..65c930d6a 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -1,10 +1,12 @@ -//! axum router — WebSocket, NIP-11, NIP-05, health. +//! axum routers — app (WebSocket + REST), health (K8s probes), metrics (Prometheus). +use std::sync::atomic::Ordering; use std::sync::Arc; use axum::{ extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode}, + middleware, response::{IntoResponse, Json}, routing::{delete, get, post, put}, Router, @@ -17,6 +19,7 @@ use tower_http::trace::TraceLayer; use crate::api; use crate::api::tokens; use crate::connection::handle_connection; +use crate::metrics::track_metrics; use crate::nip11::{relay_info_handler, RelayInfo}; use crate::state::AppState; @@ -46,6 +49,8 @@ pub fn build_router(state: Arc) -> Router { .route("/", get(nip11_or_ws_handler)) .route("/info", get(relay_info_handler)) .route("/.well-known/nostr.json", get(api::nip05::nostr_nip05)) + // Health endpoints remain on the app router for backward compat (local dev). + // In CAKE, probes hit the dedicated health port (8080) instead. .route("/health", get(health_handler)) .route("/_liveness", get(liveness_handler)) .route("/_readiness", get(readiness_handler)) @@ -171,13 +176,26 @@ pub fn build_router(state: Arc) -> Router { .with_state(state.clone()); // Merge — each sub-router carries its own body limit. - // TraceLayer and CORS are applied once over the combined router. + // Metrics → Trace → CORS applied once over the combined router. api_router .merge(media_router) + .layer(middleware::from_fn(track_metrics)) .layer(TraceLayer::new_for_http()) .layer(build_cors_layer(&state.config.cors_origins)) } +/// Build the health-only router for K8s probes (port 8080 in CAKE). +/// +/// No metrics middleware, no auth, no CORS, no body limit. +/// Separate from the app router so probes bypass Istio and don't pollute metrics. +pub fn build_health_router(state: Arc) -> Router { + Router::new() + .route("/_liveness", get(liveness_handler)) + .route("/_readiness", get(readiness_handler)) + .route("/_status", get(status_handler)) + .with_state(state) +} + /// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise. /// /// Uses `axum::extract::Request` to manually attempt WS upgrade, so non-WS @@ -229,17 +247,22 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Readiness probe — checks MySQL and Redis connectivity. +/// Readiness probe — checks shutdown flag, MySQL, and Redis connectivity. /// -/// Returns 200 `{"status":"ready"}` when both are reachable, or -/// 503 `{"status":"not_ready","mysql":bool,"redis":bool}` otherwise. -/// -/// Note: Redis pool exhaustion (all connections in use) also returns 503. -/// This is intentionally conservative — if we can't acquire a connection, -/// the pod shouldn't receive traffic. +/// Returns 503 immediately during graceful shutdown (SIGTERM received). +/// Otherwise returns 200 when both backends are reachable, or 503 with details. async fn readiness_handler(State(state): State>) -> impl IntoResponse { use std::time::Duration; + // CAKE: readiness must return 503 after graceful shutdown begins. + if state.shutting_down.load(Ordering::Relaxed) { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"status": "shutting_down"})), + ) + .into_response(); + } + let check = async { let (mysql_ok, redis_ok) = tokio::join!(state.db.ping(), async { state.redis_pool.get().await.is_ok() @@ -262,6 +285,16 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } +/// Status endpoint — service name, version, uptime. Optional per CAKE contract. +async fn status_handler(State(state): State>) -> impl IntoResponse { + let uptime_secs = state.started_at.elapsed().as_secs(); + Json(json!({ + "service": "sprout-relay", + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": uptime_secs, + })) +} + /// Build a CORS layer from the configured origins list. /// /// If `cors_origins` is empty (dev default), returns a permissive layer. diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 985c4e842..0cf19b7c8 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -1,6 +1,6 @@ //! Shared application state — Arc-wrapped, shared across all connections. -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::time::Instant; @@ -87,6 +87,7 @@ impl ConnectionManager { let count = conn.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; if count >= SLOW_CLIENT_GRACE_LIMIT { tracing::warn!(conn_id = %conn_id, count, "fan-out: sustained backpressure — cancelling slow client"); + metrics::counter!("sprout_ws_backpressure_disconnects_total").increment(1); conn.cancel.cancel(); } else { tracing::warn!(conn_id = %conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "fan-out: send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); @@ -152,6 +153,10 @@ pub struct AppState { pub local_event_ids: Arc>, /// Media storage client (S3/MinIO). pub media_storage: Arc, + /// Set to `true` on SIGTERM — readiness probe returns 503. + pub shutting_down: Arc, + /// Process start time — used by `/_status` endpoint. + pub started_at: Instant, } impl AppState { @@ -194,6 +199,8 @@ impl AppState { .build(), ), media_storage: Arc::new(media_storage), + shutting_down: Arc::new(AtomicBool::new(false)), + started_at: Instant::now(), } } diff --git a/crates/sprout-relay/src/subscription.rs b/crates/sprout-relay/src/subscription.rs index 60c50e269..85cebdfba 100644 --- a/crates/sprout-relay/src/subscription.rs +++ b/crates/sprout-relay/src/subscription.rs @@ -55,6 +55,7 @@ impl SubscriptionRegistry { .entry(conn_id) .or_default() .insert(sub_id.clone(), (filters.clone(), channel_id)); + metrics::gauge!("sprout_subscriptions_active").increment(1.0); if let Some(ch_id) = channel_id { match extract_kinds_from_filters(&filters) { @@ -93,6 +94,7 @@ impl SubscriptionRegistry { if let Some(mut conn_subs) = self.subs.get_mut(&conn_id) { if let Some((filters, channel_id)) = conn_subs.remove(sub_id) { self.remove_from_index(conn_id, sub_id, &filters, channel_id); + metrics::gauge!("sprout_subscriptions_active").decrement(1.0); } } } @@ -100,9 +102,11 @@ impl SubscriptionRegistry { /// Remove all subscriptions for a connection and clean up index entries. pub fn remove_connection(&self, conn_id: ConnId) { if let Some((_, conn_subs)) = self.subs.remove(&conn_id) { + let count = conn_subs.len(); for (sub_id, (filters, channel_id)) in &conn_subs { self.remove_from_index(conn_id, sub_id, filters, *channel_id); } + metrics::gauge!("sprout_subscriptions_active").decrement(count as f64); } } diff --git a/docker-compose.yml b/docker-compose.yml index 53f425634..8b4f31a6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,6 +174,27 @@ services: com.sprout.env: "dev" restart: "no" + prometheus: + image: prom/prometheus:latest + container_name: sprout-prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - sprout-net + extra_hosts: + - "host.docker.internal:host-gateway" + deploy: + resources: + limits: + memory: 128m + labels: + com.sprout.service: "prometheus" + com.sprout.env: "dev" + restart: unless-stopped + volumes: mysql-data: name: sprout-mysql-data @@ -187,6 +208,10 @@ volumes: name: sprout-minio-data labels: com.sprout.volume: "minio" + prometheus-data: + name: sprout-prometheus-data + labels: + com.sprout.volume: "prometheus" networks: sprout-net: diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 000000000..770329912 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,10 @@ +# Local dev Prometheus config — scrapes sprout-relay metrics. +# The relay runs on the host (not in Docker), so we use host.docker.internal. +# Default metrics port is 9102; override with SPROUT_METRICS_PORT. +global: + scrape_interval: 5s + +scrape_configs: + - job_name: sprout-relay + static_configs: + - targets: ["host.docker.internal:9102"] diff --git a/script/start b/script/start new file mode 100755 index 000000000..d4b91df2f --- /dev/null +++ b/script/start @@ -0,0 +1,26 @@ +#!/bin/sh +# CAKE entrypoint — bridge Istio abstract socket to file socket, then exec relay. +# +# Traffic flow: +# Client → Envoy → @istio-proxy.sock (abstract) → socat → /tmp/local.sock → Sprout +# K8s probes → 0.0.0.0:8080 → Sprout (direct, no Istio) +# Prometheus → 0.0.0.0:9102/metrics → Sprout +set -eu + +SIDECAR_SOCKET="${SIDECAR_LISTENER_BIND:-@istio-proxy.sock}" +LOCAL_SOCKET="/tmp/local.sock" + +# Clean up stale socket file from previous run. +rm -f "${LOCAL_SOCKET}" + +# Bridge: Envoy writes to abstract socket → socat → app reads from file socket. +# Only start if SIDECAR_LISTENER_BIND is set (i.e., running in CAKE). +if [ -n "${SIDECAR_LISTENER_BIND:-}" ]; then + socat "ABSTRACT-LISTEN:${SIDECAR_SOCKET#@},fork" "UNIX-CONNECT:${LOCAL_SOCKET}" & + export SPROUT_UDS_PATH="${LOCAL_SOCKET}" +fi + +export SPROUT_HEALTH_PORT="${SPROUT_HEALTH_PORT:-8080}" +export SPROUT_METRICS_PORT="${SPROUT_METRICS_PORT:-9102}" + +exec /code/sprout-relay