Skip to main content

otlp_embedded/jaeger/
ui.rs

1use std::cmp::Reverse;
2
3use axum::{
4    Extension, Json, Router,
5    extract::{Path, Query},
6    http::{StatusCode, Uri, header},
7    response::{Html, IntoResponse, Response},
8    routing::get,
9};
10use itertools::Itertools;
11use rust_embed::RustEmbed;
12use serde::Deserialize;
13use serde_json::json;
14
15use crate::StateRef;
16
17// TODO: make `base_path` optional.
18/// Create a new [`axum::Router`] for the Jaeger UI to visualize the traces
19/// stored in the given [`StateRef`].
20///
21/// The `base_path` is used for the application to load static assets correctly.
22/// It should start and end with `/`. For example,
23///
24/// - if the application is served at `http://localhost:3000/`, then `base_path`
25///   should be `/`.
26/// - if the application is served at `http://localhost:3000/trace/`, then
27///   `base_path` should be `/trace/`.
28pub fn app(state: StateRef, base_path: &str) -> Router {
29    if !base_path.starts_with('/') || !base_path.ends_with('/') {
30        panic!("base_path must start and end with /");
31    }
32    let base_tag = format!(r#"<base href="{base_path}""#);
33
34    let api = Router::new()
35        .route("/traces/{hex_id}", get(trace))
36        .route("/services", get(services))
37        .route("/services/{service}/operations", get(operations))
38        .route("/traces", get(traces))
39        .layer(Extension(state))
40        .fallback(|_: Uri| async move { not_found_with_msg("API not supported") });
41
42    Router::new()
43        .nest("/api/", api)
44        .fallback(|uri| async move { static_handler(uri, &base_tag).await })
45}
46
47async fn trace(
48    Path(hex_id): Path<String>,
49    Extension(state): Extension<StateRef>,
50) -> impl IntoResponse {
51    let id = hex::decode(&hex_id).unwrap_or_default();
52    let trace = state.write().await.get_by_id(&id);
53
54    if let Some(trace) = trace {
55        Json(trace.to_jaeger_batch()).into_response()
56    } else {
57        not_found_with_msg(format!("Trace {hex_id} not found, maybe expired."))
58    }
59}
60
61async fn services(Extension(state): Extension<StateRef>) -> impl IntoResponse {
62    let state = state.read().await;
63    let all_services = state.get_all_services();
64    let len = all_services.len();
65
66    let res = json!({
67        "data": all_services,
68        "total": len,
69    });
70
71    Json(res).into_response()
72}
73
74async fn operations(
75    Path(service): Path<String>,
76    Extension(state): Extension<StateRef>,
77) -> impl IntoResponse {
78    let state = state.read().await;
79    let operations = state.get_operations(&service);
80    let len = operations.len();
81
82    let res = json!({
83        "data": operations,
84        "total": len,
85    });
86
87    Json(res).into_response()
88}
89
90#[derive(Deserialize)]
91struct TracesQuery {
92    service: Option<String>,
93    operation: Option<String>,
94    limit: usize,
95}
96
97async fn traces(
98    Query(TracesQuery {
99        service,
100        operation,
101        limit,
102    }): Query<TracesQuery>,
103    Extension(state): Extension<StateRef>,
104) -> impl IntoResponse {
105    let traces = (state.read().await)
106        .get_all_complete()
107        .filter(|t| {
108            if let Some(service) = &service {
109                t.service_name().unwrap() == service
110            } else {
111                true
112            }
113        })
114        .filter(|t| {
115            if let Some(operation) = &operation {
116                t.operation().unwrap() == operation
117            } else {
118                true
119            }
120        })
121        .sorted_by_cached_key(|t| Reverse(t.end_time))
122        .map(|t| t.to_jaeger())
123        .take(limit)
124        .collect_vec();
125
126    let mock = json!({
127        "data": traces,
128        "total": traces.len(),
129    });
130
131    Json(mock).into_response()
132}
133
134const INDEX_HTML: &str = "index.html";
135
136#[derive(RustEmbed)]
137#[folder = "jaeger-ui/build"]
138struct Assets;
139
140async fn static_handler(uri: Uri, base_tag: &str) -> Response {
141    let path = uri.path().trim_start_matches('/');
142
143    if path == INDEX_HTML {
144        return index_html(base_tag);
145    }
146
147    match Assets::get(path) {
148        Some(file) => {
149            let mime = file.metadata.mimetype();
150
151            let mut res = file.data.into_response();
152            res.headers_mut()
153                .insert(header::CONTENT_TYPE, mime.parse().unwrap());
154            res
155        }
156
157        None => {
158            if path.starts_with("static") {
159                // For inexistent static assets, we simply return 404.
160                not_found()
161            } else {
162                // Due to the frontend is a SPA (Single Page Application),
163                // it has own frontend routes, we should return the ROOT PAGE
164                // to avoid frontend route 404.
165                (StatusCode::TEMPORARY_REDIRECT, index_html(base_tag)).into_response()
166            }
167        }
168    }
169}
170
171fn index_html(base_tag: &str) -> Response {
172    let file = Assets::get(INDEX_HTML).unwrap();
173    let data = std::str::from_utf8(&file.data)
174        .unwrap()
175        .replace(r#"<base href="/""#, base_tag);
176
177    Html(data).into_response()
178}
179
180fn not_found() -> Response {
181    not_found_with_msg("Not Found")
182}
183
184fn not_found_with_msg(msg: impl Into<String>) -> Response {
185    (StatusCode::NOT_FOUND, msg.into()).into_response()
186}