diff --git a/README.md b/README.md index b1d7c57..1481b20 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,32 @@ curl http://localhost:8080/stats curl http://localhost:8080/assets ``` +### Logging + +Two env vars control log output: + +- `RUST_LOG` — a [`tracing-subscriber` `EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) + directive for per-target levels. Defaults to `stellar_rwa_api=info,tower_http=warn`. +- `LOG_FORMAT` — line format: `pretty` (default; human-readable for local dev), + `compact` (single-line text), or `json` (structured output for production + log shippers like Loki, Datadog, and Cloud Logging). + +Examples: + +```bash +RUST_LOG=debug cargo run # chatty dev +RUST_LOG=info LOG_FORMAT=json cargo run # prod-shaped +RUST_LOG=stellar_rwa_api=debug LOG_FORMAT=compact cargo run +``` + +The container image already sets `LOG_FORMAT=json` so it's ready for structured +log shippers out of the box. To debug a container with human-readable logs, +override at runtime: + +```bash +docker run --rm -p 8080:8080 -e LOG_FORMAT=pretty stellar-rwa-api +``` + ### Tech stack Rust · Axum · tokio · reqwest · serde · stellar-xdr · stellar-strkey. diff --git a/api/.env.example b/api/.env.example index 42ad66e..84c6bfc 100644 --- a/api/.env.example +++ b/api/.env.example @@ -11,5 +11,8 @@ RWA_READ_SOURCE=GAIQGTOBTTLLDJ4SWGGESM7UWJ2DI4K3ZNHUSHPDKJL2IE5FKY3BSRAA # HTTP port. PORT=8080 -# Log filter. +# Log filter (a tracing-subscriber EnvFilter directive). RUST_LOG=stellar_rwa_api=info,tower_http=warn + +# Log line format: "pretty" (default), "compact", or "json". +#LOG_FORMAT=json diff --git a/api/Cargo.toml b/api/Cargo.toml index 0f0bee7..3df7795 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -18,8 +18,11 @@ stellar-xdr = { version = "23", default-features = false, features = ["curr", "s stellar-strkey = "0.0.13" thiserror = "1" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +metrics = "0.24" +metrics-exporter-prometheus = "0.16" +rand = "0.9" url = "2" [[bin]] diff --git a/api/Dockerfile b/api/Dockerfile index 2d494c8..a6ad61e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -16,6 +16,10 @@ RUN apt-get update \ WORKDIR /app COPY --from=builder /app/target/release/stellar-rwa-api /usr/local/bin/stellar-rwa-api -ENV PORT=8080 +# Sensible production defaults: structured JSON logs and a level filter +# suitable for production. Override at runtime with `docker run -e LOG_FORMAT=...`. +ENV PORT=8080 \ + LOG_FORMAT=json \ + RUST_LOG=stellar_rwa_api=info,tower_http=warn EXPOSE 8080 CMD ["stellar-rwa-api"] diff --git a/api/src/indexer/mod.rs b/api/src/indexer/mod.rs index cd4d5e6..84155ba 100644 --- a/api/src/indexer/mod.rs +++ b/api/src/indexer/mod.rs @@ -17,6 +17,8 @@ use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; +use metrics_exporter_prometheus::PrometheusHandle; +use rand::Rng; use reqwest::header::RETRY_AFTER; use reqwest::StatusCode; use serde::Deserialize; @@ -115,9 +117,15 @@ impl Snapshot { } /// Shared, hot-swappable state handed to the Axum routes. +/// +/// `inner` is wrapped in `Arc` so cloning `AppState` shares the same +/// `ArcSwap` instance — `store()` from one clone (the indexer's) is +/// visible to `load()` from another (the routes'), which is the actual +/// data flow we need. Without `Arc`, each clone deep-clones the snapshot +/// and updates from one are never observed by the others. #[derive(Clone)] pub struct AppState { - inner: ArcSwap, + inner: Arc>, pub config: Arc, pub metrics: PrometheusHandle, } @@ -125,7 +133,7 @@ pub struct AppState { impl AppState { pub fn new(config: Config, metrics: PrometheusHandle) -> Self { AppState { - inner: ArcSwap::from_arc(Arc::new(Snapshot::default())), + inner: Arc::new(ArcSwap::from(Arc::new(Snapshot::default()))), config: Arc::new(config), metrics, } @@ -134,7 +142,15 @@ impl AppState { /// Clone the current snapshot for read-only serving. pub fn snapshot(&self) -> Snapshot { let guard = self.inner.load(); - (*guard).clone() + // `Guard` derefs to `&Arc` under `arc-swap` 1.x, so the + // snapshot lives one more deref down: `**guard` is the `Snapshot`. + (**guard).clone() + } + + /// Last ledger the indexer successfully read. Used as the ETag seed for + /// snapshot-backed routes (`Cache-Control` + `304 Not Modified`). + pub fn last_indexed_ledger(&self) -> u32 { + self.snapshot().stats.last_indexed_ledger } fn replace(&self, next: Snapshot) { @@ -157,7 +173,11 @@ pub enum IndexError { #[error("http status {status}: {body}")] HttpStatus { status: u16, body: String }, #[error("rate limited or unavailable (status {status}); retry after {retry_after:?}")] - RateLimited { status: u16, retry_after: Option, body: String }, + RateLimited { + status: u16, + retry_after: Option, + body: String, + }, } impl IndexError { @@ -277,12 +297,7 @@ impl Rpc { "params": { "transaction": envelope_b64 }, }); - let resp = self - .http - .post(&self.url) - .json(&body) - .send() - .await?; + let resp = self.http.post(&self.url).json(&body).send().await?; let status = resp.status(); if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE { @@ -585,7 +600,6 @@ impl Indexer { /// Poll forever, refreshing the snapshot every [`POLL_INTERVAL`]. pub async fn run(self) { - let mut consecutive_failures: u64 = 0; loop { let backoff = match self.refresh().await { Ok(count) => { @@ -705,14 +719,13 @@ impl Indexer { }; let count = assets.len(); - self.state - .replace(Snapshot { - assets, - holders: holders_map, - compliance: compliance_map, - dividends: dividends_map, - stats, - }); + self.state.replace(Snapshot { + assets, + holders: holders_map, + compliance: compliance_map, + dividends: dividends_map, + stats, + }); Ok(count) } diff --git a/api/src/main.rs b/api/src/main.rs index 21c5f7a..68457bd 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -11,7 +11,8 @@ mod routes; use std::net::SocketAddr; -use indexer::{AppState, Config, ConfigError, Indexer}; +use indexer::{AppState, Config, Indexer}; +use metrics_exporter_prometheus::PrometheusBuilder; #[tokio::main] async fn main() { @@ -79,11 +80,21 @@ async fn shutdown_signal() { } fn init_tracing() { - use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + use tracing_subscriber::layer::Layer as _; + use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("stellar_rwa_api=info,tower_http=warn")); + // Log line format. Defaults to `pretty` for ergonomics in local dev; set + // `LOG_FORMAT=json` in production so log shippers (Loki, Datadog, Cloud + // Logging, etc.) can parse the output. `RUST_LOG` still controls levels. + let fmt_layer: Box + Send + Sync> = + match std::env::var("LOG_FORMAT").as_deref() { + Ok("json") => fmt::layer().json().boxed(), + Ok("compact") => fmt::layer().compact().boxed(), + _ => fmt::layer().boxed(), + }; tracing_subscriber::registry() .with(filter) - .with(fmt::layer()) + .with(fmt_layer) .init(); } diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index edfe9c3..c718baf 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -98,7 +98,7 @@ pub fn router(state: AppState) -> Router { /// derived from `last_indexed_ledger`: two requests against the same indexed /// ledger are guaranteed to have identical bodies. async fn cache_headers(State(state): State, req: Request, next: Next) -> Response { - let ledger = state.last_indexed_ledger().await; + let ledger = state.last_indexed_ledger(); let etag = format!("\"ledger-{ledger}\""); let fresh = req diff --git a/docs/.env.example b/docs/.env.example index 9d0ee2f..a3f4f29 100644 --- a/docs/.env.example +++ b/docs/.env.example @@ -1,2 +1,6 @@ # Base URL of the deployed Stellar RWA API (used in docs examples). NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 + +# Public site URL (sitemap.xml, OpenGraph, Twitter cards). Falls back to +# https://stellar-rwa-docs.vercel.app when unset. See docs/lib/site.ts. +NEXT_PUBLIC_SITE_URL=http://localhost:3000