Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
6 changes: 5 additions & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
51 changes: 32 additions & 19 deletions api/src/indexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,17 +117,23 @@ 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<Snapshot>,
inner: Arc<ArcSwap<Snapshot>>,
pub config: Arc<Config>,
pub metrics: PrometheusHandle,
}

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,
}
Expand All @@ -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<Snapshot>` 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) {
Expand All @@ -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<Duration>, body: String },
RateLimited {
status: u16,
retry_after: Option<Duration>,
body: String,
},
}

impl IndexError {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
}

Expand Down
17 changes: 14 additions & 3 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<dyn tracing_subscriber::Layer<_> + 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();
}
2 changes: 1 addition & 1 deletion api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>, req: Request<Body>, next: Next) -> Response {
let ledger = state.last_indexed_ledger().await;
let ledger = state.last_indexed_ledger();
let etag = format!("\"ledger-{ledger}\"");

let fresh = req
Expand Down
4 changes: 4 additions & 0 deletions docs/.env.example
Original file line number Diff line number Diff line change
@@ -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