This commit is contained in:
Hugh Kaznowski
2026-09-05 12:23:46 +01:00
parent 818f8d09ea
commit 68548911bc
34 changed files with 3262 additions and 4 deletions
+66
View File
@@ -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(),
}
}
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})
}
}
}
+38
View File
@@ -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,
}
+25
View File
@@ -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
View File
@@ -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");
}
+82
View File
@@ -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())
}
}
+29
View File
@@ -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
}
}