Webapp
This commit is contained in:
+66
@@ -0,0 +1,66 @@
|
||||
use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse};
|
||||
use crate::dto::{LogsParams, TimelineParams};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tokio::sync::oneshot;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApiState {
|
||||
engine: EngineHandle,
|
||||
}
|
||||
|
||||
pub fn router(engine: EngineHandle, static_dir: &str) -> Router {
|
||||
let state = ApiState { engine };
|
||||
let index_html = format!("{static_dir}/index.html");
|
||||
let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));
|
||||
|
||||
Router::new()
|
||||
.route("/api/logs", get(get_logs))
|
||||
.route("/api/timeline", get(get_timeline))
|
||||
.route("/api/stats", get(get_stats))
|
||||
.with_state(state)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
|
||||
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
|
||||
let (respond_to, response) = oneshot::channel();
|
||||
engine
|
||||
.send(EngineMessage::Query { query, respond_to })
|
||||
.await
|
||||
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
async fn get_logs(
|
||||
State(state): State<ApiState>,
|
||||
Query(params): Query<LogsParams>,
|
||||
) -> impl IntoResponse {
|
||||
match ask(&state.engine, EngineQuery::Logs(params)).await {
|
||||
Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(),
|
||||
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
Err(status) => status.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_timeline(
|
||||
State(state): State<ApiState>,
|
||||
Query(params): Query<TimelineParams>,
|
||||
) -> impl IntoResponse {
|
||||
match ask(&state.engine, EngineQuery::Timeline(params)).await {
|
||||
Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(),
|
||||
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
Err(status) => status.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
|
||||
match ask(&state.engine, EngineQuery::Stats).await {
|
||||
Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(),
|
||||
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
Err(status) => status.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use crate::metric_primitive::MetricPrimitive;
|
||||
use crate::timestamp::Timestamp;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A single parsed metric primitive extracted from a regex capture group.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RegexToMetricPrimitiveMatcher {
|
||||
pub param_name: String,
|
||||
pub capture_group: String,
|
||||
pub kind: MetricPrimitiveKind,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub enum MetricPrimitiveKind {
|
||||
String,
|
||||
Number,
|
||||
Timestamp,
|
||||
}
|
||||
|
||||
/// Value type paired with [`crate::index_key::IndexKey`] in the engine's primary index.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum DataValue {
|
||||
/// Paired with `IndexKey::IngestedLog`.
|
||||
LogLiteral { text: String },
|
||||
/// Paired with `IndexKey::MetricDeclaration`.
|
||||
MetricDeclaration {
|
||||
message_match_regex: String,
|
||||
params: Vec<RegexToMetricPrimitiveMatcher>,
|
||||
},
|
||||
/// Paired with `IndexKey::Bucket`.
|
||||
BucketValue {
|
||||
values: Vec<(u64, Timestamp, MetricPrimitive)>,
|
||||
},
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntryDto {
|
||||
pub sequence: u64,
|
||||
pub timestamp_ms: i64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TimelineBucketDto {
|
||||
pub start_ms: i64,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StatsDto {
|
||||
pub log_count: u64,
|
||||
pub first_sequence: Option<u64>,
|
||||
pub last_sequence: Option<u64>,
|
||||
pub first_timestamp_ms: Option<i64>,
|
||||
pub last_timestamp_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogsParams {
|
||||
pub since_sequence: Option<u64>,
|
||||
pub start_ms: Option<i64>,
|
||||
pub end_ms: Option<i64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TimelineParams {
|
||||
pub start_ms: Option<i64>,
|
||||
pub end_ms: Option<i64>,
|
||||
pub bucket_ms: Option<i64>,
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
use crate::data_value::DataValue;
|
||||
use crate::dto::{LogEntryDto, LogsParams, StatsDto, TimelineBucketDto, TimelineParams};
|
||||
use crate::index_key::IndexKey;
|
||||
use crate::timestamp::Timestamp;
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
pub struct IngestedLine {
|
||||
pub sequence: u64,
|
||||
pub timestamp: Timestamp,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub enum EngineQuery {
|
||||
Logs(LogsParams),
|
||||
Timeline(TimelineParams),
|
||||
Stats,
|
||||
}
|
||||
|
||||
pub enum EngineMessage {
|
||||
Ingest(IngestedLine),
|
||||
Query {
|
||||
query: EngineQuery,
|
||||
respond_to: oneshot::Sender<EngineResponse>,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum EngineResponse {
|
||||
Logs(Vec<LogEntryDto>),
|
||||
Timeline(Vec<TimelineBucketDto>),
|
||||
Stats(StatsDto),
|
||||
}
|
||||
|
||||
pub type EngineHandle = mpsc::Sender<EngineMessage>;
|
||||
|
||||
/// Sole owner of the primary index. Runs single-threaded so the map never needs a
|
||||
/// lock, and does its own response serialization/DTO construction so callers never
|
||||
/// have to clone the index to answer a query.
|
||||
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>) {
|
||||
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
|
||||
|
||||
while let Some(message) = rx.recv().await {
|
||||
match message {
|
||||
EngineMessage::Ingest(line) => {
|
||||
index.insert(
|
||||
IndexKey::IngestedLog {
|
||||
sequence: line.sequence,
|
||||
timestamp: line.timestamp,
|
||||
},
|
||||
DataValue::LogLiteral { text: line.text },
|
||||
);
|
||||
}
|
||||
EngineMessage::Query { query, respond_to } => {
|
||||
let response = handle_query(&index, query);
|
||||
let _ = respond_to.send(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn logs_range(index: &BTreeMap<IndexKey, DataValue>) -> impl Iterator<Item = (u64, Timestamp, &str)> {
|
||||
index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| {
|
||||
match (key, value) {
|
||||
(IndexKey::IngestedLog { sequence, timestamp }, DataValue::LogLiteral { text }) => {
|
||||
Some((*sequence, *timestamp, text.as_str()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> EngineResponse {
|
||||
match query {
|
||||
EngineQuery::Logs(params) => {
|
||||
let limit = params.limit.unwrap_or(500).min(10_000);
|
||||
let entries: Vec<LogEntryDto> = logs_range(index)
|
||||
.filter(|(sequence, timestamp, _)| {
|
||||
params.since_sequence.is_none_or(|since| *sequence > since)
|
||||
&& params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
|
||||
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
|
||||
})
|
||||
.take(limit)
|
||||
.map(|(sequence, timestamp, text)| LogEntryDto {
|
||||
sequence,
|
||||
timestamp_ms: timestamp.as_millis(),
|
||||
text: text.to_string(),
|
||||
})
|
||||
.collect();
|
||||
EngineResponse::Logs(entries)
|
||||
}
|
||||
EngineQuery::Timeline(params) => {
|
||||
let bucket_ms = params.bucket_ms.unwrap_or(1_000).max(1);
|
||||
let mut buckets: BTreeMap<i64, u64> = BTreeMap::new();
|
||||
for (_, timestamp, _) in logs_range(index).filter(|(_, timestamp, _)| {
|
||||
params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
|
||||
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
|
||||
}) {
|
||||
let bucket_start = (timestamp.as_millis() / bucket_ms) * bucket_ms;
|
||||
*buckets.entry(bucket_start).or_insert(0) += 1;
|
||||
}
|
||||
let out = buckets
|
||||
.into_iter()
|
||||
.map(|(start_ms, count)| TimelineBucketDto { start_ms, count })
|
||||
.collect();
|
||||
EngineResponse::Timeline(out)
|
||||
}
|
||||
EngineQuery::Stats => {
|
||||
let mut log_count = 0u64;
|
||||
let mut first_sequence = None;
|
||||
let mut last_sequence = None;
|
||||
let mut first_timestamp_ms = None;
|
||||
let mut last_timestamp_ms = None;
|
||||
for (sequence, timestamp, _) in logs_range(index) {
|
||||
log_count += 1;
|
||||
first_sequence.get_or_insert(sequence);
|
||||
last_sequence = Some(sequence);
|
||||
first_timestamp_ms.get_or_insert(timestamp.as_millis());
|
||||
last_timestamp_ms = Some(timestamp.as_millis());
|
||||
}
|
||||
EngineResponse::Stats(StatsDto {
|
||||
log_count,
|
||||
first_sequence,
|
||||
last_sequence,
|
||||
first_timestamp_ms,
|
||||
last_timestamp_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::metric_primitive::MetricPrimitive;
|
||||
use crate::timestamp::Timestamp;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Key type for the engine's primary index.
|
||||
///
|
||||
/// Variants are ordered top to bottom as declared (derived `Ord` compares the
|
||||
/// discriminant first), which is what carves the map into contiguous ranges:
|
||||
/// logs, then params, then metric declarations, then buckets.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub enum IndexKey {
|
||||
/// Marker for the start of the whole range. Never inserted, only navigated to.
|
||||
Start,
|
||||
IngestedLog {
|
||||
sequence: u64,
|
||||
timestamp: Timestamp,
|
||||
},
|
||||
/// Marker between the log range and the param range.
|
||||
LogParamSplit,
|
||||
ParamDeclaration {
|
||||
name: String,
|
||||
},
|
||||
/// Marker between the param range and the metric declaration range.
|
||||
ParamMetricSplit,
|
||||
MetricDeclaration {
|
||||
name: String,
|
||||
},
|
||||
/// Marker between metric declarations and their buckets.
|
||||
MetricBucketSplit,
|
||||
Bucket {
|
||||
metric_name: String,
|
||||
labels: BTreeMap<String, MetricPrimitive>,
|
||||
},
|
||||
/// Marker for the end of the whole range. Nothing sorts after this.
|
||||
End,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::engine::{EngineMessage, IngestedLine};
|
||||
use crate::timestamp::Timestamp;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
|
||||
/// to the engine task.
|
||||
pub async fn run(engine: mpsc::Sender<EngineMessage>) {
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut lines = BufReader::new(stdin).lines();
|
||||
let mut sequence: u64 = 0;
|
||||
|
||||
while let Ok(Some(text)) = lines.next_line().await {
|
||||
let line = IngestedLine {
|
||||
sequence,
|
||||
timestamp: Timestamp::now(),
|
||||
text,
|
||||
};
|
||||
sequence += 1;
|
||||
|
||||
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -1,3 +1,47 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod api;
|
||||
mod data_value;
|
||||
mod dto;
|
||||
mod engine;
|
||||
mod index_key;
|
||||
mod ingest;
|
||||
mod metric_primitive;
|
||||
mod timestamp;
|
||||
|
||||
use clap::Parser;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Observability dashboard from terminal output.
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
/// Port to serve the API and webapp on.
|
||||
#[arg(long, default_value_t = 8420)]
|
||||
port: u16,
|
||||
|
||||
/// Directory containing the built webapp (index.html + assets).
|
||||
#[arg(long, default_value = "webapp/dist")]
|
||||
static_dir: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let (engine_tx, engine_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(engine::run(engine_rx));
|
||||
tokio::spawn(ingest::run(engine_tx.clone()));
|
||||
|
||||
let app = api::router(engine_tx, &args.static_dir);
|
||||
let listener = TcpListener::bind(("0.0.0.0", args.port))
|
||||
.await
|
||||
.expect("failed to bind listener");
|
||||
|
||||
eprintln!(
|
||||
"ottyl serving on http://localhost:{} (reading logs from stdin)",
|
||||
args.port
|
||||
);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::timestamp::Timestamp;
|
||||
use serde::Serialize;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum MetricPrimitive {
|
||||
String(String),
|
||||
Number(Number),
|
||||
Timestamp(Timestamp),
|
||||
}
|
||||
|
||||
impl PartialEq for MetricPrimitive {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for MetricPrimitive {}
|
||||
|
||||
impl PartialOrd for MetricPrimitive {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for MetricPrimitive {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
fn rank(v: &MetricPrimitive) -> u8 {
|
||||
match v {
|
||||
MetricPrimitive::String(_) => 0,
|
||||
MetricPrimitive::Number(_) => 1,
|
||||
MetricPrimitive::Timestamp(_) => 2,
|
||||
}
|
||||
}
|
||||
match (self, other) {
|
||||
(MetricPrimitive::String(a), MetricPrimitive::String(b)) => a.cmp(b),
|
||||
(MetricPrimitive::Number(a), MetricPrimitive::Number(b)) => a.cmp(b),
|
||||
(MetricPrimitive::Timestamp(a), MetricPrimitive::Timestamp(b)) => a.cmp(b),
|
||||
(a, b) => rank(a).cmp(&rank(b)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub enum Number {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
impl Number {
|
||||
fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Number::Int(v) => *v as f64,
|
||||
Number::Float(v) => *v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Number {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for Number {}
|
||||
|
||||
impl PartialOrd for Number {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Number {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
if let (Number::Int(a), Number::Int(b)) = (self, other) {
|
||||
return a.cmp(b);
|
||||
}
|
||||
self.as_f64().total_cmp(&other.as_f64())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde::Serialize;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Wall-clock instant stored as nanoseconds since the Unix epoch.
|
||||
///
|
||||
/// The README's data model sketches this field as `Instant`, but `Instant` has no
|
||||
/// epoch and can't be serialized to the frontend, so wall-clock time is stored here
|
||||
/// instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct Timestamp(i64);
|
||||
|
||||
impl Timestamp {
|
||||
pub fn now() -> Self {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock is before the Unix epoch")
|
||||
.as_nanos() as i64;
|
||||
Timestamp(nanos)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn from_millis(millis: i64) -> Self {
|
||||
Timestamp(millis * 1_000_000)
|
||||
}
|
||||
|
||||
pub fn as_millis(&self) -> i64 {
|
||||
self.0 / 1_000_000
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user