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
82 changes: 82 additions & 0 deletions faze-server/src/routes/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub struct ListTracesQuery {
pub struct ListLogsQuery {
/// Filter by service name
pub service: Option<String>,
/// Return only logs correlated with this trace (ignores other filters)
pub trace_id: Option<String>,
/// Maximum number of results
pub limit: Option<usize>,
}
Expand Down Expand Up @@ -81,3 +83,83 @@ impl From<&faze::Trace> for TraceInfo {
}
}
}

/// Response for the aggregate stats endpoint
#[derive(Debug, Serialize)]
pub struct StatsResponse {
/// Total number of stored spans.
pub spans: i64,
/// Total number of stored logs.
pub logs: i64,
/// Total number of stored metrics.
pub metrics: i64,
/// Global trace aggregates.
pub traces: TraceStatsDto,
/// Per-service trace counts, most active first.
pub services: Vec<ServiceStatDto>,
/// Time-bucketed trace activity, oldest bucket first.
pub activity: Vec<TimeBucketDto>,
}

/// Global trace aggregates.
#[derive(Debug, Serialize)]
pub struct TraceStatsDto {
/// Total number of distinct traces.
pub total: i64,
/// Number of traces containing at least one error span.
pub errors: i64,
/// Mean trace duration in milliseconds.
pub avg_duration_ms: f64,
}

impl From<faze::storage::TraceStats> for TraceStatsDto {
fn from(stats: faze::storage::TraceStats) -> Self {
Self {
total: stats.total,
errors: stats.errors,
avg_duration_ms: stats.avg_duration_ms,
}
}
}

/// Per-service trace counts.
#[derive(Debug, Serialize)]
pub struct ServiceStatDto {
/// Service name.
pub name: String,
/// Number of traces attributed to the service.
pub trace_count: i64,
/// Number of those traces containing at least one error span.
pub error_count: i64,
}

impl From<faze::storage::ServiceStat> for ServiceStatDto {
fn from(stat: faze::storage::ServiceStat) -> Self {
Self {
name: stat.name,
trace_count: stat.trace_count,
error_count: stat.error_count,
}
}
}

/// One time bucket of trace activity.
#[derive(Debug, Serialize)]
pub struct TimeBucketDto {
/// Bucket start as nanoseconds since the Unix epoch.
pub bucket_start_unix_nano: i64,
/// Number of traces starting in this bucket.
pub total: i64,
/// Number of those traces containing at least one error span.
pub errors: i64,
}

impl From<faze::storage::TraceTimeBucket> for TimeBucketDto {
fn from(bucket: faze::storage::TraceTimeBucket) -> Self {
Self {
bucket_start_unix_nano: bucket.bucket_start_unix_nano,
total: bucket.total,
errors: bucket.errors,
}
}
}
120 changes: 119 additions & 1 deletion faze-server/src/routes/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! The API route handlers.

use super::AppState;
use super::dto::{ListLogsQuery, ListMetricsQuery, ListTracesQuery, TraceInfo, TraceListResponse};
use super::dto::{
ListLogsQuery, ListMetricsQuery, ListTracesQuery, StatsResponse, TraceInfo, TraceListResponse,
};
use super::error::ApiError;
use axum::{
Json,
Expand Down Expand Up @@ -74,6 +76,11 @@ pub async fn list_logs(
) -> Result<impl IntoResponse, ApiError> {
info!("GET /api/logs - params: {:?}", params);

if let Some(trace_id) = params.trace_id.as_deref() {
let logs = state.storage.get_logs_by_trace_id(trace_id)?;
return Ok(Json(logs));
}

let limit = params.limit.unwrap_or(100).min(1000);
let logs = state
.storage
Expand Down Expand Up @@ -114,6 +121,34 @@ pub async fn list_metrics(
})))
}

/// Number of buckets returned by the stats activity timeline.
const ACTIVITY_BUCKETS: usize = 30;

/// GET /api/stats - Aggregate statistics across all stored telemetry
pub async fn get_stats(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
info!("GET /api/stats");

let storage = &state.storage;
let response = StatsResponse {
spans: storage.count_spans()?,
logs: storage.count_logs()?,
metrics: storage.count_metrics()?,
traces: storage.trace_stats()?.into(),
services: storage
.service_stats()?
.into_iter()
.map(Into::into)
.collect(),
activity: storage
.trace_time_buckets(ACTIVITY_BUCKETS)?
.into_iter()
.map(Into::into)
.collect(),
};

Ok(Json(response))
}

/// GET /health - Health check endpoint
pub async fn health_check() -> impl IntoResponse {
Json(serde_json::json!({
Expand Down Expand Up @@ -386,6 +421,56 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_get_stats_empty() {
let storage = Storage::new_in_memory().unwrap();
let state = AppState {
storage: Arc::new(storage),
};

let response = get_stats(State(state)).await.into_response();

assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_get_stats_with_data() {
let storage = Storage::new_in_memory().unwrap();
let ok_span = Span::new(
"span1".to_string(),
"trace1".to_string(),
None,
"ok operation".to_string(),
SpanKind::Server,
1_000_000_000,
2_000_000_000,
Attributes::new(),
Status::ok(),
Some("service-a".to_string()),
);
let error_span = Span::new(
"span2".to_string(),
"trace2".to_string(),
None,
"failing operation".to_string(),
SpanKind::Server,
3_000_000_000,
4_000_000_000,
Attributes::new(),
Status::error("boom"),
Some("service-b".to_string()),
);
storage.insert_spans(&[ok_span, error_span]).unwrap();

let state = AppState {
storage: Arc::new(storage),
};

let response = get_stats(State(state)).await.into_response();

assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_list_logs_empty() {
let storage = Storage::new_in_memory().unwrap();
Expand All @@ -395,6 +480,39 @@ mod tests {

let query = ListLogsQuery {
service: None,
trace_id: None,
limit: None,
};

let response = list_logs(State(state), Query(query)).await.into_response();

assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_list_logs_by_trace_id() {
use faze::models::{Log, SeverityLevel};

let storage = Storage::new_in_memory().unwrap();
let log = Log::new(
1_000_000_000,
SeverityLevel::Info,
None,
"correlated log".to_string(),
Attributes::new(),
Some("trace1".to_string()),
Some("span1".to_string()),
Some("test-service".to_string()),
);
storage.insert_log(&log).unwrap();

let state = AppState {
storage: Arc::new(storage),
};

let query = ListLogsQuery {
service: None,
trace_id: Some("trace1".to_string()),
limit: None,
};

Expand Down
3 changes: 2 additions & 1 deletion faze-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ mod error;
mod handlers;

pub use handlers::{
get_project_info, get_trace, health_check, list_logs, list_metrics, list_services, list_traces,
get_project_info, get_stats, get_trace, health_check, list_logs, list_metrics, list_services,
list_traces,
};

use faze::Storage;
Expand Down
37 changes: 36 additions & 1 deletion faze-server/src/server.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
routes::{
AppState, get_project_info, get_trace, health_check, list_logs, list_metrics,
AppState, get_project_info, get_stats, get_trace, health_check, list_logs, list_metrics,
list_services, list_traces,
},
ui,
Expand Down Expand Up @@ -42,6 +42,7 @@ impl ApiServer {
.route("/api/logs", get(list_logs))
.route("/api/services", get(list_services))
.route("/api/metrics", get(list_metrics))
.route("/api/stats", get(get_stats))
.layer(CorsLayer::permissive())
.with_state(state)
.fallback(ui::fallback_service())
Expand Down Expand Up @@ -72,6 +73,7 @@ impl ApiServer {
" List services: http://localhost:{}/api/services",
self.port
);
info!(" Stats: http://localhost:{}/api/stats", self.port);

let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Expand Down Expand Up @@ -193,4 +195,37 @@ mod tests {

assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_stats_endpoint() {
let storage = Storage::new_in_memory().unwrap();
let span = Span::new(
"span1".to_string(),
"trace1".to_string(),
None,
"test".to_string(),
SpanKind::Server,
1_000_000_000,
2_000_000_000,
Attributes::new(),
Status::ok(),
Some("test-service".to_string()),
);
storage.insert_span(&span).unwrap();

let server = ApiServer::new(storage, 0);
let app = server.build_router();

let response = app
.oneshot(
Request::builder()
.uri("/api/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
}
}
53 changes: 53 additions & 0 deletions faze/src/storage/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ impl Storage {
Ok(logs)
}

/// Get all logs correlated with a trace, oldest first
#[allow(clippy::significant_drop_tightening)]
pub fn get_logs_by_trace_id(&self, trace_id: &str) -> Result<Vec<Log>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(
"SELECT time_unix_nano, severity_level, severity_text, body,
attributes, trace_id, span_id, service_name
FROM logs
WHERE trace_id = ?1
ORDER BY time_unix_nano",
)?;

let logs = stmt
.query_map([trace_id], log_from_row)?
.collect::<SqliteResult<Vec<_>>>()?;

Ok(logs)
}

/// Get count of logs
pub fn count_logs(&self) -> Result<i64> {
self.count_rows("logs")
Expand Down Expand Up @@ -94,4 +113,38 @@ mod tests {
assert_eq!(logs.len(), 1);
assert_eq!(logs[0].body, "Test log");
}

#[test]
fn test_get_logs_by_trace_id() {
let storage = Storage::new_in_memory().unwrap();
let make_log = |time: i64, body: &str, trace_id: Option<&str>| {
Log::new(
time,
SeverityLevel::Info,
None,
body.to_string(),
Attributes::new(),
trace_id.map(String::from),
None,
Some("test-service".to_string()),
)
};

storage
.insert_logs(&[
make_log(3_000, "late", Some("trace1")),
make_log(1_000, "early", Some("trace1")),
make_log(2_000, "other trace", Some("trace2")),
make_log(4_000, "uncorrelated", None),
])
.unwrap();

let logs = storage.get_logs_by_trace_id("trace1").unwrap();

assert_eq!(logs.len(), 2);
assert_eq!(logs[0].body, "early");
assert_eq!(logs[1].body, "late");

assert!(storage.get_logs_by_trace_id("missing").unwrap().is_empty());
}
}
Loading
Loading