Compare commits

..
9 Commits
Author SHA1 Message Date
Hugh KaznowskiandClaude Sonnet 5 a810ecf396 Split src/ into an api/cli/engine/config module hierarchy
- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing
  surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs.
- src/cli/: argument parsing and process entry (formerly inline in
  main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now
  a thin entry point that just declares modules and calls cli::run().
- src/engine/: the actor + everything it depends on to parse/index/
  query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs,
  rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest,
  filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs,
  timestamp.rs.
- src/config/: persisted configuration: mod.rs (formerly settings.rs),
  profile.rs.
- src/consts.rs stays at the crate root, used from every module above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:15:39 +01:00
Hugh KaznowskiandClaude Sonnet 5 311713986a Wrap the left-right index in IndexWriter/IndexReader types
The engine actor and API handlers previously called left_right's
WriteHandle/ReadHandleFactory methods (append/publish/enter/handle)
directly. IndexWriter now exposes mutate()/peek() and IndexReader
exposes handle() (returning an IndexReadHandle with its own enter()),
so how the single-writer/multi-reader index is accessed is named and
scoped to these two types instead of being raw left_right calls
scattered through engine.rs/api.rs/profile.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:10:23 +01:00
Hugh KaznowskiandClaude Sonnet 5 3c2995acc1 Convert remaining clean-receiver free functions to methods
Profile (legacy-shape migration, load_into_engine, load_legacy_store),
MetricDeclarationRecord/Dashboard seed constructors, PromqlQuery::parse,
LogStreamHeader (detect/split_row/column_pattern/message_pattern), and
api.rs's EngineHandle::ask/*Error::into_response/ParamDeclarationDto::reserved
all had a single obvious receiver and are now methods on it instead of
free functions taking that type as a parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:08:29 +01:00
Hugh KaznowskiandClaude Sonnet 5 529f79e9da Convert engine.rs index-manipulating functions into EngineSnapshot methods
Free functions that took the engine's index/sort-helper-index state as
a parameter now live on EngineSnapshot (or, for a handful of clean
single-receiver cases, on MetricPrimitive/CompareOp/CompiledFilter/
EngineQuery). Route handlers in api.rs and alert evaluation in
alerts.rs are updated to call the new methods; alerts.rs now threads
an &EngineSnapshot through instead of a raw &BTreeMap so the per-line
alert backfill path doesn't clone the whole index just to call a method.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 17:03:19 +01:00
Hugh KaznowskiandClaude Sonnet 5 b6401c1c3c Strip all comments outside src/consts.rs
Removes doc comments, module comments, and inline comments across
the backend so the only comments left in the codebase are the brief
ones on the constants they document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 16:34:42 +01:00
Hugh KaznowskiandClaude Sonnet 5 284e3562a2 Move scattered magic constants into src/consts.rs
Centralizes engine tuning values, poll intervals, logfmt column
regex fragments, and app/settings identity strings that were
previously defined ad hoc across engine.rs, ingest.rs, logfmt.rs,
profile.rs, settings.rs, and main.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 16:29:03 +01:00
Hugh Kaznowski 1c635bc530 Left-right single-writer, multi-reader index 2026-09-06 16:05:04 +01:00
Hugh KaznowskiandClaude Sonnet 5 9fc7540a5b Resolve tickets 071-074, 076: log-stream contamination, sort, ottyl_fmt column, ingest lag
- 076: gate auto-declared log-stream column/message params
  (`ParamDeclaration::log_stream_gated`) behind `is_log_stream_line` at
  extraction time, since their fixed-offset regex has no content anchor
  and previously extracted garbage from continuation lines and the
  header row itself (the regex crate doesn't support lookaround, so
  this couldn't be baked into the regex text).
- 074: end-to-end regression test confirms sorting a real `log stream`
  capture by a log-stream column (e.g. PID) now actually reorders
  results, since 076's fix removes the garbage values that were
  corrupting the sort helper index; also add a "Building sort
  index…" indicator in the timeline UI for a slow first-time sort.
- 072: attach the reserved `ottyl_fmt` pseudo-param to each
  `LogEntryDto`'s params so it can be picked as a Log Preview column
  (previously listed as pickable but always blank).
- 073: track a rolling 5s high-watermark of ingestion-to-engine-processing
  lag and surface it in the nav stats popover next to approx_bytes_total.
- 071: group the regex(...) help button back with the filter bar's other
  controls, and restructure the Filtering docs section into scannable
  per-feature subsections (also fixes stale log_type/process/pid mention).

Tickets 070 (CLI scoping) and 075 (API unresponsiveness investigation)
are left in place per their own scope — both are explicitly
conversation/investigation tasks, not implementation tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
2026-09-06 15:12:07 +01:00
Hugh Kaznowski f34dbba6c7 Update skill and tickets 2026-09-06 14:37:03 +01:00
37 changed files with 6195 additions and 6778 deletions
Generated
+130
View File
@@ -272,6 +272,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.24.1" version = "0.24.1"
@@ -496,6 +502,21 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -840,6 +861,23 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "left-right"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.189"
@@ -882,12 +920,34 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -933,6 +993,15 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -973,6 +1042,7 @@ dependencies = [
"http-body-util", "http-body-util",
"humantime", "humantime",
"include_dir", "include_dir",
"left-right",
"nom", "nom",
"pest", "pest",
"pest_derive", "pest_derive",
@@ -1481,6 +1551,12 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1564,6 +1640,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1701,6 +1786,15 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -1854,6 +1948,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -1915,6 +2039,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
+1
View File
@@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
petname = { version = "3.2.0", default-features = false, features = ["default-rng", "default-words"] } petname = { version = "3.2.0", default-features = false, features = ["default-rng", "default-words"] }
rmcp = { version = "3.2.0", default-features = false, features = ["server", "macros", "transport-io"] } rmcp = { version = "3.2.0", default-features = false, features = ["server", "macros", "transport-io"] }
anyhow = "1" anyhow = "1"
left-right = "0.11.8"
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" tempfile = "3.27.0"
+15
View File
@@ -32,6 +32,21 @@ ottyl mcp --port 51423
This exposes six tools, each a thin proxy onto the target instance's HTTP API. This exposes six tools, each a thin proxy onto the target instance's HTTP API.
## Get the latest SKILL.md
This file is compiled into the `ottyl` binary, so a copy you have on disk can lag behind the
version a running instance actually serves (e.g. after an upgrade). A running instance always
serves its own current copy read-only over HTTP — fetch it directly rather than trusting a stale
local file:
```
curl http://localhost:<port>/SKILL.md
```
Use the port from `ottyl sessions` (or the `--port`/`--session` you passed to `ottyl mcp`). There
is no CLI subcommand for this and no way to update it at runtime — the served content only changes
when the binary is rebuilt with a new `SKILL.md`.
## Tools ## Tools
### `query_logs` ### `query_logs`
+7 -60
View File
@@ -1,8 +1,8 @@
use crate::alerts::AlertCondition; use crate::engine::alerts::AlertCondition;
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::metric_primitive::MetricPrimitive; use crate::engine::metric_primitive::MetricPrimitive;
use crate::profile::{ChartType, Dashboard, Panel, Profile}; use crate::config::profile::{ChartType, Dashboard, Panel, Profile};
use crate::settings::{ColorScheme, CssEntry, Settings}; use crate::config::{ColorScheme, CssEntry, Settings};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -11,7 +11,6 @@ pub struct LogEntryDto {
pub sequence: u64, pub sequence: u64,
pub timestamp_ms: i64, pub timestamp_ms: i64,
pub text: String, pub text: String,
/// Only params whose regex matched (and parsed as the declared kind) are present — omitted rather than sent as null, so the frontend can render a blank for anything missing.
pub params: BTreeMap<String, MetricPrimitive>, pub params: BTreeMap<String, MetricPrimitive>,
} }
@@ -20,20 +19,10 @@ pub struct ParamDeclarationDto {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
pub capture_group: String, pub capture_group: String,
/// See `crate::data_value::ParamDeclaration::json_field_path`.
pub json_field_path: Option<String>, pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind, pub kind: MetricPrimitiveKind,
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`.
pub elapsed_since_last: bool, pub elapsed_since_last: bool,
/// See `crate::data_value::ParamDeclaration::auto_derived`. The frontend uses this to disable
/// editing — `POST /api/params` also rejects a request naming an auto-derived param.
pub auto_derived: bool, pub auto_derived: bool,
/// Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names
/// (`crate::engine::OTTYL_RESERVED_PARAM_NAMES`), synthesized into the `GET /api/params`
/// response by `crate::api::list_params` rather than backed by any index entry — distinct from
/// `auto_derived` (which *is* a real, if machine-discovered, `ParamDeclaration`). Always
/// `false` for every other entry. There is no removal path for these at all (not just a
/// disabled one), so the frontend should treat `reserved` as non-deletable/non-editable.
#[serde(default)] #[serde(default)]
pub reserved: bool, pub reserved: bool,
} }
@@ -42,13 +31,9 @@ pub struct ParamDeclarationDto {
pub struct MetricDeclarationDto { pub struct MetricDeclarationDto {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
pub label_params: Vec<String>, pub label_params: Vec<String>,
/// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label.
pub value_param: Option<String>, pub value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
pub matched_labels: Option<BTreeMap<String, String>>, pub matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
pub unmatched_labels: Option<BTreeMap<String, String>>, pub unmatched_labels: Option<BTreeMap<String, String>>,
} }
@@ -65,19 +50,8 @@ pub struct StatsDto {
pub last_sequence: Option<u64>, pub last_sequence: Option<u64>,
pub first_timestamp_ms: Option<i64>, pub first_timestamp_ms: Option<i64>,
pub last_timestamp_ms: Option<i64>, pub last_timestamp_ms: Option<i64>,
/// Ticket 011: a running, *approximate* estimate of the in-memory heap
/// held by currently-ingested logs, in bytes. "Approximate" because it
/// only counts each `IngestedLine::text`'s UTF-8 byte length — it does
/// not account for `String`'s allocator overhead/capacity slack, the
/// `BTreeMap`/`IndexKey` node overhead the text is stored under, or any
/// derived data (bucket values, params) the line produces elsewhere in
/// the index. That's judged the best cheap proxy for "how much memory is
/// this log's text costing us": it's O(1) to compute per log (`text.len()`,
/// already an interned byte count for UTF-8 `String`), moves in lockstep
/// with the dominant cost (raw log text, which is usually much larger
/// than the fixed per-entry bookkeeping), and needs no per-entry
/// bookkeeping beyond what eviction already tracks.
pub approx_bytes_total: u64, pub approx_bytes_total: u64,
pub ingest_lag_high_watermark_ms: i64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -86,15 +60,8 @@ pub struct LogsParams {
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
pub limit: Option<usize>, pub limit: Option<usize>,
/// Raw FilterBar query string, e.g. `regex("error") and ottyl_seq<5`.
pub filter: Option<String>, pub filter: Option<String>,
/// Ticket 045: an already-declared param name to order the returned entries by, in place of the
/// default ingest order (which tracks `ottyl_ts`). `None` (the default) leaves ordering
/// unchanged.
pub sort_by: Option<String>, pub sort_by: Option<String>,
/// Ticket 045: only meaningful alongside `sort_by`. `true` (the default) orders lines missing
/// that param among themselves by `ottyl_seq` and appends them after the sorted lines; `false`
/// drops those lines from the response entirely.
pub sort_missing_fallback: Option<bool>, pub sort_missing_fallback: Option<bool>,
} }
@@ -103,11 +70,9 @@ pub struct TimelineParams {
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>, pub bucket_ms: Option<i64>,
/// Same FilterBar query string as `LogsParams::filter`; only matching logs are counted into timeline buckets.
pub filter: Option<String>, pub filter: Option<String>,
} }
/// One (sequence, timestamp, value) occurrence within a metric's bucket for a given label combination.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct BucketValueDto { pub struct BucketValueDto {
pub labels: BTreeMap<String, MetricPrimitive>, pub labels: BTreeMap<String, MetricPrimitive>,
@@ -116,7 +81,6 @@ pub struct BucketValueDto {
pub value: MetricPrimitive, pub value: MetricPrimitive,
} }
/// `labels` is a comma-separated `key=value` list (not repeated params) since axum's `Query` extractor needs a fixed set of field names.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct BucketValuesParams { pub struct BucketValuesParams {
pub labels: Option<String>, pub labels: Option<String>,
@@ -126,7 +90,6 @@ pub struct BucketValuesParams {
pub limit: Option<usize>, pub limit: Option<usize>,
} }
/// `labels` uses plain `String` (already Display-formatted) since charts just need something to show; `value` is always `f64` for plottability.
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq)]
pub struct PromqlSeriesDto { pub struct PromqlSeriesDto {
pub labels: BTreeMap<String, String>, pub labels: BTreeMap<String, String>,
@@ -140,27 +103,22 @@ pub struct PromqlParams {
pub end_ms: Option<i64>, pub end_ms: Option<i64>,
} }
/// JSON error body returned by API handlers on a 4xx/5xx response.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ErrorDto { pub struct ErrorDto {
pub error: String, pub error: String,
} }
/// Every imported CSS entry is a selectable theme (ticket 020) — there's no separate
/// always-on-overlay mode to opt into.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ImportCssRequest { pub struct ImportCssRequest {
pub filename: String, pub filename: String,
pub content: String, pub content: String,
} }
/// A reference to an external CSS path/URL that ottyl doesn't copy or own.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LinkCssRequest { pub struct LinkCssRequest {
pub path_or_url: String, pub path_or_url: String,
} }
/// `id` is the real id for `Imported` entries or a stable id derived from the path/URL for `Linked` ones, so the frontend can key both kinds uniformly (only `Imported` entries are deletable/content-fetchable).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CssEntryDto { pub struct CssEntryDto {
pub id: String, pub id: String,
@@ -186,7 +144,7 @@ impl From<&CssEntry> for CssEntryDto {
path_or_url: None, path_or_url: None,
}, },
CssEntry::Linked { path_or_url } => CssEntryDto { CssEntry::Linked { path_or_url } => CssEntryDto {
id: crate::settings::css_entry_id(entry), id: crate::config::css_entry_id(entry),
kind: CssEntryKind::Linked, kind: CssEntryKind::Linked,
filename: None, filename: None,
path_or_url: Some(path_or_url.clone()), path_or_url: Some(path_or_url.clone()),
@@ -195,18 +153,13 @@ impl From<&CssEntry> for CssEntryDto {
} }
} }
/// `css_entries` is omitted here since it has its own `GET /api/settings/css` endpoint.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SettingsDto { pub struct SettingsDto {
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
pub active_profile: Option<String>, pub active_profile: Option<String>,
/// `None` means unlimited.
pub max_logs: Option<usize>, pub max_logs: Option<usize>,
/// Id of the selected custom theme entry, if any. `None` means `color_scheme` alone determines the active palette.
pub custom_theme_id: Option<String>, pub custom_theme_id: Option<String>,
/// Ntfy topic every alert rule delivers to; `None` until configured.
pub ntfy_topic: Option<String>, pub ntfy_topic: Option<String>,
/// Ntfy server override; `None` means `https://ntfy.sh`.
pub ntfy_server: Option<String>, pub ntfy_server: Option<String>,
} }
@@ -223,7 +176,6 @@ impl From<&Settings> for SettingsDto {
} }
} }
/// `max_logs`/`custom_theme_id`/`ntfy_topic`/`ntfy_server` distinguish "omitted" (leave unchanged) from "present" (null clears, value sets), via `deserialize_double_option`.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest { pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>, pub color_scheme: Option<ColorScheme>,
@@ -237,7 +189,6 @@ pub struct UpdateSettingsRequest {
pub ntfy_server: Option<Option<String>>, pub ntfy_server: Option<Option<String>>,
} }
/// Distinguishes a missing field (outer `None`) from present `null` (`Some(None)`) or present value (`Some(Some(value))`), which plain `Option<Option<T>>` can't do alone.
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error> fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
@@ -260,7 +211,6 @@ pub struct UpdateAlertRuleRequest {
pub condition: AlertCondition, pub condition: AlertCondition,
} }
/// `server` defaults to `https://ntfy.sh` when absent.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TestAlertNotificationRequest { pub struct TestAlertNotificationRequest {
pub topic: String, pub topic: String,
@@ -291,7 +241,6 @@ pub struct CreateProfileRequest {
pub name: String, pub name: String,
} }
/// Mirrors `profile::Panel`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelDto { pub struct PanelDto {
pub id: String, pub id: String,
@@ -322,7 +271,6 @@ impl From<&PanelDto> for Panel {
} }
} }
/// Mirrors `profile::Dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardDto { pub struct DashboardDto {
pub id: String, pub id: String,
@@ -355,7 +303,6 @@ pub struct CreateDashboardRequest {
pub name: String, pub name: String,
} }
/// The dashboard `id` in the URL is authoritative; any `id` in the body is ignored.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SaveDashboardRequest { pub struct SaveDashboardRequest {
pub name: String, pub name: String,
@@ -1,13 +1,3 @@
//! MCP (Model Context Protocol) server for agents (ticket 067): a short-lived process, started
//! via `ottyl mcp --session <name>` (see `crate::sessions`), that speaks MCP over stdio and
//! proxies each tool call to a *running* ottyl instance's existing HTTP API. Every tool here is a
//! thin translation onto `api.rs` routes/DTOs — no query logic is duplicated outside `engine.rs`.
//!
//! Uses the official `rmcp` SDK (https://github.com/modelcontextprotocol/rust-sdk) over the
//! `transport-io` (stdio) transport rather than hand-rolling the JSON-RPC/MCP handshake, since a
//! maintained implementation of the protocol's framing and lifecycle is worth the extra
//! dependency.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::router::tool::ToolRouter;
@@ -21,13 +11,10 @@ use serde_json::Value;
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct QueryLogsParams { pub struct QueryLogsParams {
/// FilterBar query string, e.g. `regex("error") and ottyl_seq<5`. Omitted matches every log.
#[serde(default)] #[serde(default)]
pub filter: Option<String>, pub filter: Option<String>,
/// If true, return only how many logs matched instead of the log lines themselves.
#[serde(default)] #[serde(default)]
pub count_only: bool, pub count_only: bool,
/// Maximum number of log lines to return. Ignored when `count_only` is set.
#[serde(default)] #[serde(default)]
pub limit: Option<usize>, pub limit: Option<usize>,
} }
@@ -35,15 +22,11 @@ pub struct QueryLogsParams {
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DeclareParamParams { pub struct DeclareParamParams {
pub name: String, pub name: String,
/// Regex matched against each log line; `capture_group` extracts this param's value from it.
pub message_match_regex: String, pub message_match_regex: String,
pub capture_group: String, pub capture_group: String,
/// Dotted path into a JSON-structured log line to extract from instead of `capture_group`.
#[serde(default)] #[serde(default)]
pub json_field_path: Option<String>, pub json_field_path: Option<String>,
/// One of: "string", "number", "timestamp", "duration", "boolean".
pub kind: String, pub kind: String,
/// Extract the elapsed time since this param was last seen, instead of parsing captured text.
#[serde(default)] #[serde(default)]
pub elapsed_since_last: bool, pub elapsed_since_last: bool,
} }
@@ -52,23 +35,18 @@ pub struct DeclareParamParams {
pub struct DeclareMetricParams { pub struct DeclareMetricParams {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
/// Names of already-declared params whose values become this metric's bucket labels.
#[serde(default)] #[serde(default)]
pub label_params: Vec<String>, pub label_params: Vec<String>,
/// Name of an already-declared param whose value becomes this metric's per-event bucket value.
#[serde(default)] #[serde(default)]
pub value_param: Option<String>, pub value_param: Option<String>,
/// Fixed labels layered on top of `label_params` for matching lines.
#[serde(default)] #[serde(default)]
pub matched_labels: Option<BTreeMap<String, String>>, pub matched_labels: Option<BTreeMap<String, String>>,
/// Fixed labels recorded once for a non-matching line, instead of it being invisible to this metric.
#[serde(default)] #[serde(default)]
pub unmatched_labels: Option<BTreeMap<String, String>>, pub unmatched_labels: Option<BTreeMap<String, String>>,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CreateDashboardParams { pub struct CreateDashboardParams {
/// Profile to create the dashboard in. Defaults to the currently active profile.
#[serde(default)] #[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
pub name: String, pub name: String,
@@ -76,17 +54,13 @@ pub struct CreateDashboardParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AddPanelParams { pub struct AddPanelParams {
/// Profile the dashboard lives in. Defaults to the currently active profile.
#[serde(default)] #[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
pub dashboard_id: String, pub dashboard_id: String,
/// Id of an existing panel to update in place; omit to add a new panel.
#[serde(default)] #[serde(default)]
pub panel_id: Option<String>, pub panel_id: Option<String>,
pub title: String, pub title: String,
/// PromQL-style query backing this panel (see `crate::promql`).
pub promql: String, pub promql: String,
/// One of: "bar", "line", "pie". Defaults to "line".
#[serde(default = "default_chart_type")] #[serde(default = "default_chart_type")]
pub chart_type: String, pub chart_type: String,
} }
@@ -97,7 +71,6 @@ fn default_chart_type() -> String {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetDashboardParams { pub struct GetDashboardParams {
/// Profile the dashboard lives in. Defaults to the currently active profile.
#[serde(default)] #[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
pub dashboard_id: String, pub dashboard_id: String,
@@ -107,9 +80,6 @@ pub struct GetDashboardParams {
pub struct OttylMcp { pub struct OttylMcp {
client: reqwest::Client, client: reqwest::Client,
base_url: String, base_url: String,
// Constructed fresh per call by the `#[tool_handler]`-generated `ServerHandler` methods
// (`Self::tool_router()`), not read through this field — kept only because `#[tool_router]`
// requires a field of this name/type to exist on the struct.
#[allow(dead_code)] #[allow(dead_code)]
tool_router: ToolRouter<OttylMcp>, tool_router: ToolRouter<OttylMcp>,
} }
@@ -138,8 +108,6 @@ impl OttylMcp {
format!("{}{path}", self.base_url) format!("{}{path}", self.base_url)
} }
/// Resolves the profile a dashboard tool call should operate on: the explicit `profile`
/// argument if given, otherwise the target instance's currently active profile.
async fn resolve_profile(&self, explicit: Option<String>) -> Result<String, McpError> { async fn resolve_profile(&self, explicit: Option<String>) -> Result<String, McpError> {
if let Some(profile) = explicit { if let Some(profile) = explicit {
return Ok(profile); return Ok(profile);
@@ -282,10 +250,6 @@ impl OttylMcp {
json_result(body) json_result(body)
} }
// Read-modify-write over HTTP (no dashboard-level locking): two concurrent `add_panel` calls
// against the same dashboard can race and one's addition can be lost, same as two browser tabs
// saving the same dashboard through the webapp today. Not addressed here — out of scope for a
// thin proxy over the existing `save_dashboard` endpoint.
#[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")] #[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")]
async fn add_panel( async fn add_panel(
&self, &self,
@@ -359,8 +323,6 @@ impl OttylMcp {
} }
} }
/// Percent-encodes a single path segment (profile/dashboard names/ids can contain characters that
/// aren't valid unescaped in a URL path).
fn urlencoding_path(segment: &str) -> String { fn urlencoding_path(segment: &str) -> String {
segment segment
.bytes() .bytes()
@@ -373,8 +335,6 @@ fn urlencoding_path(segment: &str) -> String {
.collect() .collect()
} }
/// Generates a new panel id by hashing title/promql/time, avoiding a `uuid` dependency (matches
/// `settings::generate_imported_css_id`'s approach for the same reason).
fn generate_panel_id(title: &str, promql: &str) -> String { fn generate_panel_id(title: &str, promql: &str) -> String {
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
+107 -182
View File
@@ -1,21 +1,24 @@
pub mod dto;
pub mod mcp_server;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use crate::dto::{ use crate::api::dto::{
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest, BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
}; };
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::{ use crate::engine::{
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery, DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
EngineResponse, EngineResponse,
}; };
use crate::alerts::{deliver_ntfy, AlertError}; use crate::engine::alerts::{deliver_ntfy, AlertError};
use crate::profile::{self, Dashboard, ParamDeclarationRecord, Profile, ProfileError}; use crate::config::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError};
use crate::settings::{CssError, SettingsHandle}; use crate::config::{CssError, SettingsHandle};
use axum::extract::{Path as AxumPath, Query, State}; use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
@@ -26,15 +29,12 @@ use tokio::sync::oneshot;
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
/// Embedded so an agent that has only discovered a port (no filesystem access into the ottyl const SKILL_MD: &str = include_str!("../../SKILL.md");
/// repo/binary) can still fetch it — see `SKILL.md` at the repo root and ticket 069.
const SKILL_MD: &str = include_str!("../SKILL.md");
#[derive(Clone)] #[derive(Clone)]
struct ApiState { struct ApiState {
engine: EngineHandle, engine: EngineHandle,
settings: SettingsHandle, settings: SettingsHandle,
/// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
settings_path: Arc<PathBuf>, settings_path: Arc<PathBuf>,
} }
@@ -46,7 +46,6 @@ impl ApiState {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
fn persist_param_declaration(&self, request: &DeclareParamRequest) { fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let mut settings = self.settings.lock().unwrap(); let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else { let Some(profile_name) = settings.active_profile.clone() else {
@@ -60,11 +59,6 @@ impl ApiState {
} }
} }
/// Counterpart to `persist_param_declaration` for `DELETE /api/params` (ticket 052): empties the
/// active profile's stored params so the just-cleared set doesn't reappear from `settings.json`
/// on next load. Same best-effort semantics — a no-op if no profile is active, I/O failures only
/// logged, since the engine-side clear (already applied by the time this runs) is the source of
/// truth for the running process.
fn persist_clear_params(&self) { fn persist_clear_params(&self) {
let mut settings = self.settings.lock().unwrap(); let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else { let Some(profile_name) = settings.active_profile.clone() else {
@@ -77,11 +71,6 @@ impl ApiState {
} }
} }
/// Same as `persist_param_declaration`, for metric declarations. Also promotes any of the
/// metric's referenced params that only exist as auto-derived engine state (see
/// `promoted_param_records`) into persisted profile params, so a metric built against an
/// auto-detected logfmt key survives a restart instead of being rejected as "undeclared" once
/// declarations replay before any log line re-derives it.
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) { async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
let profile_name = { let profile_name = {
let settings = self.settings.lock().unwrap(); let settings = self.settings.lock().unwrap();
@@ -109,7 +98,6 @@ impl ApiState {
} }
} }
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) { async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
let profile_name = { let profile_name = {
let settings = self.settings.lock().unwrap(); let settings = self.settings.lock().unwrap();
@@ -138,9 +126,6 @@ impl ApiState {
} }
} }
/// Best-effort, matching `persist_param_declaration`'s intent — a no-op if no profile is
/// active, and I/O failures are only logged, since the engine's removal is already the source
/// of truth for what the live process shows.
fn persist_metric_declaration_removal(&self, name: &str) { fn persist_metric_declaration_removal(&self, name: &str) {
let mut settings = self.settings.lock().unwrap(); let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else { let Some(profile_name) = settings.active_profile.clone() else {
@@ -203,7 +188,6 @@ async fn get_skill_md() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD) ([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD)
} }
/// Falls back to `index.html` for unmatched paths so client-side routing works.
async fn serve_webapp(uri: axum::http::Uri) -> Response { async fn serve_webapp(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/'); let path = uri.path().trim_start_matches('/');
let file = WEBAPP_DIST let file = WEBAPP_DIST
@@ -234,22 +218,28 @@ fn mime_for_extension(extension: &str) -> &'static str {
} }
} }
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> { impl EngineHandle {
async fn ask(&self, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
let read_handle = self.reader.handle();
if let Some(sort_by) = query.sort_by() {
let needs_build = match read_handle.enter() {
Some(guard) => !guard.has_sort_helper_index(sort_by),
None => return Err(StatusCode::SERVICE_UNAVAILABLE),
};
if needs_build {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
engine self.tx
.send(EngineMessage::Query { query, respond_to }) .send(EngineMessage::EnsureSortHelperIndex { param_name: sort_by.to_string(), respond_to })
.await .await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE) response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
}
}
let guard = read_handle.enter().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
Ok(guard.handle_query_ro(query))
}
} }
/// Any of `request`'s referenced params (`label_params`/`value_param`) not already present in
/// `existing_params` are looked up live in the engine and returned as records ready to persist
/// alongside the metric (see `ProfileStore::record_metric_declaration`'s `promoted_params`). A
/// metric can validly reference a param that's only auto-derived engine state (see
/// `crate::engine::auto_declare_params`) — that param was never written into any profile, so without
/// this promotion the metric would be rejected as "undeclared" the next time declarations replay on
/// startup, before any log line has re-derived it.
async fn promoted_param_records( async fn promoted_param_records(
engine: &EngineHandle, engine: &EngineHandle,
existing_params: &[ParamDeclarationRecord], existing_params: &[ParamDeclarationRecord],
@@ -266,7 +256,7 @@ async fn promoted_param_records(
return Vec::new(); return Vec::new();
} }
let Ok(EngineResponse::ParamDeclarations(declarations)) = let Ok(EngineResponse::ParamDeclarations(declarations)) =
ask(engine, EngineQuery::ParamDeclarations).await engine.ask(EngineQuery::ParamDeclarations).await
else { else {
return Vec::new(); return Vec::new();
}; };
@@ -288,7 +278,7 @@ async fn get_logs(
State(state): State<ApiState>, State(state): State<ApiState>,
Query(params): Query<LogsParams>, Query(params): Query<LogsParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Logs(params)).await { match state.engine.ask(EngineQuery::Logs(params)).await {
Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(), Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(),
Ok(EngineResponse::Error(err)) => { Ok(EngineResponse::Error(err)) => {
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response() (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
@@ -302,7 +292,7 @@ async fn get_timeline(
State(state): State<ApiState>, State(state): State<ApiState>,
Query(params): Query<TimelineParams>, Query(params): Query<TimelineParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Timeline(params)).await { match state.engine.ask(EngineQuery::Timeline(params)).await {
Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(), Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(),
Ok(EngineResponse::Error(err)) => { Ok(EngineResponse::Error(err)) => {
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response() (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
@@ -313,7 +303,7 @@ async fn get_timeline(
} }
async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse { async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Stats).await { match state.engine.ask(EngineQuery::Stats).await {
Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(), Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(), Err(status) => status.into_response(),
@@ -321,16 +311,9 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
} }
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse { async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::ParamDeclarations).await { match state.engine.ask(EngineQuery::ParamDeclarations).await {
Ok(EngineResponse::ParamDeclarations(mut declarations)) => { Ok(EngineResponse::ParamDeclarations(mut declarations)) => {
// `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` (ticket 055) are computed line-locally rather than declarations.extend(ParamDeclarationDto::reserved());
// stored as index `ParamDeclaration` entries (see `crate::engine::declare_param`'s
// rejection of these names), so they're merged in here purely for discoverability
// rather than via `param_declarations_range`. This is deliberately a merge only in this
// one HTTP-facing listing, not in `EngineQuery::ParamDeclarations` itself — other
// internal callers of that query (promoting a metric's referenced params, replaying
// declarations on profile switch) must keep seeing only real, persistable entries.
declarations.extend(reserved_param_declaration_dtos());
Json(declarations).into_response() Json(declarations).into_response()
} }
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
@@ -338,11 +321,8 @@ async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
} }
} }
/// `GET /api/params`-only synthesized entries for the three reserved names impl ParamDeclarationDto {
/// (`crate::engine::OTTYL_RESERVED_PARAM_NAMES`) — see `list_params`. `message_match_regex`/ fn reserved() -> [ParamDeclarationDto; 3] {
/// `capture_group` are empty since these are never text-extracted; `reserved: true` marks them as
/// non-deletable/non-editable, distinct from `auto_derived`.
fn reserved_param_declaration_dtos() -> [ParamDeclarationDto; 3] {
[ [
ParamDeclarationDto { ParamDeclarationDto {
name: "ottyl_seq".to_string(), name: "ottyl_seq".to_string(),
@@ -376,6 +356,7 @@ fn reserved_param_declaration_dtos() -> [ParamDeclarationDto; 3] {
}, },
] ]
} }
}
async fn declare_param( async fn declare_param(
State(state): State<ApiState>, State(state): State<ApiState>,
@@ -385,6 +366,7 @@ async fn declare_param(
let request = body.clone(); let request = body.clone();
if state if state
.engine .engine
.tx
.send(EngineMessage::DeclareParam { request: body, respond_to }) .send(EngineMessage::DeclareParam { request: body, respond_to })
.await .await
.is_err() .is_err()
@@ -401,8 +383,6 @@ async fn declare_param(
json_field_path: request.json_field_path, json_field_path: request.json_field_path,
kind: request.kind, kind: request.kind,
elapsed_since_last: request.elapsed_since_last, elapsed_since_last: request.elapsed_since_last,
// A request that reached here was accepted by `declare_param`, which always stores
// it as non-auto (see `engine::declare_param`).
auto_derived: false, auto_derived: false,
reserved: false, reserved: false,
}; };
@@ -413,14 +393,9 @@ async fn declare_param(
} }
} }
/// `DELETE /api/params` (ticket 052): clears every declared param (manual and auto-derived) and
/// immediately re-derives whatever `auto_declare_params` can still find in log text the engine
/// still retains. The response body's `logs_considered` tells the caller how many retained lines
/// were re-scanned, so the UI can make clear that lines evicted by `max_logs` before this ran
/// can't regain their params — this is expected behavior, not a partial failure.
async fn clear_params(State(state): State<ApiState>) -> impl IntoResponse { async fn clear_params(State(state): State<ApiState>) -> impl IntoResponse {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
if state.engine.send(EngineMessage::ClearParams { respond_to }).await.is_err() { if state.engine.tx.send(EngineMessage::ClearParams { respond_to }).await.is_err() {
return StatusCode::SERVICE_UNAVAILABLE.into_response(); return StatusCode::SERVICE_UNAVAILABLE.into_response();
} }
match response.await { match response.await {
@@ -433,7 +408,7 @@ async fn clear_params(State(state): State<ApiState>) -> impl IntoResponse {
} }
async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse { async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::MetricDeclarations).await { match state.engine.ask(EngineQuery::MetricDeclarations).await {
Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(), Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(), Err(status) => status.into_response(),
@@ -448,6 +423,7 @@ async fn declare_metric(
let request = body.clone(); let request = body.clone();
if state if state
.engine .engine
.tx
.send(EngineMessage::DeclareMetric { request: body, respond_to }) .send(EngineMessage::DeclareMetric { request: body, respond_to })
.await .await
.is_err() .is_err()
@@ -472,7 +448,6 @@ async fn declare_metric(
} }
} }
/// Updates the metric declaration named by the URL path in place, reusing `declare_metric`'s validation (via `engine::update_metric`) rather than creating a second declaration or erroring on a duplicate-name conflict. `body.name` may differ from the path segment to rename the metric, so long as no other declaration already uses the new name.
async fn update_metric( async fn update_metric(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -482,6 +457,7 @@ async fn update_metric(
let request = body.clone(); let request = body.clone();
if state if state
.engine .engine
.tx
.send(EngineMessage::UpdateMetric { name: name.clone(), request: body, respond_to }) .send(EngineMessage::UpdateMetric { name: name.clone(), request: body, respond_to })
.await .await
.is_err() .is_err()
@@ -506,12 +482,6 @@ async fn update_metric(
} }
} }
/// Deletes the metric declaration named by the URL path — both from the live engine index and,
/// if a profile is active, from persisted settings (ticket 051), so it doesn't reappear on
/// restart. Its recorded bucket-value history is purged along with it (see the comment on
/// `EngineMessage::RemoveMetricDeclaration`'s handler in `engine.rs`), rather than left orphaned,
/// so a dashboard panel or PromQL query still referencing the metric by name fails with a clear
/// "unknown metric" error instead of silently keeping stale data alive.
async fn delete_metric( async fn delete_metric(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -519,6 +489,7 @@ async fn delete_metric(
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
if state if state
.engine .engine
.tx
.send(EngineMessage::RemoveMetricDeclaration { name: name.clone(), respond_to }) .send(EngineMessage::RemoveMetricDeclaration { name: name.clone(), respond_to })
.await .await
.is_err() .is_err()
@@ -535,7 +506,6 @@ async fn delete_metric(
} }
} }
/// Malformed pairs (no `=`) are skipped rather than rejected.
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> { fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
raw.split(',') raw.split(',')
.filter_map(|pair| pair.split_once('=')) .filter_map(|pair| pair.split_once('='))
@@ -557,7 +527,7 @@ async fn get_metric_bucket_values(
since_sequence: params.since_sequence, since_sequence: params.since_sequence,
limit: params.limit, limit: params.limit,
}; };
match ask(&state.engine, query).await { match state.engine.ask(query).await {
Ok(EngineResponse::BucketValues(values)) => Json(values).into_response(), Ok(EngineResponse::BucketValues(values)) => Json(values).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(), Err(status) => status.into_response(),
@@ -573,7 +543,7 @@ async fn run_promql_query(
start_ms: params.start_ms, start_ms: params.start_ms,
end_ms: params.end_ms, end_ms: params.end_ms,
}; };
match ask(&state.engine, query).await { match state.engine.ask(query).await {
Ok(EngineResponse::Promql(Ok(series))) => Json(series).into_response(), Ok(EngineResponse::Promql(Ok(series))) => Json(series).into_response(),
Ok(EngineResponse::Promql(Err(err))) => { Ok(EngineResponse::Promql(Err(err))) => {
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response() (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
@@ -583,11 +553,13 @@ async fn run_promql_query(
} }
} }
fn css_error_response(err: CssError) -> axum::response::Response { impl CssError {
match err { fn into_response(self) -> axum::response::Response {
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), match self {
CssError::NotImported => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), CssError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), CssError::NotImported => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(),
}
} }
} }
@@ -608,15 +580,14 @@ async fn import_css(
let entry = settings let entry = settings
.css_entries .css_entries
.iter() .iter()
.find(|entry| crate::settings::css_entry_id(entry) == id) .find(|entry| crate::config::css_entry_id(entry) == id)
.expect("entry was just inserted by import_css"); .expect("entry was just inserted by import_css");
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
} }
Err(err) => css_error_response(err), Err(err) => err.into_response(),
} }
} }
/// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
async fn link_css( async fn link_css(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<LinkCssRequest>, Json(body): Json<LinkCssRequest>,
@@ -630,7 +601,7 @@ async fn link_css(
.expect("entry was just pushed by link_css"); .expect("entry was just pushed by link_css");
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
} }
Err(err) => css_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -642,11 +613,10 @@ async fn remove_css(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.remove_css(&state.settings_path, &css_dir, &id) { match settings.remove_css(&state.settings_path, &css_dir, &id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => css_error_response(err), Err(err) => err.into_response(),
} }
} }
/// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
async fn get_css_content( async fn get_css_content(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(id): AxumPath<String>, AxumPath(id): AxumPath<String>,
@@ -660,14 +630,16 @@ async fn get_css_content(
content, content,
) )
.into_response(), .into_response(),
Err(err) => css_error_response(err), Err(err) => err.into_response(),
} }
} }
fn alert_error_response(err: AlertError) -> axum::response::Response { impl AlertError {
match err { fn into_response(self) -> axum::response::Response {
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), match self {
AlertError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), AlertError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
AlertError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(),
}
} }
} }
@@ -683,7 +655,7 @@ async fn create_alert_rule(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.create_alert_rule(&state.settings_path, body.name, body.enabled, body.condition) { match settings.create_alert_rule(&state.settings_path, body.name, body.enabled, body.condition) {
Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(), Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(),
Err(err) => alert_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -695,7 +667,7 @@ async fn update_alert_rule(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.update_alert_rule(&state.settings_path, &id, body.name, body.enabled, body.condition) { match settings.update_alert_rule(&state.settings_path, &id, body.name, body.enabled, body.condition) {
Ok(rule) => Json(rule).into_response(), Ok(rule) => Json(rule).into_response(),
Err(err) => alert_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -703,11 +675,10 @@ async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.remove_alert_rule(&state.settings_path, &id) { match settings.remove_alert_rule(&state.settings_path, &id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => alert_error_response(err), Err(err) => err.into_response(),
} }
} }
/// Delivers a test notification without a saved rule so the sink can be verified before saving.
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse { async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string()); let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await { match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
@@ -716,13 +687,11 @@ async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>)
} }
} }
/// CSS entries have their own endpoint (`GET /api/settings/css`).
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse { async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
Json(SettingsDto::from(&*settings)).into_response() Json(SettingsDto::from(&*settings)).into_response()
} }
/// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
async fn update_settings( async fn update_settings(
State(state): State<ApiState>, State(state): State<ApiState>,
Json(body): Json<UpdateSettingsRequest>, Json(body): Json<UpdateSettingsRequest>,
@@ -754,18 +723,22 @@ async fn update_settings(
if max_logs_changed { if max_logs_changed {
let _ = state let _ = state
.engine .engine
.tx
.send(EngineMessage::SetMaxLogs(new_max_logs.1)) .send(EngineMessage::SetMaxLogs(new_max_logs.1))
.await; .await;
state.engine.barrier().await;
} }
Json(new_max_logs.0).into_response() Json(new_max_logs.0).into_response()
} }
fn profile_error_response(err: ProfileError) -> axum::response::Response { impl ProfileError {
match err { fn into_response(self) -> axum::response::Response {
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(), match self {
ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), ProfileError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(),
ProfileError::CannotDeleteDefault => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), ProfileError::CannotDeleteDefault => (StatusCode::BAD_REQUEST, self.to_string()).into_response(),
ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(),
}
} }
} }
@@ -785,11 +758,10 @@ async fn create_profile(
let profile = settings.profile(&body.name).expect("profile was just created by create_profile"); let profile = settings.profile(&body.name).expect("profile was just created by create_profile");
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response() (StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
} }
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
/// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
async fn delete_profile( async fn delete_profile(
State(state): State<ApiState>, State(state): State<ApiState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
@@ -797,21 +769,18 @@ async fn delete_profile(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.delete_profile(&state.settings_path, &name) { match settings.delete_profile(&state.settings_path, &name) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
/// Auto-derived params (`ParamDeclarationDto::auto_derived`) are left alone — they aren't part of
/// any profile, just facts discovered from the log stream, so a profile switch shouldn't discard
/// them (they'd otherwise only reappear once a new line re-introduces that key).
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) { async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
if let Ok(EngineResponse::ParamDeclarations(params)) = if let Ok(EngineResponse::ParamDeclarations(params)) =
ask(engine, EngineQuery::ParamDeclarations).await engine.ask(EngineQuery::ParamDeclarations).await
{ {
for param in params.into_iter().filter(|param| !param.auto_derived) { for param in params.into_iter().filter(|param| !param.auto_derived) {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
if engine if engine
.tx
.send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to }) .send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to })
.await .await
.is_ok() .is_ok()
@@ -821,11 +790,12 @@ async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
} }
} }
if let Ok(EngineResponse::MetricDeclarations(metrics)) = if let Ok(EngineResponse::MetricDeclarations(metrics)) =
ask(engine, EngineQuery::MetricDeclarations).await engine.ask(EngineQuery::MetricDeclarations).await
{ {
for metric in metrics { for metric in metrics {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
if engine if engine
.tx
.send(EngineMessage::RemoveMetricDeclaration { name: metric.name, respond_to }) .send(EngineMessage::RemoveMetricDeclaration { name: metric.name, respond_to })
.await .await
.is_ok() .is_ok()
@@ -834,7 +804,7 @@ async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
} }
} }
} }
profile::load_into_engine(engine, profile).await; profile.load_into_engine(engine).await;
} }
async fn activate_profile( async fn activate_profile(
@@ -857,7 +827,7 @@ async fn activate_profile(
let settings = state.settings.lock().unwrap(); let settings = state.settings.lock().unwrap();
Json(SettingsDto::from(&*settings)).into_response() Json(SettingsDto::from(&*settings)).into_response()
} }
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -871,7 +841,7 @@ async fn list_dashboards(
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect(); let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
Json(dtos).into_response() Json(dtos).into_response()
} }
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -883,7 +853,7 @@ async fn create_dashboard(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.create_dashboard(&state.settings_path, &name, body.name) { match settings.create_dashboard(&state.settings_path, &name, body.name) {
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(), Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -895,9 +865,9 @@ async fn get_dashboard(
match settings.list_dashboards(&name) { match settings.list_dashboards(&name) {
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) { Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(), Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
None => profile_error_response(ProfileError::NotFound), None => ProfileError::NotFound.into_response(),
}, },
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -914,7 +884,7 @@ async fn save_dashboard(
}; };
match settings.save_dashboard(&state.settings_path, &name, &dashboard_id, dashboard) { match settings.save_dashboard(&state.settings_path, &name, &dashboard_id, dashboard) {
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(), Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -925,7 +895,7 @@ async fn delete_dashboard(
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
match settings.delete_dashboard(&state.settings_path, &name, &dashboard_id) { match settings.delete_dashboard(&state.settings_path, &name, &dashboard_id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => profile_error_response(err), Err(err) => err.into_response(),
} }
} }
@@ -933,25 +903,21 @@ async fn delete_dashboard(
mod tests { mod tests {
use super::*; use super::*;
use crate::engine::{self, IngestedLine}; use crate::engine::{self, IngestedLine};
use crate::settings::Settings; use crate::config::Settings;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use axum::body::Body; use axum::body::Body;
use axum::http::Request; use axum::http::Request;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::Mutex; use std::sync::Mutex;
use tokio::sync::mpsc;
use tower::ServiceExt; use tower::ServiceExt;
/// Spins up a fresh engine + router pair backed by a temp settings file.
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) { fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
let (engine_tx, engine_rx) = mpsc::channel(128);
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let settings = Arc::new(Mutex::new(Settings::load(&settings_path))); let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
tokio::spawn(engine::run(engine_rx, settings.clone())); let engine_tx = engine::spawn(settings.clone(), 128);
let app = router(engine_tx.clone(), settings.clone(), settings_path); let app = router(engine_tx.clone(), settings.clone(), settings_path);
(app, engine_tx, settings, dir) (app, engine_tx, settings, dir)
@@ -1039,7 +1005,6 @@ mod tests {
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await; let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(status, StatusCode::NOT_FOUND);
// AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
let request = Request::builder() let request = Request::builder()
.method("PUT") .method("PUT")
.uri("/api/settings/alerts/does-not-exist") .uri("/api/settings/alerts/does-not-exist")
@@ -1077,10 +1042,6 @@ mod tests {
assert_eq!(body["capture_group"], "level"); assert_eq!(body["capture_group"], "level");
} }
/// Ticket 052's core acceptance criteria: `DELETE /api/params` removes every declared param
/// (manual and auto-derived), that removal survives a reload of persisted settings, and —
/// without any new line being ingested — the retained `IngestedLog` entries immediately
/// re-derive a fresh (auto-derived) param set.
#[tokio::test] #[tokio::test]
async fn clearing_params_persists_and_reextracts_from_retained_logs() { async fn clearing_params_persists_and_reextracts_from_retained_logs() {
let (app, engine, settings_handle, dir) = test_app(); let (app, engine, settings_handle, dir) = test_app();
@@ -1092,14 +1053,10 @@ mod tests {
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
} }
// A manual declaration plus an auto-derived one (from ingesting a logfmt line before "path" is declared).
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await; declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
ingest_lines(&engine, 0, &["request path=/a", "level=info x=1"]).await; ingest_lines(&engine, 0, &["request path=/a", "level=info x=1"]).await;
let (_, before) = get_json(&app, "/api/params").await; let (_, before) = get_json(&app, "/api/params").await;
// Ticket 055 merges the three non-deletable `ottyl_*` reserved names into this listing
// too; `DELETE /api/params` (ticket 052) only ever clears real declarations, so the
// reserved ones must be excluded from the "everything got removed" count below.
let before_declared: Vec<&str> = before let before_declared: Vec<&str> = before
.as_array() .as_array()
.unwrap() .unwrap()
@@ -1114,7 +1071,6 @@ mod tests {
let (status, result) = send_json(&app, "DELETE", "/api/params", json!({})).await; let (status, result) = send_json(&app, "DELETE", "/api/params", json!({})).await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(result["removed"], before_declared.len() as u64); assert_eq!(result["removed"], before_declared.len() as u64);
// Re-derived without any new ingest — same three keys come back, now all auto-derived.
assert_eq!(result["reextracted"], 3); assert_eq!(result["reextracted"], 3);
assert_eq!(result["logs_considered"], 2); assert_eq!(result["logs_considered"], 2);
@@ -1124,8 +1080,6 @@ mod tests {
assert_eq!(after_declared.len(), 3); assert_eq!(after_declared.len(), 3);
assert!(after_declared.iter().all(|p| p["auto_derived"] == true)); assert!(after_declared.iter().all(|p| p["auto_derived"] == true));
// The clear persisted: "path" (the only manually-declared, profile-stored param) doesn't
// reappear from settings.json on a fresh load.
let reloaded_settings = Settings::load(&settings_path); let reloaded_settings = Settings::load(&settings_path);
let profile = reloaded_settings.profile("web").unwrap(); let profile = reloaded_settings.profile("web").unwrap();
assert!(profile.params.is_empty()); assert!(profile.params.is_empty());
@@ -1150,9 +1104,6 @@ mod tests {
assert!(body["error"].as_str().unwrap().contains("invalid regex")); assert!(body["error"].as_str().unwrap().contains("invalid regex"));
} }
/// Declares a standalone param via `POST /api/params` — most metric-declaration tests need one
/// or more already-declared params to reference by name (ticket 037), rather than embedding a
/// regex/capture-group/kind inline in the metric request.
async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) { async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) {
let (status, _body) = send_json( let (status, _body) = send_json(
app, app,
@@ -1244,7 +1195,6 @@ mod tests {
assert_eq!(body["name"], "requests"); assert_eq!(body["name"], "requests");
assert_eq!(body["label_params"].as_array().unwrap().len(), 2); assert_eq!(body["label_params"].as_array().unwrap().len(), 2);
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
let (status, body) = get_json(&app, "/api/metrics").await; let (status, body) = get_json(&app, "/api/metrics").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let metrics = body.as_array().unwrap(); let metrics = body.as_array().unwrap();
@@ -1330,8 +1280,6 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(status, StatusCode::NOT_FOUND);
} }
/// Acceptance criterion: deleting a metric persists the removal into the active profile too, so
/// it doesn't reappear from `settings.json` on the next restart.
#[tokio::test] #[tokio::test]
async fn deleting_metric_persists_removal_and_it_does_not_reappear_after_restart() { async fn deleting_metric_persists_removal_and_it_does_not_reappear_after_restart() {
let (app, _engine, settings_handle, dir) = test_app(); let (app, _engine, settings_handle, dir) = test_app();
@@ -1362,10 +1310,6 @@ mod tests {
assert!(Settings::load(&settings_path).profile("web").unwrap().metrics.is_empty()); assert!(Settings::load(&settings_path).profile("web").unwrap().metrics.is_empty());
} }
/// Acceptance criterion: a dashboard panel (PromQL query) that still references a metric by
/// name after it's been deleted fails with a readable "unknown metric" error, rather than a
/// panic or a silently-empty series — seeded here by declaring, ingesting into, deleting, then
/// re-querying the same metric name.
#[tokio::test] #[tokio::test]
async fn promql_query_against_deleted_metric_fails_with_readable_error() { async fn promql_query_against_deleted_metric_fails_with_readable_error() {
let (app, engine, _settings, _dir) = test_app(); let (app, engine, _settings, _dir) = test_app();
@@ -1383,6 +1327,7 @@ mod tests {
) )
.await; .await;
engine engine
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence: 0, sequence: 0,
timestamp: Timestamp::from_millis(0), timestamp: Timestamp::from_millis(0),
@@ -1390,8 +1335,8 @@ mod tests {
})) }))
.await .await
.unwrap(); .unwrap();
engine.barrier().await;
// Sanity check: the dashboard-panel-style query works before deletion.
let (status, _body) = get_json(&app, "/api/query?promql=requests").await; let (status, _body) = get_json(&app, "/api/query?promql=requests").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
@@ -1429,6 +1374,7 @@ mod tests {
(2u64, "request path=/a"), (2u64, "request path=/a"),
] { ] {
engine engine
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence, sequence,
timestamp: Timestamp::from_millis(sequence as i64 * 1000), timestamp: Timestamp::from_millis(sequence as i64 * 1000),
@@ -1437,6 +1383,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
} }
engine.barrier().await;
let (status, body) = get_json(&app, "/api/metrics/requests").await; let (status, body) = get_json(&app, "/api/metrics/requests").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
@@ -1447,11 +1394,9 @@ mod tests {
let values = body.as_array().unwrap(); let values = body.as_array().unwrap();
assert_eq!(values.len(), 2); assert_eq!(values.len(), 2);
for value in values { for value in values {
// MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
assert_eq!(value["labels"]["path"]["String"], "/a"); assert_eq!(value["labels"]["path"]["String"], "/a");
} }
// Bucket values group by label (BTreeMap-ordered "/a" before "/b"), not by sequence, so limit=1 picks sequence 2 from "/a" ahead of sequence 1 from "/b".
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await; let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let values = body.as_array().unwrap(); let values = body.as_array().unwrap();
@@ -1482,6 +1427,7 @@ mod tests {
(2u64, "request path=/a"), (2u64, "request path=/a"),
] { ] {
engine engine
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence, sequence,
timestamp: Timestamp::from_millis(sequence as i64 * 1000), timestamp: Timestamp::from_millis(sequence as i64 * 1000),
@@ -1490,6 +1436,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
} }
engine.barrier().await;
let (status, body) = let (status, body) =
get_json(&app, "/api/query?promql=requests%7Bpath%3D%22%2Fa%22%7D").await; get_json(&app, "/api/query?promql=requests%7Bpath%3D%22%2Fa%22%7D").await;
@@ -1515,7 +1462,6 @@ mod tests {
async fn declaring_param_persists_into_active_profile() { async fn declaring_param_persists_into_active_profile() {
let (app, _engine, settings_handle, dir) = test_app(); let (app, _engine, settings_handle, dir) = test_app();
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
{ {
let mut settings = settings_handle.lock().unwrap(); let mut settings = settings_handle.lock().unwrap();
@@ -1543,11 +1489,6 @@ mod tests {
assert_eq!(profile.params[0].name, "level"); assert_eq!(profile.params[0].name, "level");
} }
/// Regression test: declaring a metric against a param that only exists as auto-derived engine
/// state (discovered from an ingested logfmt key, never manually declared via `POST
/// /api/params`) must persist that param into the active profile too, not just the metric —
/// otherwise a restart replays the metric before any log line re-derives the auto param, and the
/// engine rejects it as referencing an undeclared param (see `promoted_param_records`).
#[tokio::test] #[tokio::test]
async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() { async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() {
let (app, engine, settings_handle, dir) = test_app(); let (app, engine, settings_handle, dir) = test_app();
@@ -1559,8 +1500,6 @@ mod tests {
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
} }
// Ingest a line carrying a logfmt key before any param is manually declared, so "dx" only
// exists as engine-internal auto-derived state (never persisted anywhere).
ingest_lines(&engine, 0, &["dx=1 moved"]).await; ingest_lines(&engine, 0, &["dx=1 moved"]).await;
let (status, _body) = send_json( let (status, _body) = send_json(
@@ -1580,22 +1519,15 @@ mod tests {
let profile = reloaded_settings.profile("web").unwrap(); let profile = reloaded_settings.profile("web").unwrap();
assert_eq!(profile.metrics.len(), 1); assert_eq!(profile.metrics.len(), 1);
assert_eq!(profile.metrics[0].name, "direction_x"); assert_eq!(profile.metrics[0].name, "direction_x");
// The auto-derived "dx" param got promoted into the profile alongside the metric.
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "dx"); assert_eq!(profile.params[0].name, "dx");
// Simulate a restart: replay the persisted profile into a fresh engine with no prior ingestion.
let (fresh_engine_tx, fresh_engine_rx) = mpsc::channel(16);
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
tokio::spawn(crate::engine::run(fresh_engine_rx, fresh_settings_handle)); let fresh_engine_tx = crate::engine::spawn(fresh_settings_handle, 16);
profile::load_into_engine(&fresh_engine_tx, profile).await; profile.load_into_engine(&fresh_engine_tx).await;
let (respond_to, response) = oneshot::channel(); let response = fresh_engine_tx.ask(EngineQuery::MetricDeclarations).await.unwrap();
fresh_engine_tx let EngineResponse::MetricDeclarations(metrics) = response else {
.send(EngineMessage::Query { query: EngineQuery::MetricDeclarations, respond_to })
.await
.unwrap();
let EngineResponse::MetricDeclarations(metrics) = response.await.unwrap() else {
panic!("unexpected response"); panic!("unexpected response");
}; };
assert_eq!(metrics.len(), 1); assert_eq!(metrics.len(), 1);
@@ -1706,7 +1638,6 @@ mod tests {
) )
.await; .await;
// Simulate a restart by reloading settings fresh from disk, independent of the running app/engine.
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let reloaded_settings = Settings::load(&settings_path); let reloaded_settings = Settings::load(&settings_path);
let dashboards = reloaded_settings.list_dashboards("web").unwrap(); let dashboards = reloaded_settings.list_dashboards("web").unwrap();
@@ -1720,6 +1651,7 @@ mod tests {
for (offset, text) in texts.iter().enumerate() { for (offset, text) in texts.iter().enumerate() {
let sequence = start_sequence + offset as u64; let sequence = start_sequence + offset as u64;
engine engine
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence, sequence,
timestamp: Timestamp::from_millis(sequence as i64 * 1000), timestamp: Timestamp::from_millis(sequence as i64 * 1000),
@@ -1728,6 +1660,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
} }
engine.barrier().await;
} }
#[tokio::test] #[tokio::test]
@@ -1743,8 +1676,6 @@ mod tests {
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
assert_eq!(body["max_logs"], 2); assert_eq!(body["max_logs"], 2);
// Existing over-cap logs are evicted as soon as the setting change
// reaches the engine, not just on the next ingest.
let (_, body) = get_json(&app, "/api/logs").await; let (_, body) = get_json(&app, "/api/logs").await;
assert_eq!(body.as_array().unwrap().len(), 2); assert_eq!(body.as_array().unwrap().len(), 2);
@@ -1789,8 +1720,6 @@ mod tests {
assert_eq!(body.as_array().unwrap().len(), 3); assert_eq!(body.as_array().unwrap().len(), 3);
} }
/// Ticket 045: seeded fixture mixing lines that carry the sort param with lines that don't,
/// exercised through the actual HTTP endpoint rather than just `handle_query` directly.
#[tokio::test] #[tokio::test]
async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() { async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() {
let (app, engine, _settings, _dir) = test_app(); let (app, engine, _settings, _dir) = test_app();
@@ -1801,28 +1730,24 @@ mod tests {
&engine, &engine,
0, 0,
&[ &[
"priority=3 boom", // sequence 0, has param "priority=3 boom",
"no param here", // sequence 1, missing "no param here",
"priority=1 boom", // sequence 2, has param "priority=1 boom",
"also missing", // sequence 3, missing "also missing",
], ],
) )
.await; .await;
// Default order (no sort_by): unchanged, plain ingest/ottyl_seq order.
let (status, body) = get_json(&app, "/api/logs").await; let (status, body) = get_json(&app, "/api/logs").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect(); let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
assert_eq!(sequences, vec![0, 1, 2, 3]); assert_eq!(sequences, vec![0, 1, 2, 3]);
// sort_by=priority with the fallback toggle on (the default): entries carrying the param
// sorted ascending by its value, then the missing-param entries ordered by ottyl_seq.
let (status, body) = get_json(&app, "/api/logs?sort_by=priority").await; let (status, body) = get_json(&app, "/api/logs?sort_by=priority").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect(); let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
assert_eq!(sequences, vec![2, 0, 1, 3]); assert_eq!(sequences, vec![2, 0, 1, 3]);
// Same sort_by, but with the fallback toggle explicitly off: missing-param lines are dropped.
let (status, body) = get_json(&app, "/api/logs?sort_by=priority&sort_missing_fallback=false").await; let (status, body) = get_json(&app, "/api/logs?sort_by=priority&sort_missing_fallback=false").await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect(); let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
+205
View File
@@ -0,0 +1,205 @@
pub mod sessions;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use crate::config::Settings;
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
crate::config::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
metrics_url: Option<String>,
#[arg(long)]
name: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Command {
Mcp {
#[arg(long)]
session: Option<String>,
#[arg(long)]
port: Option<u16>,
},
Sessions,
}
pub async fn run() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
crate::config::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
match args.command {
Some(Command::Mcp { session, port }) => {
run_mcp(&settings_path, session, port).await;
return;
}
Some(Command::Sessions) => {
print_sessions(&settings_path);
return;
}
None => {}
}
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let engine_tx = crate::engine::spawn(settings.clone(), crate::consts::ENGINE_CHANNEL_CAPACITY);
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.tx.send(crate::engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
{
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
active_profile.load_into_engine(&engine_tx).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(crate::engine::ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(crate::engine::ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(crate::engine::ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
}
let app = crate::api::router(engine_tx, settings, settings_path.clone());
let listener = TcpListener::bind((crate::consts::DEFAULT_BIND_HOST, args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
match sessions::register_session(&settings_path, &session_name, bound_port) {
Ok(_) => eprintln!(
"ottyl serving on http://localhost:{} ({}), session name: {}",
bound_port, source, session_name
),
Err(err) => eprintln!(
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
bound_port, source, session_name, err
),
}
axum::serve(listener, app)
.await
.expect("server error");
}
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
use rmcp::ServiceExt;
let port = match (port, session) {
(Some(port), _) => port,
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
Some(info) => info.port,
None => {
eprintln!("error: no running ottyl session named '{name}'");
std::process::exit(1);
}
},
(None, None) => {
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
std::process::exit(1);
}
};
let base_url = format!("http://localhost:{port}");
let service = crate::api::mcp_server::OttylMcp::new(base_url)
.serve(rmcp::transport::stdio())
.await
.expect("failed to start MCP server");
service.waiting().await.expect("MCP server error");
}
fn print_sessions(settings_path: &std::path::Path) {
let dir = sessions::sessions_dir(settings_path);
let sessions = sessions::list_sessions(&dir);
if sessions.is_empty() {
println!("no known ottyl sessions");
return;
}
for session in sessions {
println!("{}\tport={}\tpid={}", session.name, session.port, session.pid);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
}
-30
View File
@@ -1,8 +1,3 @@
//! Named session registry (ticket 068): each running `ottyl` process registers itself under a
//! name by writing a small file to `<settings dir>/sessions/<name>.json`, so a second CLI
//! invocation (e.g. `ottyl mcp --session <name>`, ticket 067) can resolve a name to a port without
//! the user copying one around by hand.
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -10,7 +5,6 @@ use std::process;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// On-disk shape of a `sessions/<name>.json` file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionInfo { pub struct SessionInfo {
pub name: String, pub name: String,
@@ -18,8 +12,6 @@ pub struct SessionInfo {
pub pid: u32, pub pid: u32,
} }
/// The `sessions/` directory for a given settings file, following the same
/// sibling-directory convention as `profiles/` (see `settings::migrate_legacy_profiles_file`).
pub fn sessions_dir(settings_path: &Path) -> PathBuf { pub fn sessions_dir(settings_path: &Path) -> PathBuf {
settings_path settings_path
.parent() .parent()
@@ -31,10 +23,6 @@ fn session_file_path(dir: &Path, name: &str) -> PathBuf {
dir.join(format!("{name}.json")) dir.join(format!("{name}.json"))
} }
/// Best-effort liveness check for a PID. Shells out to `kill -0` on unix (no signal is delivered,
/// only permission/existence is checked); assumed alive on other platforms, which just means a
/// stale session file there waits for an explicit name collision to be overwritten instead of
/// being proactively cleaned up.
#[cfg(unix)] #[cfg(unix)]
fn is_pid_alive(pid: u32) -> bool { fn is_pid_alive(pid: u32) -> bool {
process::Command::new("kill") process::Command::new("kill")
@@ -51,9 +39,6 @@ fn is_pid_alive(_pid: u32) -> bool {
true true
} }
/// Removes session files whose owning process is no longer running. Best-effort: there's no
/// shutdown hook to unregister a session on graceful exit, so this runs on every startup instead.
/// Any I/O error reading a single entry is ignored — a future call gets another chance.
pub fn cleanup_stale(dir: &Path) { pub fn cleanup_stale(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else { let Ok(entries) = fs::read_dir(dir) else {
return; return;
@@ -75,16 +60,10 @@ pub fn cleanup_stale(dir: &Path) {
} }
} }
/// Generates a random human-readable session name (e.g. `comic-gazelle`) for when `--name` isn't
/// given explicitly.
pub fn generate_name() -> String { pub fn generate_name() -> String {
petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id())) petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id()))
} }
/// Registers this process as session `name` on `port`: cleans up stale entries, then writes
/// `sessions/<name>.json`, overwriting any existing file for that name (documented behavior — a
/// second instance started under the same explicit `--name` as a still-running one simply
/// replaces its registration; the older process keeps running, just without a discoverable entry).
pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> { pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> {
let dir = sessions_dir(settings_path); let dir = sessions_dir(settings_path);
fs::create_dir_all(&dir)?; fs::create_dir_all(&dir)?;
@@ -99,9 +78,6 @@ pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Resu
Ok(path) Ok(path)
} }
/// Looks up a session by name. Returns `None` if no such file exists, it fails to parse, or its
/// process is no longer alive (a stale entry left by a process that didn't exit through the normal
/// next-`register_session` cleanup path).
pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> { pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> {
let dir = sessions_dir(settings_path); let dir = sessions_dir(settings_path);
let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?; let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?;
@@ -109,8 +85,6 @@ pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo>
is_pid_alive(info.pid).then_some(info) is_pid_alive(info.pid).then_some(info)
} }
/// Lists every session file under `dir`, live or stale (callers that only want live sessions
/// should filter on `is_pid_alive`-backed `resolve_session` instead). Used by `ottyl sessions`.
pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> { pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> {
let Ok(entries) = fs::read_dir(dir) else { let Ok(entries) = fs::read_dir(dir) else {
return Vec::new(); return Vec::new();
@@ -157,8 +131,6 @@ mod tests {
let sessions_dir_path = sessions_dir(&settings_path); let sessions_dir_path = sessions_dir(&settings_path);
fs::create_dir_all(&sessions_dir_path).unwrap(); fs::create_dir_all(&sessions_dir_path).unwrap();
// A PID essentially guaranteed not to be alive (PID 1 exists but a huge, unlikely-assigned
// PID stands in as "not alive" without depending on system PID-reuse specifics).
let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 }; let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 };
fs::write( fs::write(
session_file_path(&sessions_dir_path, "foo"), session_file_path(&sessions_dir_path, "foo"),
@@ -166,8 +138,6 @@ mod tests {
) )
.unwrap(); .unwrap();
// Registering a live session under the same name succeeds and the stale cleanup pass (run
// as part of registration) doesn't error out on the bogus pre-existing entry.
register_session(&settings_path, "foo", 5555).unwrap(); register_session(&settings_path, "foo", 5555).unwrap();
let info = resolve_session(&settings_path, "foo").unwrap(); let info = resolve_session(&settings_path, "foo").unwrap();
assert_eq!(info.port, 5555); assert_eq!(info.port, 5555);
+14 -99
View File
@@ -1,5 +1,4 @@
//! Process-wide settings model: colour scheme, CSS entries, and profiles (ticket 049 folded pub mod profile;
//! profile storage inline here instead of a separate `profiles.json`), persisted as JSON.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
@@ -13,18 +12,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::alerts::AlertRule; use crate::engine::alerts::AlertRule;
use crate::profile::Profile; use crate::config::profile::Profile;
/// Shared, process-wide settings handle. Cheap to clone.
pub type SettingsHandle = Arc<Mutex<Settings>>; pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp.
///
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
/// near-duplicate of "dark" and has been removed as a selectable option) so a
/// settings file saved before that change still loads cleanly instead of
/// falling back to `System`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ColorScheme { pub enum ColorScheme {
@@ -37,33 +29,22 @@ pub enum ColorScheme {
Yellow, Yellow,
Pink, Pink,
Orange, Orange,
/// Brighter, light-surfaced counterpart to `Green` (ticket 048).
#[serde(rename = "light_green")] #[serde(rename = "light_green")]
LightGreen, LightGreen,
/// Brighter, light-surfaced counterpart to `Red` (ticket 056).
#[serde(rename = "light_red")] #[serde(rename = "light_red")]
LightRed, LightRed,
/// Brighter, light-surfaced counterpart to `Blue` (ticket 056).
#[serde(rename = "light_blue")] #[serde(rename = "light_blue")]
LightBlue, LightBlue,
/// Brighter, light-surfaced counterpart to `Yellow` (ticket 056).
#[serde(rename = "light_yellow")] #[serde(rename = "light_yellow")]
LightYellow, LightYellow,
/// Brighter, light-surfaced counterpart to `Pink` (ticket 056).
#[serde(rename = "light_pink")] #[serde(rename = "light_pink")]
LightPink, LightPink,
/// Brighter, light-surfaced counterpart to `Orange` (ticket 056).
#[serde(rename = "light_orange")] #[serde(rename = "light_orange")]
LightOrange, LightOrange,
#[default] #[default]
System, System,
} }
/// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external
/// path/URL ("linked", not deletable via the API). Every entry is always a selectable
/// colour-scheme theme (ticket 020 removed the "use as theme"/always-on-overlay distinction);
/// an entry persisted before that change with a stored `is_theme: false` still loads fine (the
/// field is simply ignored) and becomes selectable like any other entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum CssEntry { pub enum CssEntry {
@@ -71,25 +52,15 @@ pub enum CssEntry {
Linked { path_or_url: String }, Linked { path_or_url: String },
} }
/// Legacy on-disk shape of the (pre-049) `Profiles` pointer struct: only names/active-profile/dir
/// pointers, with the actual profile content living in a separate `profiles.json`. Kept only so an
/// old `settings.json` still parses; see `ProfilesOnDisk` and `Settings::migrate_legacy_profiles_file`.
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
struct LegacyProfilesPointer { struct LegacyProfilesPointer {
// `names` is intentionally not captured: it was always redundant with the legacy
// `profiles.json`'s actual profile list, which is what migration reads instead.
#[serde(default)] #[serde(default)]
active_profile: Option<String>, active_profile: Option<String>,
/// Directory (relative to the settings file's directory, or absolute) the legacy `profiles.json`
/// lived under. Every version of this codebase ever wrote `"profiles"` here (no API ever let a
/// user change it), so migration doesn't bother re-deriving a custom value from this field.
#[serde(default)] #[serde(default)]
#[allow(dead_code)] #[allow(dead_code)]
profiles_dir: Option<String>, profiles_dir: Option<String>,
} }
/// On-disk shape of the `profiles` field: either the new inline `Vec<Profile>` (ticket 049) or the
/// old pointer-struct shape, so a `settings.json` written before this ticket still deserializes.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
enum ProfilesOnDisk { enum ProfilesOnDisk {
@@ -103,9 +74,6 @@ impl Default for ProfilesOnDisk {
} }
} }
/// Raw on-disk shape of `Settings`, used only to support deserializing an old `settings.json` whose
/// `profiles` field was the pointer struct and had no top-level `active_profile` — see `Settings`'s
/// `#[serde(from = ...)]`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct SettingsOnDisk { struct SettingsOnDisk {
color_scheme: ColorScheme, color_scheme: ColorScheme,
@@ -147,46 +115,35 @@ impl From<SettingsOnDisk> for Settings {
} }
} }
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
///
/// `profiles`/`active_profile` used to be a separate `Profiles` pointer struct referencing a
/// standalone `profiles.json` (see `crate::profile::ProfileStore`, removed by ticket 049); profile
/// data now lives inline here, as the single source of truth. `Deserialize` goes through
/// `SettingsOnDisk` so a `settings.json` written before this ticket still loads (see
/// `migrate_legacy_profiles_file`, which then imports the old `profiles.json`, if any, on `load`).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "SettingsOnDisk")] #[serde(from = "SettingsOnDisk")]
pub struct Settings { pub struct Settings {
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
pub css_entries: Vec<CssEntry>, pub css_entries: Vec<CssEntry>,
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
#[serde(default)] #[serde(default)]
pub custom_theme_id: Option<String>, pub custom_theme_id: Option<String>,
/// Every known profile, inline (ticket 049).
pub profiles: Vec<Profile>, pub profiles: Vec<Profile>,
/// Name of the currently active profile, if any.
pub active_profile: Option<String>, pub active_profile: Option<String>,
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
#[serde(default)] #[serde(default)]
pub max_logs: Option<usize>, pub max_logs: Option<usize>,
/// Alert rule definitions.
#[serde(default)] #[serde(default)]
pub alert_rules: Vec<AlertRule>, pub alert_rules: Vec<AlertRule>,
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
#[serde(default)] #[serde(default)]
pub ntfy_topic: Option<String>, pub ntfy_topic: Option<String>,
/// Ntfy server override; `None` means `https://ntfy.sh`.
#[serde(default)] #[serde(default)]
pub ntfy_server: Option<String>, pub ntfy_server: Option<String>,
} }
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
pub fn default_settings_path() -> Option<PathBuf> { pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json")) ProjectDirs::from(
crate::consts::APP_QUALIFIER,
crate::consts::APP_ORGANIZATION,
crate::consts::APP_NAME,
)
.map(|dirs| dirs.config_dir().join(crate::consts::SETTINGS_FILE_NAME))
} }
impl Settings { impl Settings {
/// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file. Also runs the one-time legacy `profiles.json` migration (see `migrate_legacy_profiles_file`).
pub fn load(path: &Path) -> Settings { pub fn load(path: &Path) -> Settings {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<Settings>(&contents) { Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
@@ -224,7 +181,6 @@ impl Settings {
} }
} }
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
pub fn save(&self, path: &Path) -> io::Result<()> { pub fn save(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@@ -234,14 +190,6 @@ impl Settings {
fs::write(path, contents) fs::write(path, contents)
} }
/// One-time migration (ticket 049): if `self.profiles` is empty (a fresh `Settings`, or one
/// parsed from a pre-049 `settings.json` whose profiles lived in a separate file) and a legacy
/// `<settings_path's parent>/profiles/profiles.json` exists, reads it in, merges it into
/// `self.profiles`, persists `self`, and — only once that save succeeds — renames the old file
/// (`.migrated` suffix) so it isn't re-imported on a later load. Every prior version of this
/// codebase always used the fixed `"profiles"` subdirectory name (no API ever let it be
/// reconfigured), so that's the only location checked. Best-effort: any failure is logged and
/// left for a future load to retry rather than panicking.
fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) { fn migrate_legacy_profiles_file(&mut self, settings_path: &Path) {
if !self.profiles.is_empty() { if !self.profiles.is_empty() {
return; return;
@@ -251,7 +199,7 @@ impl Settings {
.map(|parent| parent.join("profiles")) .map(|parent| parent.join("profiles"))
.unwrap_or_else(|| PathBuf::from("profiles")) .unwrap_or_else(|| PathBuf::from("profiles"))
.join("profiles.json"); .join("profiles.json");
let Some(profiles) = crate::profile::load_legacy_profile_store(&legacy_path) else { let Some(profiles) = crate::config::profile::Profile::load_legacy_store(&legacy_path) else {
return; return;
}; };
self.profiles = profiles; self.profiles = profiles;
@@ -271,14 +219,8 @@ impl Settings {
} }
} }
/// Migrates any pre-037/pre-038 metric or param shapes still present in `self.profiles` into
/// their current form (see `crate::profile::migrate_legacy_profile_shapes`), persisting `self`
/// if anything changed. Runs on every load — previously done inside the now-removed
/// `ProfileStore::load` (ticket 049 folded profile storage into `settings.json`) — so a profile
/// saved by an older version of ottyl, whether loaded inline here or just merged in by
/// `migrate_legacy_profiles_file`, still gets migrated forward.
fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) { fn migrate_legacy_profile_shapes(&mut self, settings_path: &Path) {
if crate::profile::migrate_legacy_profile_shapes(&mut self.profiles) if crate::config::profile::migrate_legacy_profile_shapes(&mut self.profiles)
&& let Err(err) = self.save(settings_path) && let Err(err) = self.save(settings_path)
{ {
eprintln!( eprintln!(
@@ -289,14 +231,10 @@ impl Settings {
} }
} }
/// Error returned by CSS-entry mutations and lookups.
#[derive(Debug)] #[derive(Debug)]
pub enum CssError { pub enum CssError {
/// No entry with the given id exists.
NotFound, NotFound,
/// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
NotImported, NotImported,
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
Io(io::Error), Io(io::Error),
} }
@@ -318,7 +256,6 @@ impl From<io::Error> for CssError {
} }
} }
/// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
pub fn css_entry_id(entry: &CssEntry) -> String { pub fn css_entry_id(entry: &CssEntry) -> String {
match entry { match entry {
CssEntry::Imported { id, .. } => id.clone(), CssEntry::Imported { id, .. } => id.clone(),
@@ -332,7 +269,6 @@ fn linked_entry_id(path_or_url: &str) -> String {
format!("linked-{:016x}", hasher.finish()) format!("linked-{:016x}", hasher.finish())
} }
/// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
fn generate_imported_css_id(filename: &str, content: &str) -> String { fn generate_imported_css_id(filename: &str, content: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
filename.hash(&mut hasher); filename.hash(&mut hasher);
@@ -350,7 +286,6 @@ fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
} }
impl Settings { impl Settings {
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
pub fn import_css( pub fn import_css(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -369,14 +304,12 @@ impl Settings {
Ok(id) Ok(id)
} }
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> { pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
self.css_entries.push(CssEntry::Linked { path_or_url }); self.css_entries.push(CssEntry::Linked { path_or_url });
self.save(settings_path)?; self.save(settings_path)?;
Ok(()) Ok(())
} }
/// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
pub fn remove_css( pub fn remove_css(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -393,7 +326,6 @@ impl Settings {
Some(CssEntry::Imported { .. }) => { Some(CssEntry::Imported { .. }) => {
let idx = index.unwrap(); let idx = index.unwrap();
self.css_entries.remove(idx); self.css_entries.remove(idx);
// Best-effort: the settings entry is already removed even if the file is gone.
let _ = fs::remove_file(imported_css_file_path(css_dir, id)); let _ = fs::remove_file(imported_css_file_path(css_dir, id));
self.save(settings_path)?; self.save(settings_path)?;
Ok(()) Ok(())
@@ -401,7 +333,6 @@ impl Settings {
} }
} }
/// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> { pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) { match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
None => Err(CssError::NotFound), None => Err(CssError::NotFound),
@@ -419,7 +350,6 @@ mod tests {
#[test] #[test]
fn default_settings_path_ends_with_settings_json() { fn default_settings_path_ends_with_settings_json() {
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
if let Some(path) = default_settings_path() { if let Some(path) = default_settings_path() {
assert_eq!(path.file_name().unwrap(), "settings.json"); assert_eq!(path.file_name().unwrap(), "settings.json");
assert!(path.parent().is_some()); assert!(path.parent().is_some());
@@ -466,10 +396,6 @@ mod tests {
assert_eq!(settings.color_scheme, ColorScheme::Dark); assert_eq!(settings.color_scheme, ColorScheme::Dark);
} }
/// A `settings.json` written before ticket 049 (nested `Profiles` pointer struct, no top-level
/// `active_profile`) still parses: the pointer's `active_profile` carries over, and `profiles`
/// starts empty (the real content lived in a separate `profiles.json`, migrated separately by
/// `Settings::load`, not by plain deserialization).
#[test] #[test]
fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() { fn legacy_profiles_pointer_shape_deserializes_with_empty_inline_profiles() {
let settings: Settings = serde_json::from_str( let settings: Settings = serde_json::from_str(
@@ -494,13 +420,13 @@ mod tests {
settings.css_entries.push(CssEntry::Linked { settings.css_entries.push(CssEntry::Linked {
path_or_url: "https://example.com/style.css".to_string(), path_or_url: "https://example.com/style.css".to_string(),
}); });
settings.profiles.push(crate::profile::Profile::new("default".to_string())); settings.profiles.push(crate::config::profile::Profile::new("default".to_string()));
settings.active_profile = Some("default".to_string()); settings.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::alerts::AlertRule { settings.alert_rules.push(crate::engine::alerts::AlertRule {
id: "rule1".to_string(), id: "rule1".to_string(),
name: "error spike".to_string(), name: "error spike".to_string(),
enabled: true, enabled: true,
condition: crate::alerts::AlertCondition::Immediate { condition: crate::engine::alerts::AlertCondition::Immediate {
query: r#"regex("error")"#.to_string(), query: r#"regex("error")"#.to_string(),
}, },
}); });
@@ -543,7 +469,6 @@ mod tests {
assert!(css_file.exists()); assert!(css_file.exists());
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }"); assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
// Drop and reload settings from disk (simulates a restart).
drop(settings); drop(settings);
let reloaded = Settings::load(&settings_path); let reloaded = Settings::load(&settings_path);
assert!(reloaded.css_entries.iter().any(|entry| matches!( assert!(reloaded.css_entries.iter().any(|entry| matches!(
@@ -593,7 +518,6 @@ mod tests {
let result = settings.remove_css(&settings_path, &css_dir, &linked_id); let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
assert!(matches!(result, Err(CssError::NotImported))); assert!(matches!(result, Err(CssError::NotImported)));
// Entry must still be there — rejection shouldn't silently drop it.
assert_eq!(settings.css_entries.len(), 1); assert_eq!(settings.css_entries.len(), 1);
} }
@@ -624,9 +548,6 @@ mod tests {
assert!(matches!(result, Err(CssError::NotImported))); assert!(matches!(result, Err(CssError::NotImported)));
} }
/// Acceptance criterion (ticket 049): a pre-existing legacy `profiles_dir/profiles.json` is
/// merged into `settings.json` on the next load, and the old file is renamed (not re-imported on
/// a subsequent load).
#[test] #[test]
fn load_migrates_legacy_profiles_file_into_inline_settings() { fn load_migrates_legacy_profiles_file_into_inline_settings() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -639,8 +560,6 @@ mod tests {
r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#, r#"[{"name":"web","params":[],"metrics":[],"dashboards":[]}]"#,
) )
.unwrap(); .unwrap();
// No settings.json yet: simulates upgrading a pre-049 install that never wrote one either,
// or one that did but whose nested pointer already carried an active_profile name.
fs::write( fs::write(
&settings_path, &settings_path,
r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#, r#"{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{"names":["web"],"active_profile":"web","profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
@@ -652,19 +571,15 @@ mod tests {
assert_eq!(settings.profiles[0].name, "web"); assert_eq!(settings.profiles[0].name, "web");
assert_eq!(settings.active_profile, Some("web".to_string())); assert_eq!(settings.active_profile, Some("web".to_string()));
// The legacy file is gone (renamed), so it isn't re-imported.
assert!(!legacy_path.exists()); assert!(!legacy_path.exists());
let migrated_path = legacy_path.with_extension("json.migrated"); let migrated_path = legacy_path.with_extension("json.migrated");
assert!(migrated_path.exists()); assert!(migrated_path.exists());
// Reload from disk: the migrated settings.json now carries profiles inline directly.
let reloaded = Settings::load(&settings_path); let reloaded = Settings::load(&settings_path);
assert_eq!(reloaded.profiles.len(), 1); assert_eq!(reloaded.profiles.len(), 1);
assert_eq!(reloaded.profiles[0].name, "web"); assert_eq!(reloaded.profiles[0].name, "web");
} }
/// A fresh install (no legacy file at all) never creates `profiles.json` — it's not even looked
/// for beyond the one migration check, and `profiles` simply stays empty.
#[test] #[test]
fn load_without_legacy_file_leaves_profiles_empty() { fn load_without_legacy_file_leaves_profiles_empty() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+72 -265
View File
@@ -1,8 +1,3 @@
//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app.
//! Ticket 049 folded profile storage inline into `Settings` (`settings.json`) instead of a separate
//! `profiles.json`; this module still owns the `Profile`/`Dashboard`/declaration data model, plus the
//! CRUD methods on `Settings` (`impl Settings` below `ProfileError`) that used to live on the
//! now-removed `ProfileStore`.
#![allow(dead_code)] #![allow(dead_code)]
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
@@ -16,24 +11,19 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::data_value::MetricPrimitiveKind; use crate::consts::{DEFAULT_PROFILE_NAME, SEEDED_METRIC_LABEL, SEEDED_METRIC_NAME};
use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage}; use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
use crate::settings::Settings; use crate::config::Settings;
use tokio::sync::oneshot; use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see `Settings::ensure_default_profile`).
pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProfileMetricPrimitiveKind { pub enum ProfileMetricPrimitiveKind {
String, String,
Number, Number,
Timestamp, Timestamp,
/// Mirrors `MetricPrimitiveKind::Duration`.
Duration, Duration,
/// Mirrors `MetricPrimitiveKind::Boolean`.
Boolean, Boolean,
} }
@@ -61,20 +51,14 @@ impl From<ProfileMetricPrimitiveKind> for MetricPrimitiveKind {
} }
} }
/// A stored param declaration, matching `DeclareParamRequest`'s shape (== `data_value::ParamDeclaration`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamDeclarationRecord { pub struct ParamDeclarationRecord {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
pub capture_group: String, pub capture_group: String,
/// See `crate::data_value::ParamDeclaration::json_field_path`; `#[serde(default)]`
/// so profiles saved before this field existed still load as plain regex-capture-group params.
#[serde(default)] #[serde(default)]
pub json_field_path: Option<String>, pub json_field_path: Option<String>,
pub kind: ProfileMetricPrimitiveKind, pub kind: ProfileMetricPrimitiveKind,
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`; `#[serde(default)]` so
/// profiles saved before ticket 038 (where a `Duration`-kind value param implicitly meant this)
/// still load — `migrate_duration_value_params` below converts those.
#[serde(default)] #[serde(default)]
pub elapsed_since_last: bool, pub elapsed_since_last: bool,
} }
@@ -101,16 +85,12 @@ impl From<&ParamDeclarationRecord> for DeclareParamRequest {
json_field_path: record.json_field_path.clone(), json_field_path: record.json_field_path.clone(),
kind: record.kind.into(), kind: record.kind.into(),
elapsed_since_last: record.elapsed_since_last, elapsed_since_last: record.elapsed_since_last,
// A profile only ever stores manually-declared params (see `record_param_declaration`).
auto_derived: false, auto_derived: false,
log_stream_gated: false,
} }
} }
} }
/// Pre-037 role of an inline metric param matcher — a label identifying a bucket, or the (at most
/// one) value that becomes the event's captured value. Kept only so `LegacyMetricParamRecord` can
/// still deserialize a profile saved before params were unified into standalone `ParamDeclaration`s
/// referenced by name; see `migrate_legacy_metric_params`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum LegacyParamRole { enum LegacyParamRole {
@@ -119,10 +99,6 @@ enum LegacyParamRole {
Value, Value,
} }
/// Pre-037 inline metric param matcher shape (formerly `data_value::RegexToMetricPrimitiveMatcher`),
/// kept only to support migrating profiles saved before ticket 037 unified params — see
/// `migrate_legacy_metric_params`. Never produced when serializing a record built from a
/// `DeclareMetricRequest`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct LegacyMetricParamRecord { struct LegacyMetricParamRecord {
param_name: String, param_name: String,
@@ -134,25 +110,16 @@ struct LegacyMetricParamRecord {
role: LegacyParamRole, role: LegacyParamRole,
} }
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape. `legacy_params` is only
/// ever populated by deserializing a pre-037 profile file (old key name `"params"`) — see
/// `migrate_legacy_metric_params`, which converts it into `label_params`/`value_param` plus any
/// implicitly-created standalone params, and empties it back out so a subsequent save never writes
/// the old shape again.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetricDeclarationRecord { pub struct MetricDeclarationRecord {
pub name: String, pub name: String,
pub message_match_regex: String, pub message_match_regex: String,
/// Names of standalone `ParamDeclarationRecord`s used as this metric's bucket labels.
#[serde(default)] #[serde(default)]
pub label_params: Vec<String>, pub label_params: Vec<String>,
/// Name of the standalone `ParamDeclarationRecord` (if any) whose value becomes this metric's bucket value.
#[serde(default)] #[serde(default)]
pub value_param: Option<String>, pub value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
#[serde(default)] #[serde(default)]
pub matched_labels: Option<std::collections::BTreeMap<String, String>>, pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
#[serde(default)] #[serde(default)]
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>, pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
#[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")] #[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")]
@@ -186,51 +153,41 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
} }
} }
/// Migrates every metric in `profile` still carrying pre-037 inline matchers impl Profile {
/// (`MetricDeclarationRecord::legacy_params`, deserialized from an old profile file's `"params"` fn migrate_legacy_metric_params(&mut self) -> bool {
/// key) into the unified shape: each matcher becomes a reference to a standalone
/// `ParamDeclarationRecord`, resolved by reusing an existing declared param of the exact same name
/// and shape (regex/capture-group-or-json-field-path/kind) when one exists, or else creating a new
/// one implicitly named `<metric_name>.<param_name>` (added to `profile.params`). Deterministic
/// naming means re-running this on an already-migrated profile is a no-op, and running it twice on
/// the same legacy data produces the same result. Returns whether anything changed, so the caller
/// can decide whether to persist the migration.
fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
let mut migrated = false; let mut migrated = false;
for metric_index in 0..profile.metrics.len() { for metric_index in 0..self.metrics.len() {
if profile.metrics[metric_index].legacy_params.is_empty() { if self.metrics[metric_index].legacy_params.is_empty() {
continue; continue;
} }
let metric_name = profile.metrics[metric_index].name.clone(); let metric_name = self.metrics[metric_index].name.clone();
let message_match_regex = profile.metrics[metric_index].message_match_regex.clone(); let message_match_regex = self.metrics[metric_index].message_match_regex.clone();
let legacy = std::mem::take(&mut profile.metrics[metric_index].legacy_params); let legacy = std::mem::take(&mut self.metrics[metric_index].legacy_params);
let mut label_params = Vec::new(); let mut label_params = Vec::new();
let mut value_param = None; let mut value_param = None;
for matcher in &legacy { for matcher in &legacy {
let param_name = let param_name =
resolve_or_create_legacy_param(profile, &metric_name, &message_match_regex, matcher); self.resolve_or_create_legacy_param(&metric_name, &message_match_regex, matcher);
match matcher.role { match matcher.role {
LegacyParamRole::Label => label_params.push(param_name), LegacyParamRole::Label => label_params.push(param_name),
LegacyParamRole::Value => value_param = Some(param_name), LegacyParamRole::Value => value_param = Some(param_name),
} }
} }
profile.metrics[metric_index].label_params = label_params; self.metrics[metric_index].label_params = label_params;
profile.metrics[metric_index].value_param = value_param; self.metrics[metric_index].value_param = value_param;
migrated = true; migrated = true;
} }
migrated migrated
} }
/// Resolves one legacy matcher to a standalone param name, reusing an existing declared param of
/// the same bare name and shape, or else creating `<metric_name>.<param_name>` on `profile.params`.
fn resolve_or_create_legacy_param( fn resolve_or_create_legacy_param(
profile: &mut Profile, &mut self,
metric_name: &str, metric_name: &str,
message_match_regex: &str, message_match_regex: &str,
matcher: &LegacyMetricParamRecord, matcher: &LegacyMetricParamRecord,
) -> String { ) -> String {
if let Some(existing) = profile.params.iter().find(|p| p.name == matcher.param_name) { if let Some(existing) = self.params.iter().find(|p| p.name == matcher.param_name) {
if existing.message_match_regex == message_match_regex if existing.message_match_regex == message_match_regex
&& existing.capture_group == matcher.capture_group && existing.capture_group == matcher.capture_group
&& existing.json_field_path == matcher.json_field_path && existing.json_field_path == matcher.json_field_path
@@ -241,16 +198,13 @@ fn resolve_or_create_legacy_param(
} }
let implicit_name = format!("{metric_name}.{}", matcher.param_name); let implicit_name = format!("{metric_name}.{}", matcher.param_name);
if !profile.params.iter().any(|p| p.name == implicit_name) { if !self.params.iter().any(|p| p.name == implicit_name) {
profile.params.push(ParamDeclarationRecord { self.params.push(ParamDeclarationRecord {
name: implicit_name.clone(), name: implicit_name.clone(),
message_match_regex: message_match_regex.to_string(), message_match_regex: message_match_regex.to_string(),
capture_group: matcher.capture_group.clone(), capture_group: matcher.capture_group.clone(),
json_field_path: matcher.json_field_path.clone(), json_field_path: matcher.json_field_path.clone(),
kind: matcher.kind, kind: matcher.kind,
// Pre-037, a Value-role Duration matcher implicitly meant "elapsed time since the
// previous same-labeled event" (ticket 038's now-explicit `elapsed_since_last`) — carry
// that meaning forward rather than dropping it into a meaningless literal-parse matcher.
elapsed_since_last: matcher.role == LegacyParamRole::Value elapsed_since_last: matcher.role == LegacyParamRole::Value
&& matcher.kind == ProfileMetricPrimitiveKind::Duration, && matcher.kind == ProfileMetricPrimitiveKind::Duration,
}); });
@@ -258,23 +212,11 @@ fn resolve_or_create_legacy_param(
implicit_name implicit_name
} }
/// Migrates every already-declared `Duration`-kind param that's referenced as some metric's fn migrate_duration_value_params(&mut self) -> bool {
/// `value_param` into the explicit `elapsed_since_last` extractor (ticket 038). Before ticket 038, a
/// `Duration`-kind value param implicitly meant "elapsed time since the previous same-labeled
/// event" (see `crate::engine::apply_metric_to_log`'s pre-038 history) — this migration makes that
/// meaning explicit so `Duration` kind no longer has a role-dependent special case. Only params
/// actually referenced as *some* metric's value param are converted: a `Duration`-kind param used
/// purely as a label param already meant a literal parsed millisecond count, and keeps meaning that.
/// Runs after `migrate_legacy_metric_params` (which populates `value_param` from pre-037
/// `legacy_params` and already sets `elapsed_since_last` correctly for those via
/// `resolve_or_create_legacy_param`), so this only has new work to do for profiles saved between
/// ticket 037 and ticket 038 landing, where `value_param` already pointed at a bare `Duration`-kind
/// param straight from the unified shape. Returns whether anything changed.
fn migrate_duration_value_params(profile: &mut Profile) -> bool {
let value_param_names: std::collections::HashSet<&str> = let value_param_names: std::collections::HashSet<&str> =
profile.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect(); self.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect();
let mut migrated = false; let mut migrated = false;
for param in &mut profile.params { for param in &mut self.params {
if !param.elapsed_since_last if !param.elapsed_since_last
&& param.kind == ProfileMetricPrimitiveKind::Duration && param.kind == ProfileMetricPrimitiveKind::Duration
&& value_param_names.contains(param.name.as_str()) && value_param_names.contains(param.name.as_str())
@@ -285,8 +227,8 @@ fn migrate_duration_value_params(profile: &mut Profile) -> bool {
} }
migrated migrated
} }
}
/// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ChartType { pub enum ChartType {
@@ -295,7 +237,6 @@ pub enum ChartType {
Pie, Pie,
} }
/// One panel within a dashboard: a PromQL query plus how to chart its result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Panel { pub struct Panel {
pub id: String, pub id: String,
@@ -304,7 +245,6 @@ pub struct Panel {
pub chart_type: ChartType, pub chart_type: ChartType,
} }
/// A named set of panels, saved as part of a profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Dashboard { pub struct Dashboard {
pub id: String, pub id: String,
@@ -313,7 +253,6 @@ pub struct Dashboard {
pub panels: Vec<Panel>, pub panels: Vec<Panel>,
} }
/// Generates an id by hashing a seed (dashboard/panel name) with the current time, avoiding a `uuid` dependency for what's otherwise an opaque string.
fn generate_id(seed: &str) -> String { fn generate_id(seed: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
seed.hash(&mut hasher); seed.hash(&mut hasher);
@@ -332,12 +271,8 @@ impl Dashboard {
} }
} }
/// Name of the metric seeded into a brand-new `Default` profile, used to drive its starter dashboard's two panels. impl MetricDeclarationRecord {
const SEEDED_METRIC_NAME: &str = "log_category"; fn seeded_error_split() -> MetricDeclarationRecord {
const SEEDED_METRIC_LABEL: &str = "category";
/// A counter that splits every line into `category="error"`/`"other"` via `matched_labels`/`unmatched_labels` rather than a captured param, so case variants like "ERROR" collapse into one series; the `regex` crate lacks the lookaround needed to express "didn't match" any other way.
fn seeded_error_split_metric() -> MetricDeclarationRecord {
MetricDeclarationRecord { MetricDeclarationRecord {
name: SEEDED_METRIC_NAME.to_string(), name: SEEDED_METRIC_NAME.to_string(),
message_match_regex: r"(?i)error".to_string(), message_match_regex: r"(?i)error".to_string(),
@@ -348,9 +283,10 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord {
legacy_params: Vec::new(), legacy_params: Vec::new(),
} }
} }
}
/// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter. impl Dashboard {
fn seeded_default_dashboard() -> Dashboard { fn seeded_default() -> Dashboard {
let mut dashboard = Dashboard::new("Overview".to_string()); let mut dashboard = Dashboard::new("Overview".to_string());
dashboard.panels = vec![ dashboard.panels = vec![
Panel { Panel {
@@ -368,8 +304,8 @@ fn seeded_default_dashboard() -> Dashboard {
]; ];
dashboard dashboard
} }
}
/// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile { pub struct Profile {
pub name: String, pub name: String,
@@ -392,69 +328,67 @@ impl Profile {
} }
} }
/// Replays every param/metric declaration stored in `profile` into `engine` (used on startup and in tests); any rejected declaration or send/recv failure is logged to stderr and skipped rather than panicking. impl Profile {
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) { pub async fn load_into_engine(&self, engine: &EngineHandle) {
for record in &profile.params { for record in &self.params {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
let request: DeclareParamRequest = record.into(); let request: DeclareParamRequest = record.into();
let name = request.name.clone(); let name = request.name.clone();
if engine if engine
.tx
.send(EngineMessage::DeclareParam { request, respond_to }) .send(EngineMessage::DeclareParam { request, respond_to })
.await .await
.is_err() .is_err()
{ {
eprintln!("warning: engine unavailable while loading profile '{}'", profile.name); eprintln!("warning: engine unavailable while loading profile '{}'", self.name);
return; return;
} }
match response.await { match response.await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(err)) => eprintln!( Ok(Err(err)) => eprintln!(
"warning: profile '{}' param declaration '{name}' rejected by engine: {err}", "warning: profile '{}' param declaration '{name}' rejected by engine: {err}",
profile.name self.name
), ),
Err(_) => eprintln!( Err(_) => eprintln!(
"warning: engine dropped response while declaring param '{name}' from profile '{}'", "warning: engine dropped response while declaring param '{name}' from profile '{}'",
profile.name self.name
), ),
} }
} }
for record in &profile.metrics { for record in &self.metrics {
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
let request: DeclareMetricRequest = record.into(); let request: DeclareMetricRequest = record.into();
let name = request.name.clone(); let name = request.name.clone();
if engine if engine
.tx
.send(EngineMessage::DeclareMetric { request, respond_to }) .send(EngineMessage::DeclareMetric { request, respond_to })
.await .await
.is_err() .is_err()
{ {
eprintln!("warning: engine unavailable while loading profile '{}'", profile.name); eprintln!("warning: engine unavailable while loading profile '{}'", self.name);
return; return;
} }
match response.await { match response.await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(err)) => eprintln!( Ok(Err(err)) => eprintln!(
"warning: profile '{}' metric declaration '{name}' rejected by engine: {err}", "warning: profile '{}' metric declaration '{name}' rejected by engine: {err}",
profile.name self.name
), ),
Err(_) => eprintln!( Err(_) => eprintln!(
"warning: engine dropped response while declaring metric '{name}' from profile '{}'", "warning: engine dropped response while declaring metric '{name}' from profile '{}'",
profile.name self.name
), ),
} }
} }
} }
}
/// Error returned by `Settings`'s profile-related mutations and lookups.
#[derive(Debug)] #[derive(Debug)]
pub enum ProfileError { pub enum ProfileError {
/// No profile with the given name exists.
NotFound, NotFound,
/// A profile with the given name already exists.
DuplicateName, DuplicateName,
/// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
CannotDeleteDefault, CannotDeleteDefault,
/// Underlying I/O failure reading/writing the profile store or settings file.
Io(io::Error), Io(io::Error),
} }
@@ -477,11 +411,8 @@ impl From<io::Error> for ProfileError {
} }
} }
/// Reads a legacy pre-049 `profiles.json` file (back when profiles lived in a separate file rather impl Profile {
/// than inline in `settings.json`) for `Settings::migrate_legacy_profiles_file`. Returns `None` if pub(crate) fn load_legacy_store(path: &Path) -> Option<Vec<Profile>> {
/// the file doesn't exist or can't be read/parsed — treated as "nothing to migrate" rather than
/// failing settings load.
pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
let contents = fs::read_to_string(path).ok()?; let contents = fs::read_to_string(path).ok()?;
match serde_json::from_str::<Vec<Profile>>(&contents) { match serde_json::from_str::<Vec<Profile>>(&contents) {
Ok(profiles) => Some(profiles), Ok(profiles) => Some(profiles),
@@ -494,29 +425,13 @@ pub(crate) fn load_legacy_profile_store(path: &Path) -> Option<Vec<Profile>> {
} }
} }
} }
}
/// Turns any pre-unification/pre-explicit-duration metric or param shapes still present in
/// `profiles` into their current form; used by `Settings::load` on every startup (previously done
/// by the now-removed `ProfileStore::load`, before ticket 049 folded profile storage into
/// `settings.json`). Returns whether anything changed, so the caller can decide whether to
/// re-persist.
///
/// Ticket 037: turn any pre-unification inline metric matchers into standalone param references.
/// Deterministic naming makes this idempotent, so it's safe to run on every load; persisting the
/// result isn't required for correctness (migration re-runs identically next time), but it's done
/// here anyway so the on-disk file only carries the legacy `"params"` shape once, on the very next
/// save.
///
/// Ticket 038: follows on immediately after — once `migrate_legacy_metric_params` has resolved
/// every metric's `value_param`, convert any `Duration`-kind param still referenced that way into
/// the explicit `elapsed_since_last` extractor, so a profile saved either before 037 (inline
/// matchers) or between 037 and 038 (bare `value_param` reference) ends up behaving identically
/// under the new, explicit shape.
pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool { pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
let mut any_migrated = false; let mut any_migrated = false;
for profile in profiles { for profile in profiles {
let legacy_migrated = migrate_legacy_metric_params(profile); let legacy_migrated = profile.migrate_legacy_metric_params();
let duration_migrated = migrate_duration_value_params(profile); let duration_migrated = profile.migrate_duration_value_params();
if legacy_migrated || duration_migrated { if legacy_migrated || duration_migrated {
any_migrated = true; any_migrated = true;
} }
@@ -524,17 +439,11 @@ pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
any_migrated any_migrated
} }
/// Profile CRUD, previously on the standalone `ProfileStore` (removed by ticket 049): every mutation
/// here reads/writes `self.profiles` directly and persists via `Settings::save`, so `settings.json` is
/// the single source of truth instead of a separate file kept in sync alongside it.
impl Settings { impl Settings {
pub fn profile(&self, name: &str) -> Option<&Profile> { pub fn profile(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name) self.profiles.iter().find(|p| p.name == name)
} }
/// Creates a new profile named `name`, persisting settings, copying the active profile's
/// params/metrics/dashboards as a starting point rather than starting blank. Fails with
/// `DuplicateName` if the name exists.
pub fn create_profile(&mut self, settings_path: &Path, name: String) -> Result<(), ProfileError> { pub fn create_profile(&mut self, settings_path: &Path, name: String) -> Result<(), ProfileError> {
if self.profile(&name).is_some() { if self.profile(&name).is_some() {
return Err(ProfileError::DuplicateName); return Err(ProfileError::DuplicateName);
@@ -550,8 +459,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Deletes the profile named `name`, persisting settings, clearing `active_profile` if it was
/// active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> { pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> {
if name == DEFAULT_PROFILE_NAME { if name == DEFAULT_PROFILE_NAME {
return Err(ProfileError::CannotDeleteDefault); return Err(ProfileError::CannotDeleteDefault);
@@ -565,14 +472,11 @@ impl Settings {
Ok(()) Ok(())
} }
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first
/// creation), and falls back to active if nothing else is. Called once on startup before anything
/// else touches profiles.
pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> { pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> {
if self.profile(DEFAULT_PROFILE_NAME).is_none() { if self.profile(DEFAULT_PROFILE_NAME).is_none() {
let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string()); let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string());
profile.metrics.push(seeded_error_split_metric()); profile.metrics.push(MetricDeclarationRecord::seeded_error_split());
profile.dashboards.push(seeded_default_dashboard()); profile.dashboards.push(Dashboard::seeded_default());
self.profiles.push(profile); self.profiles.push(profile);
} }
if self.active_profile.is_none() { if self.active_profile.is_none() {
@@ -582,9 +486,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Sets (or clears, with `None`) the active profile. Fails with `NotFound` if `name` is `Some`
/// and no such profile exists. Doesn't itself reload declarations into a running engine — that
/// happens on next startup via `load_into_engine`.
pub fn set_active_profile( pub fn set_active_profile(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -600,11 +501,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Empties `profile_name`'s stored param declarations, persisting settings — the counterpart to
/// `record_param_declaration` for `DELETE /api/params` (ticket 052). A profile only ever stores
/// manually-declared params (auto-derived ones live only in the engine, never persisted here), so
/// this is enough to keep the whole set from reappearing on next load; the engine-side clear
/// re-derives whatever auto params the retained log text still supports.
pub fn clear_param_declarations( pub fn clear_param_declarations(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -620,9 +516,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param
/// name), persisting settings. Intended to be called by the HTTP API alongside declaring against
/// the engine, so declarations survive a restart.
pub fn record_param_declaration( pub fn record_param_declaration(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -643,17 +536,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric
/// name), persisting settings; same integration intent as `record_param_declaration`.
///
/// `promoted_params` is any of the metric's referenced `label_params`/`value_param` that live
/// only as auto-derived engine state (see `crate::engine::auto_declare_params`) rather than a
/// persisted profile param — the caller (`ApiState::persist_metric_declaration`) resolves these
/// against the live engine before calling in. Without persisting them alongside the metric, a
/// restart would replay the metric before any log line re-derives its auto params, and the
/// engine would reject it as referencing an undeclared param. Each is added only if `profile
/// .params` doesn't already have an entry of that name, so an existing (e.g. manually-declared)
/// param is never clobbered.
pub fn record_metric_declaration( pub fn record_metric_declaration(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -680,10 +562,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Same as `record_metric_declaration`, for an in-place edit that may rename the metric: drops
/// any persisted record under `old_name` first (when it differs from the new name) so a rename
/// doesn't leave a stale duplicate declaration behind, then upserts by the new name as usual.
/// `promoted_params` has the same meaning as in `record_metric_declaration`.
pub fn record_metric_declaration_update( pub fn record_metric_declaration_update(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -714,11 +592,6 @@ impl Settings {
Ok(()) Ok(())
} }
/// Removes `name`'s persisted metric declaration from `profile_name` (ticket 051), so it doesn't
/// reappear from `settings.json` on the next restart. A no-op (not an error) if the profile has
/// no record under that name — matches `Settings::remove_css`'s sibling calls in spirit, but
/// deleting an already-gone declaration is fine here since the caller (the API layer) has
/// already confirmed against the live engine index whether the metric existed at all.
pub fn remove_metric_declaration( pub fn remove_metric_declaration(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -735,14 +608,11 @@ impl Settings {
Ok(()) Ok(())
} }
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> { pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?; let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
Ok(&profile.dashboards) Ok(&profile.dashboards)
} }
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting settings; fails
/// with `NotFound` if the profile doesn't exist.
pub fn create_dashboard( pub fn create_dashboard(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -760,9 +630,6 @@ impl Settings {
Ok(dashboard) Ok(dashboard)
} }
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting settings;
/// `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity
/// through this call.
pub fn save_dashboard( pub fn save_dashboard(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -786,8 +653,6 @@ impl Settings {
Ok(dashboard) Ok(dashboard)
} }
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting settings;
/// fails with `NotFound` if either doesn't exist.
pub fn delete_dashboard( pub fn delete_dashboard(
&mut self, &mut self,
settings_path: &Path, settings_path: &Path,
@@ -813,11 +678,10 @@ impl Settings {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::dto::LogsParams; use crate::api::dto::LogsParams;
use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use tokio::sync::mpsc;
fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest { fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest {
DeclareParamRequest { DeclareParamRequest {
@@ -828,6 +692,7 @@ mod tests {
kind: MetricPrimitiveKind::String, kind: MetricPrimitiveKind::String,
elapsed_since_last: false, elapsed_since_last: false,
auto_derived: false, auto_derived: false,
log_stream_gated: false,
} }
} }
@@ -843,9 +708,9 @@ mod tests {
} }
async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse { async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse {
let (respond_to, response) = oneshot::channel(); let read_handle = engine.reader.handle();
engine.send(EngineMessage::Query { query, respond_to }).await.unwrap(); let guard = read_handle.enter().expect("engine unavailable");
response.await.unwrap() guard.handle_query_ro(query)
} }
#[test] #[test]
@@ -863,7 +728,6 @@ mod tests {
let dup = settings.create_profile(&settings_path, "web".to_string()); let dup = settings.create_profile(&settings_path, "web".to_string());
assert!(matches!(dup, Err(ProfileError::DuplicateName))); assert!(matches!(dup, Err(ProfileError::DuplicateName)));
// Reload from disk: profiles persisted.
let reloaded_settings = Settings::load(&settings_path); let reloaded_settings = Settings::load(&settings_path);
assert_eq!(reloaded_settings.profiles.len(), 2); assert_eq!(reloaded_settings.profiles.len(), 2);
assert!(reloaded_settings.profile("web").is_some()); assert!(reloaded_settings.profile("web").is_some());
@@ -888,7 +752,6 @@ mod tests {
assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some()); assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some());
assert_eq!(settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string())); assert_eq!(settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()));
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
settings.create_profile(&settings_path, "web".to_string()).unwrap(); settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
settings.ensure_default_profile(&settings_path).unwrap(); settings.ensure_default_profile(&settings_path).unwrap();
@@ -925,7 +788,6 @@ mod tests {
.any(|p| p.chart_type == ChartType::Line && p.promql.contains(SEEDED_METRIC_NAME))); .any(|p| p.chart_type == ChartType::Line && p.promql.contains(SEEDED_METRIC_NAME)));
assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME)); assert!(dashboard.panels.iter().any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME));
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
let mut reloaded = Settings::load(&settings_path); let mut reloaded = Settings::load(&settings_path);
reloaded.ensure_default_profile(&settings_path).unwrap(); reloaded.ensure_default_profile(&settings_path).unwrap();
let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap(); let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap();
@@ -936,8 +798,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn seeded_metric_renders_real_error_vs_other_data() { async fn seeded_metric_renders_real_error_vs_other_data() {
use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine}; use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use tokio::sync::mpsc;
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
@@ -945,13 +806,13 @@ mod tests {
settings.ensure_default_profile(&settings_path).unwrap(); settings.ensure_default_profile(&settings_path).unwrap();
let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap().clone(); let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap().clone();
let (engine_tx, engine_rx) = mpsc::channel(128);
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
tokio::spawn(engine::run(engine_rx, settings_handle)); let engine_tx = engine::spawn(settings_handle, 128);
load_into_engine(&engine_tx, &default_profile).await; default_profile.load_into_engine(&engine_tx).await;
for (sequence, text) in [(0u64, "all good"), (1, "an ERROR happened"), (2, "fine again")] { for (sequence, text) in [(0u64, "all good"), (1, "an ERROR happened"), (2, "fine again")] {
engine_tx engine_tx
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence, sequence,
timestamp: Timestamp::from_millis(sequence as i64), timestamp: Timestamp::from_millis(sequence as i64),
@@ -960,6 +821,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
} }
engine_tx.barrier().await;
let response = ask( let response = ask(
&engine_tx, &engine_tx,
@@ -1008,7 +870,6 @@ mod tests {
assert_eq!(copy.params, settings.profile("web").unwrap().params); assert_eq!(copy.params, settings.profile("web").unwrap().params);
assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards); assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards);
// With no active profile, new profiles start empty as before.
settings.set_active_profile(&settings_path, None).unwrap(); settings.set_active_profile(&settings_path, None).unwrap();
settings.create_profile(&settings_path, "blank".to_string()).unwrap(); settings.create_profile(&settings_path, "blank".to_string()).unwrap();
let blank = settings.profile("blank").unwrap(); let blank = settings.profile("blank").unwrap();
@@ -1028,17 +889,14 @@ mod tests {
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
assert_eq!(settings.active_profile, Some("web".to_string())); assert_eq!(settings.active_profile, Some("web".to_string()));
// Switching to another known profile works and persists.
settings.set_active_profile(&settings_path, Some("worker".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("worker".to_string())).unwrap();
let reloaded = Settings::load(&settings_path); let reloaded = Settings::load(&settings_path);
assert_eq!(reloaded.active_profile, Some("worker".to_string())); assert_eq!(reloaded.active_profile, Some("worker".to_string()));
// Setting an unknown profile active is rejected, leaving the previous value.
let err = settings.set_active_profile(&settings_path, Some("ghost".to_string())); let err = settings.set_active_profile(&settings_path, Some("ghost".to_string()));
assert!(matches!(err, Err(ProfileError::NotFound))); assert!(matches!(err, Err(ProfileError::NotFound)));
assert_eq!(settings.active_profile, Some("worker".to_string())); assert_eq!(settings.active_profile, Some("worker".to_string()));
// Clearing the active profile works.
settings.set_active_profile(&settings_path, None).unwrap(); settings.set_active_profile(&settings_path, None).unwrap();
assert_eq!(settings.active_profile, None); assert_eq!(settings.active_profile, None);
} }
@@ -1063,14 +921,12 @@ mod tests {
assert_eq!(profile.metrics.len(), 1); assert_eq!(profile.metrics.len(), 1);
assert_eq!(profile.metrics[0].name, "requests"); assert_eq!(profile.metrics[0].name, "requests");
// Re-declaring the same name updates in place rather than duplicating.
let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level"); let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level");
settings.record_param_declaration(&settings_path, "web", &param_req_v2).unwrap(); settings.record_param_declaration(&settings_path, "web", &param_req_v2).unwrap();
let profile = settings.profile("web").unwrap(); let profile = settings.profile("web").unwrap();
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P<level>\w+)"); assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P<level>\w+)");
// Reload from disk: recorded declarations survive a restart.
let reloaded = Settings::load(&settings_path); let reloaded = Settings::load(&settings_path);
let profile = reloaded.profile("web").unwrap(); let profile = reloaded.profile("web").unwrap();
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
@@ -1091,22 +947,18 @@ mod tests {
settings.remove_metric_declaration(&settings_path, "web", "requests").unwrap(); settings.remove_metric_declaration(&settings_path, "web", "requests").unwrap();
assert!(settings.profile("web").unwrap().metrics.is_empty()); assert!(settings.profile("web").unwrap().metrics.is_empty());
// Removing an unknown metric name is a harmless no-op, not an error.
settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap(); settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap();
// Reload from disk: the removal itself survives a restart (ticket 051's acceptance criterion).
let reloaded = Settings::load(&settings_path); let reloaded = Settings::load(&settings_path);
assert!(reloaded.profile("web").unwrap().metrics.is_empty()); assert!(reloaded.profile("web").unwrap().metrics.is_empty());
} }
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading settings from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
#[tokio::test] #[tokio::test]
async fn profile_round_trip_reproduces_declarations_in_fresh_engine() { async fn profile_round_trip_reproduces_declarations_in_fresh_engine() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json"); let settings_path = dir.path().join("settings.json");
let mut settings = Settings::default(); let mut settings = Settings::default();
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
settings.create_profile(&settings_path, "web".to_string()).unwrap(); settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings settings
.record_param_declaration( .record_param_declaration(
@@ -1115,8 +967,6 @@ mod tests {
&param_request("level", r"level=(?P<level>\w+)", "level"), &param_request("level", r"level=(?P<level>\w+)", "level"),
) )
.unwrap(); .unwrap();
// A metric now references params by name (ticket 037), so "duration" must be declared as a
// standalone param before the metric below can reference it as a label param.
settings settings
.record_param_declaration( .record_param_declaration(
&settings_path, &settings_path,
@@ -1134,20 +984,17 @@ mod tests {
.unwrap(); .unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
// Simulate a restart: drop everything in memory, reload from disk.
drop(settings); drop(settings);
let settings = Settings::load(&settings_path); let settings = Settings::load(&settings_path);
let active_name = settings.active_profile.clone().unwrap(); let active_name = settings.active_profile.clone().unwrap();
let profile = settings.profile(&active_name).unwrap().clone(); let profile = settings.profile(&active_name).unwrap().clone();
// Spawn a fresh engine and replay the profile's declarations into it.
let (engine_tx, engine_rx) = mpsc::channel(16);
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
tokio::spawn(crate::engine::run(engine_rx, settings_handle)); let engine_tx = crate::engine::spawn(settings_handle, 16);
load_into_engine(&engine_tx, &profile).await; profile.load_into_engine(&engine_tx).await;
// Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
engine_tx engine_tx
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence: 0, sequence: 0,
timestamp: Timestamp::from_millis(0), timestamp: Timestamp::from_millis(0),
@@ -1155,6 +1002,7 @@ mod tests {
})) }))
.await .await
.unwrap(); .unwrap();
engine_tx.barrier().await;
let params = match ask(&engine_tx, EngineQuery::ParamDeclarations).await { let params = match ask(&engine_tx, EngineQuery::ParamDeclarations).await {
EngineResponse::ParamDeclarations(params) => params, EngineResponse::ParamDeclarations(params) => params,
@@ -1196,7 +1044,6 @@ mod tests {
assert_eq!(logs[0].text, "level=info request duration=42"); assert_eq!(logs[0].text, "level=info request duration=42");
} }
/// Acceptance criterion: a dashboard (with a panel) survives reloading settings from disk.
#[test] #[test]
fn dashboard_create_save_delete_round_trip_and_persists_across_reload() { fn dashboard_create_save_delete_round_trip_and_persists_across_reload() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1222,7 +1069,6 @@ mod tests {
assert_eq!(saved.panels.len(), 1); assert_eq!(saved.panels.len(), 1);
assert_eq!(saved.id, dashboard.id); assert_eq!(saved.id, dashboard.id);
// Simulate a restart: drop and reload from disk.
drop(settings); drop(settings);
let mut reloaded = Settings::load(&settings_path); let mut reloaded = Settings::load(&settings_path);
let dashboards = reloaded.list_dashboards("web").unwrap(); let dashboards = reloaded.list_dashboards("web").unwrap();
@@ -1231,23 +1077,17 @@ mod tests {
assert_eq!(dashboards[0].panels.len(), 1); assert_eq!(dashboards[0].panels.len(), 1);
assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#); assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#);
// Deleting removes it, and that too persists.
reloaded.delete_dashboard(&settings_path, "web", &dashboard.id).unwrap(); reloaded.delete_dashboard(&settings_path, "web", &dashboard.id).unwrap();
assert!(reloaded.list_dashboards("web").unwrap().is_empty()); assert!(reloaded.list_dashboards("web").unwrap().is_empty());
let reloaded_again = Settings::load(&settings_path); let reloaded_again = Settings::load(&settings_path);
assert!(reloaded_again.list_dashboards("web").unwrap().is_empty()); assert!(reloaded_again.list_dashboards("web").unwrap().is_empty());
// Unknown profile/dashboard both fail with NotFound.
assert!(matches!( assert!(matches!(
reloaded_again.list_dashboards("ghost"), reloaded_again.list_dashboards("ghost"),
Err(ProfileError::NotFound) Err(ProfileError::NotFound)
)); ));
} }
/// Writes a pre-037 `settings.json` (with an inline `profiles` array whose metrics carry legacy
/// `"params"` matchers rather than `label_params`/`value_param`) directly to disk, bypassing
/// `Settings`/`Profile` entirely so this doesn't accidentally exercise the current
/// (post-migration) shape. `profiles_json` is the raw JSON array of profiles.
fn write_legacy_settings_json(settings_path: &Path, profiles_json: &str) { fn write_legacy_settings_json(settings_path: &Path, profiles_json: &str) {
fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
let contents = format!( let contents = format!(
@@ -1256,9 +1096,6 @@ mod tests {
fs::write(settings_path, contents).unwrap(); fs::write(settings_path, contents).unwrap();
} }
/// Acceptance criterion: a profile saved before ticket 037 (inline `param_name`/`capture_group`/
/// `kind`/`role` matchers embedded in the metric) loads with those matchers turned into
/// standalone param declarations referenced by name, and the metric still ingests identically.
#[test] #[test]
fn migrates_legacy_inline_metric_params_into_standalone_declarations() { fn migrates_legacy_inline_metric_params_into_standalone_declarations() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1287,7 +1124,6 @@ mod tests {
let settings = Settings::load(&settings_path); let settings = Settings::load(&settings_path);
let profile = settings.profile("legacy").unwrap(); let profile = settings.profile("legacy").unwrap();
// No pre-existing bare "path"/"duration" param, so implicit ones were created, named after the metric.
assert_eq!(profile.params.len(), 2); assert_eq!(profile.params.len(), 2);
let path_param = profile.params.iter().find(|p| p.name == "requests.path").unwrap(); let path_param = profile.params.iter().find(|p| p.name == "requests.path").unwrap();
assert_eq!(path_param.message_match_regex, profile.metrics[0].message_match_regex); assert_eq!(path_param.message_match_regex, profile.metrics[0].message_match_regex);
@@ -1300,18 +1136,16 @@ mod tests {
assert_eq!(profile.metrics[0].label_params, vec!["requests.path".to_string()]); assert_eq!(profile.metrics[0].label_params, vec!["requests.path".to_string()]);
assert_eq!(profile.metrics[0].value_param, Some("requests.duration".to_string())); assert_eq!(profile.metrics[0].value_param, Some("requests.duration".to_string()));
// The migration is persisted, so the on-disk file no longer carries the legacy "params" shape.
let reloaded_raw = fs::read_to_string(&settings_path).unwrap(); let reloaded_raw = fs::read_to_string(&settings_path).unwrap();
assert!(!reloaded_raw.contains("param_name")); assert!(!reloaded_raw.contains("param_name"));
// Replaying into a fresh engine behaves identically to before migration: the metric still ingests and buckets.
let profile = profile.clone(); let profile = profile.clone();
let (engine_tx, engine_rx) = mpsc::channel(16);
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
tokio::runtime::Runtime::new().unwrap().block_on(async { tokio::runtime::Runtime::new().unwrap().block_on(async {
tokio::spawn(crate::engine::run(engine_rx, settings_handle)); let engine_tx = crate::engine::spawn(settings_handle, 16);
load_into_engine(&engine_tx, &profile).await; profile.load_into_engine(&engine_tx).await;
engine_tx engine_tx
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence: 0, sequence: 0,
timestamp: Timestamp::from_millis(0), timestamp: Timestamp::from_millis(0),
@@ -1330,10 +1164,6 @@ mod tests {
}); });
} }
/// Acceptance criterion (ticket 038): a pre-037 legacy profile whose inline `Value`-role matcher
/// used `kind: "duration"` (the old "elapsed time since previous event" special case) migrates
/// into a standalone param with the new explicit `elapsed_since_last: true` extractor, not just a
/// bare `Duration`-kind param that would now (post-038) mean a literal parsed millisecond count.
#[test] #[test]
fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() { fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1368,11 +1198,6 @@ mod tests {
assert_eq!(profile.metrics[0].value_param, Some("login_gap.gap".to_string())); assert_eq!(profile.metrics[0].value_param, Some("login_gap.gap".to_string()));
} }
/// Acceptance criterion (ticket 038): a profile saved after ticket 037 landed but before ticket
/// 038 — so it already carries the unified `label_params`/`value_param` shape rather than legacy
/// inline matchers, with a bare `Duration`-kind param referenced as a metric's `value_param` — is
/// migrated the same way: the param gains `elapsed_since_last: true` so its behavior is unchanged
/// (elapsed time since the previous same-labeled event) under the new explicit extractor.
#[test] #[test]
fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() { fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1404,13 +1229,9 @@ mod tests {
let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap(); let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap();
assert!(gap_param.elapsed_since_last); assert!(gap_param.elapsed_since_last);
// A Duration-kind param not referenced as any value_param is left untouched (still a literal parse).
assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last); assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last);
} }
/// When a bare-named param already declared on the profile matches a legacy matcher's shape
/// exactly (same regex/capture-group/kind), migration reuses it by name instead of creating a
/// redundant `<metric>.<param>` declaration.
#[test] #[test]
fn migration_reuses_an_existing_matching_bare_named_param() { fn migration_reuses_an_existing_matching_bare_named_param() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1440,14 +1261,11 @@ mod tests {
let settings = Settings::load(&settings_path); let settings = Settings::load(&settings_path);
let profile = settings.profile("legacy").unwrap(); let profile = settings.profile("legacy").unwrap();
// Reused the existing "path" declaration rather than creating "requests.path".
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "path"); assert_eq!(profile.params[0].name, "path");
assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]); assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]);
} }
/// Migration is idempotent: loading an already-migrated (or never-legacy) profile a second time
/// doesn't change anything or re-trigger a save.
#[test] #[test]
fn migration_is_a_noop_on_an_already_migrated_profile() { fn migration_is_a_noop_on_an_already_migrated_profile() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1471,11 +1289,6 @@ mod tests {
assert_eq!(before, after); assert_eq!(before, after);
} }
/// Acceptance criterion: a metric declared against a param that only exists as auto-derived
/// engine state (not yet a persisted profile param) still ingests correctly after a simulated
/// restart, because `record_metric_declaration`'s `promoted_params` persists that param into the
/// profile alongside the metric. Without this, `load_into_engine` would reject the metric on
/// restart since declarations replay before any log line re-derives the auto param.
#[tokio::test] #[tokio::test]
async fn metric_referencing_auto_derived_param_survives_restart_via_promotion() { async fn metric_referencing_auto_derived_param_survives_restart_via_promotion() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1485,12 +1298,10 @@ mod tests {
settings.create_profile(&settings_path, "web".to_string()).unwrap(); settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap(); settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
// Simulate the auto-derive path: ingest a line carrying a logfmt key before any param is
// manually declared, so `dx` only exists as engine-internal auto-derived state.
let (engine_tx, engine_rx) = mpsc::channel(16);
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
tokio::spawn(crate::engine::run(engine_rx, settings_handle)); let engine_tx = crate::engine::spawn(settings_handle, 16);
engine_tx engine_tx
.tx
.send(EngineMessage::Ingest(IngestedLine { .send(EngineMessage::Ingest(IngestedLine {
sequence: 0, sequence: 0,
timestamp: Timestamp::from_millis(0), timestamp: Timestamp::from_millis(0),
@@ -1499,17 +1310,15 @@ mod tests {
.await .await
.unwrap(); .unwrap();
// Declare a metric referencing the auto-derived "dx" param — this succeeds live (the param
// exists in the engine), mirroring the interactive-declare flow that hit this bug.
let metric_req = metric_request("direction_x", r"dx=(?P<dx>\S+) moved", "dx"); let metric_req = metric_request("direction_x", r"dx=(?P<dx>\S+) moved", "dx");
let (respond_to, response) = oneshot::channel(); let (respond_to, response) = oneshot::channel();
engine_tx engine_tx
.tx
.send(EngineMessage::DeclareMetric { request: metric_req.clone(), respond_to }) .send(EngineMessage::DeclareMetric { request: metric_req.clone(), respond_to })
.await .await
.unwrap(); .unwrap();
response.await.unwrap().unwrap(); response.await.unwrap().unwrap();
// Promote "dx" from its live (auto-derived) engine state, as `ApiState::persist_metric_declaration` does.
let EngineResponse::ParamDeclarations(live_params) = let EngineResponse::ParamDeclarations(live_params) =
ask(&engine_tx, EngineQuery::ParamDeclarations).await ask(&engine_tx, EngineQuery::ParamDeclarations).await
else { else {
@@ -1527,17 +1336,15 @@ mod tests {
}; };
settings.record_metric_declaration(&settings_path, "web", &metric_req, &[promoted]).unwrap(); settings.record_metric_declaration(&settings_path, "web", &metric_req, &[promoted]).unwrap();
// Simulate a restart: drop everything, reload from disk into a *fresh* engine with no prior ingestion.
drop(settings); drop(settings);
let reloaded_settings = Settings::load(&settings_path); let reloaded_settings = Settings::load(&settings_path);
let profile = reloaded_settings.profile("web").unwrap().clone(); let profile = reloaded_settings.profile("web").unwrap().clone();
assert_eq!(profile.params.len(), 1); assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "dx"); assert_eq!(profile.params[0].name, "dx");
let (fresh_engine_tx, fresh_engine_rx) = mpsc::channel(16);
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
tokio::spawn(crate::engine::run(fresh_engine_rx, fresh_settings_handle)); let fresh_engine_tx = crate::engine::spawn(fresh_settings_handle, 16);
load_into_engine(&fresh_engine_tx, &profile).await; profile.load_into_engine(&fresh_engine_tx).await;
let EngineResponse::MetricDeclarations(metrics) = let EngineResponse::MetricDeclarations(metrics) =
ask(&fresh_engine_tx, EngineQuery::MetricDeclarations).await ask(&fresh_engine_tx, EngineQuery::MetricDeclarations).await
+54
View File
@@ -0,0 +1,54 @@
use std::time::Duration;
// mpsc channel capacity between ingest/API callers and the engine actor task.
pub const ENGINE_CHANNEL_CAPACITY: usize = 1024;
// Hard ceiling on how many params `auto_declare_params` will ever create automatically over the
// life of the engine, so one unusual input can't inflate the declared-param set forever.
pub const MAX_AUTO_DECLARED_PARAMS: usize = 64;
// Per-line ceiling on how many *new* params a single ingested line may contribute to the
// auto-derived set, so one unusually wide line can't burn the whole per-run budget at once.
pub const MAX_NEW_AUTO_PARAMS_PER_LINE: usize = 8;
// How long a sort helper index may sit unused before it's purged and left to rebuild on demand.
pub const SORT_HELPER_INDEX_PURGE_AFTER: Duration = Duration::from_secs(300);
// Window and capacity for the rolling ingestion-to-engine-processing lag high watermark.
pub const INGEST_LAG_WATERMARK_WINDOW_MS: i64 = 5000;
pub const INGEST_LAG_WATERMARK_MAX_ENTRIES: usize = 5;
// Bucket width used to merge multiple sub-series into one combined time series.
pub const MERGE_WINDOW_MS: i64 = 1_000;
// Default timeline query bucket width when the caller doesn't specify one.
pub const DEFAULT_TIMELINE_BUCKET_MS: i64 = 1_000;
// Reserved param names usable without declaring anything (ottyl-synthesized columns).
pub const OTTYL_RESERVED_PARAM_NAMES: [&str; 3] = ["ottyl_seq", "ottyl_ts", "ottyl_fmt"];
// Idle poll cadence while tailing stdin/a file for new log lines.
pub const STDIN_POLL_INTERVAL: Duration = Duration::from_millis(200);
// Poll cadence for a configured Prometheus/OpenMetrics `--metrics-url`.
pub const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(15);
// Column-splitting regex fragments for the fixed-width logfmt column parser.
pub const COLUMN_SEPARATOR_PATTERN: &str = r"(?:\t|[ \t]{2,})";
pub const COLUMN_VALUE_PATTERN: &str = r"\S+(?: \S+)*";
// Name of the always-present profile every Settings file is seeded with.
pub const DEFAULT_PROFILE_NAME: &str = "Default";
// Name/label of the metric the Default profile is seeded with.
pub const SEEDED_METRIC_NAME: &str = "log_category";
pub const SEEDED_METRIC_LABEL: &str = "category";
// Host the HTTP server binds to; port is user-configurable, host is not.
pub const DEFAULT_BIND_HOST: &str = "0.0.0.0";
// App identity used to resolve the OS-conventional settings directory, and the settings file name.
pub const APP_QUALIFIER: &str = "";
pub const APP_ORGANIZATION: &str = "";
pub const APP_NAME: &str = "ottyl";
pub const SETTINGS_FILE_NAME: &str = "settings.json";
-154
View File
@@ -1,154 +0,0 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it
/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to
/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name
/// (`MetricDeclaration::label_params`/`value_param`) instead of redeclaring its own extractor per
/// param, so a param declared once (e.g. via the Params panel) can be reused as a label by one
/// metric and as the value by another without re-entering its regex/capture-group/kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParamDeclaration {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
/// group. When present and non-empty, this takes precedence over `capture_group`.
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
/// as plain regex-capture-group params.
#[serde(default)]
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
/// A third extraction source, alongside `capture_group`/`json_field_path`: when set, this
/// param's value is never parsed from text at all — it's computed as elapsed time since the
/// previous event that produced the same label set (ticket 038). Takes precedence over
/// `json_field_path`/`capture_group` when set, and `message_match_regex`/`capture_group`/
/// `json_field_path` are simply unused in that case (`declare_param` doesn't validate them for
/// this shape). Only meaningful paired with `kind == Duration`, since elapsed time is the only
/// primitive this computation can produce — `declare_param` enforces that pairing.
/// `#[serde(default)]` so params declared before this field existed keep parsing as plain
/// regex/JSON-field extraction.
#[serde(default)]
pub elapsed_since_last: bool,
/// Set when this param was discovered by `crate::engine::auto_declare_params` (a nom-based
/// `key=value` scan run on every ingested line) rather than declared through `POST /api/params`.
/// `declare_param` refuses to overwrite an auto-derived param by name, and it's never persisted
/// to a profile — see `crate::engine::declare_param` and `crate::api::reload_engine_declarations`.
/// `#[serde(default)]` so profiles/requests saved before this field existed still load as
/// ordinary (non-auto) params.
#[serde(default)]
pub auto_derived: bool,
}
/// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for
/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON
/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar
/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into
/// `parse_metric_primitive`.
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
let root: serde_json::Value = serde_json::from_str(text).ok()?;
let mut current = root.as_object()?;
let mut segments = field_path.split('.').peekable();
loop {
let segment = segments.next()?;
let next_value = current.get(segment)?;
if segments.peek().is_none() {
return match next_value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
};
}
current = next_value.as_object()?;
}
}
/// Flattens a JSON object into dot-separated field paths mapped to each leaf scalar's raw text form
/// (string/number/bool), for [`crate::engine::auto_declare_params`] when a line is JSON
/// (`crate::logfmt::detect_format`) rather than logfmt — the JSON equivalent of
/// `crate::logfmt::extract_pairs`'s `key=value` scan. Nested objects recurse, dot-joining the path
/// (`"foo": {"bar": 1}` yields `"foo.bar"`); arrays and null are skipped since there's no single leaf
/// scalar to declare a param against, matching `extract_json_field`'s own restriction to scalars.
/// Returns an empty vec for anything that isn't a JSON object, same as `extract_pairs` returns empty
/// for a line with no `key=value` pairs.
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut results = Vec::new();
flatten_json_object_into(&root, "", &mut results);
results
}
fn flatten_json_object_into(
object: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
results: &mut Vec<(String, String)>,
) {
for (key, value) in object {
let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
match value {
serde_json::Value::String(s) => results.push((path, s.clone())),
serde_json::Value::Number(n) => results.push((path, n.to_string())),
serde_json::Value::Bool(b) => results.push((path, b.to_string())),
serde_json::Value::Object(nested) => flatten_json_object_into(nested, &path, results),
serde_json::Value::Null | serde_json::Value::Array(_) => {}
}
}
}
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
/// A literal i64 millisecond count. Note this is unrelated to
/// `ParamDeclaration::elapsed_since_last`, which is a separate, independent extraction source —
/// a param using that source always parses/produces `Duration`, but not every `Duration`-kind
/// param is computed that way (one can also just parse a literal duration out of captured text,
/// like any other kind).
Duration,
/// A standalone param declared with this kind doesn't require (or use) a capture group: its value is
/// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own
/// flags — see `crate::engine::derive_log_params`. Unlike other kinds it's present as `true`/`false`
/// on every line rather than omitted when unmatched.
Boolean,
}
/// 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::ParamDeclaration`.
ParamDeclaration(ParamDeclaration),
/// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone
/// `ParamDeclaration`s by name (resolved against the engine's param store in
/// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor.
MetricDeclaration {
message_match_regex: String,
label_params: Vec<String>,
/// At most one value param per metric; its primitive becomes the event's bucket value instead of a label.
value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
matched_labels: Option<BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
unmatched_labels: Option<BTreeMap<String, String>>,
},
/// Paired with `IndexKey::Bucket`.
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
/// Paired with `IndexKey::LastMetricEvent`.
LastEventTimestamp {
timestamp: Timestamp,
},
}
-4465
View File
File diff suppressed because it is too large Load Diff
+28 -70
View File
@@ -1,16 +1,5 @@
//! Alert rules: definitions, trigger evaluation, and ntfy delivery.
//!
//! Alert queries are restricted to regex/ottyl_seq/ottyl_ts/ottyl_fmt/json[field] — a metric[param] comparison would require a full bucket rescan per line, breaking the proportional-cost guarantee. json[field] is fine here since, like regex/ottyl_seq/ottyl_ts/ottyl_fmt, it only inspects the line's own text/fields, no index lookup.
//!
//! `Threshold`/`Ratio` windows are maintained via `crate::rate::RateWindow` — the same windowed-rate
//! implementation PromQL's `rate()` uses (`crate::engine::evaluate_promql`) — rather than a
//! bespoke `VecDeque`+prune loop, so the two agree on what "windowed rate" means (ticket 040). A
//! window is also backfilled once, on first evaluation, from already-ingested logs within its own
//! trailing window (see `backfill_window`): a rule created mid-session (or a process that replays
//! existing log content, e.g. `--file` tailing) no longer starts with a silently empty window.
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::io; use std::io;
@@ -20,14 +9,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::data_value::DataValue; use crate::engine::{self, eval_json_comparison};
use crate::engine::{self, apply_compare_op, eval_json_comparison}; use crate::engine::filter_datetime;
use crate::filter_datetime; use crate::engine::filter_parser::{self, CompareOp, FilterExpr, FilterValue};
use crate::filter_parser::{self, CompareOp, FilterExpr, FilterValue}; use crate::engine::rate::RateWindow;
use crate::index_key::IndexKey; use crate::config::Settings;
use crate::rate::RateWindow; use crate::engine::timestamp::Timestamp;
use crate::settings::Settings;
use crate::timestamp::Timestamp;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AlertRule { pub struct AlertRule {
@@ -41,19 +28,16 @@ pub struct AlertRule {
#[serde(tag = "kind", rename_all = "lowercase")] #[serde(tag = "kind", rename_all = "lowercase")]
pub enum AlertCondition { pub enum AlertCondition {
Immediate { query: String }, Immediate { query: String },
/// Fires when `query`'s match count within a rolling `window_ms` crosses `threshold` (via `op`).
Threshold { Threshold {
query: String, query: String,
op: CompareOp, op: CompareOp,
threshold: f64, threshold: f64,
window_ms: i64, window_ms: i64,
}, },
/// Seen values live only in memory — a restart forgets them, so previously-seen values can fire again; a deliberate simplicity tradeoff, not an oversight.
NewValue { NewValue {
field_regex: String, field_regex: String,
capture_group: String, capture_group: String,
}, },
/// A zero `count_b` skips evaluation for that line rather than treating the ratio as infinite.
Ratio { Ratio {
query_a: String, query_a: String,
query_b: String, query_b: String,
@@ -86,7 +70,6 @@ impl From<io::Error> for AlertError {
} }
} }
/// Hashes the name and current time rather than pulling in a `uuid` dependency.
fn generate_alert_rule_id(name: &str) -> String { fn generate_alert_rule_id(name: &str) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
name.hash(&mut hasher); name.hash(&mut hasher);
@@ -145,13 +128,6 @@ impl Settings {
} }
} }
/// Per-rule in-memory evaluation state; lives only for the process's lifetime — not persisted
/// across a restart with no replayed log history. Each `RateWindow` is backfilled once (see
/// `backfill_window`) from whatever matching history is already in the index at first evaluation,
/// so — unlike before ticket 040 — a rule doesn't start blind to matches ingested before it was
/// declared, or before this evaluation loop last started, as long as that history is still in the
/// in-memory log index (e.g. `--file` tailing replays existing file content from the start on
/// every process start).
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct AlertRuntimeState { pub struct AlertRuntimeState {
threshold_window: RateWindow, threshold_window: RateWindow,
@@ -163,12 +139,8 @@ pub struct AlertRuntimeState {
seen_values: HashSet<String>, seen_values: HashSet<String>,
} }
/// Builds a `RateWindow` seeded from already-ingested logs matching `query`, bounded to
/// `window_ms` before `now` (not the full log history) so the cost stays proportional to the
/// window rather than everything ever ingested. `before_sequence` excludes the line currently
/// being evaluated, which the caller pushes separately if it matches.
fn backfill_window( fn backfill_window(
index: &BTreeMap<IndexKey, DataValue>, snapshot: &engine::EngineSnapshot,
query: &str, query: &str,
before_sequence: u64, before_sequence: u64,
now: Timestamp, now: Timestamp,
@@ -176,7 +148,7 @@ fn backfill_window(
) -> RateWindow { ) -> RateWindow {
let since_ms = now.as_millis() - window_ms; let since_ms = now.as_millis() - window_ms;
let matching_timestamps = let matching_timestamps =
engine::logs_since(index, before_sequence, since_ms, now.as_millis()).filter_map( snapshot.logs_since(before_sequence, since_ms, now.as_millis()).filter_map(
|(sequence, timestamp, text)| { |(sequence, timestamp, text)| {
(eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp) (eval_alert_query(query, sequence, timestamp, text) == Ok(true)).then_some(timestamp)
}, },
@@ -184,7 +156,6 @@ fn backfill_window(
RateWindow::backfill(matching_timestamps, now, window_ms) RateWindow::backfill(matching_timestamps, now, window_ms)
} }
/// Evaluates a FilterBar query against a single line's own fields only (metric[param] comparisons aren't supported — see module doc; json[field] is supported since it's also line-local).
fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> { fn eval_alert_query(query: &str, sequence: u64, timestamp: Timestamp, text: &str) -> Result<bool, String> {
let expr = filter_parser::parse(query).map_err(|err| err.to_string())?; let expr = filter_parser::parse(query).map_err(|err| err.to_string())?;
eval_alert_expr(&expr, sequence, timestamp, text) eval_alert_expr(&expr, sequence, timestamp, text)
@@ -203,7 +174,7 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
.parse::<f64>() .parse::<f64>()
.map_err(|_| format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}"))?, .map_err(|_| format!("'ottyl_seq' comparisons require a numeric value, got {raw:?}"))?,
}; };
Ok(apply_compare_op((sequence as f64).total_cmp(&target), *op)) Ok(op.matches((sequence as f64).total_cmp(&target)))
} }
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_ts" => { FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_ts" => {
let target_ms = match value { let target_ms = match value {
@@ -212,14 +183,14 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
.map_err(|err| err.to_string())? .map_err(|err| err.to_string())?
.as_millis(), .as_millis(),
}; };
Ok(apply_compare_op(timestamp.as_millis().cmp(&target_ms), *op)) Ok(op.matches(timestamp.as_millis().cmp(&target_ms)))
} }
FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_fmt" => { FilterExpr::Comparison { metric, param: None, op, value } if metric == "ottyl_fmt" => {
let target = match value { let target = match value {
FilterValue::Number(n) => n.to_string(), FilterValue::Number(n) => n.to_string(),
FilterValue::Raw(raw) => raw.clone(), FilterValue::Raw(raw) => raw.clone(),
}; };
Ok(apply_compare_op(crate::logfmt::detect_format(text).cmp(target.as_str()), *op)) Ok(op.matches(crate::engine::logfmt::detect_format(text).cmp(target.as_str())))
} }
FilterExpr::Comparison { metric, .. } => Err(format!( FilterExpr::Comparison { metric, .. } => Err(format!(
"alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \ "alert queries only support regex()/ottyl_seq/ottyl_ts/ottyl_fmt (not metric comparisons like \
@@ -237,11 +208,10 @@ fn eval_alert_expr(expr: &FilterExpr, sequence: u64, timestamp: Timestamp, text:
} }
} }
/// A malformed query (bad regex, unsupported metric comparison) is logged and simply never matches, rather than panicking.
pub fn evaluate_alerts( pub fn evaluate_alerts(
rules: &[AlertRule], rules: &[AlertRule],
states: &mut HashMap<String, AlertRuntimeState>, states: &mut HashMap<String, AlertRuntimeState>,
index: &BTreeMap<IndexKey, DataValue>, snapshot: &engine::EngineSnapshot,
sequence: u64, sequence: u64,
timestamp: Timestamp, timestamp: Timestamp,
text: &str, text: &str,
@@ -253,7 +223,7 @@ pub fn evaluate_alerts(
} }
let state = states.entry(rule.id.clone()).or_default(); let state = states.entry(rule.id.clone()).or_default();
if let Some(message) = if let Some(message) =
evaluate_condition(&rule.condition, state, index, sequence, timestamp, text, &rule.name) evaluate_condition(&rule.condition, state, snapshot, sequence, timestamp, text, &rule.name)
{ {
fired.push((rule.clone(), message)); fired.push((rule.clone(), message));
} }
@@ -264,7 +234,7 @@ pub fn evaluate_alerts(
fn evaluate_condition( fn evaluate_condition(
condition: &AlertCondition, condition: &AlertCondition,
state: &mut AlertRuntimeState, state: &mut AlertRuntimeState,
index: &BTreeMap<IndexKey, DataValue>, snapshot: &engine::EngineSnapshot,
sequence: u64, sequence: u64,
timestamp: Timestamp, timestamp: Timestamp,
text: &str, text: &str,
@@ -281,14 +251,14 @@ fn evaluate_condition(
}, },
AlertCondition::Threshold { query, op, threshold, window_ms } => { AlertCondition::Threshold { query, op, threshold, window_ms } => {
if !state.threshold_backfilled { if !state.threshold_backfilled {
state.threshold_window = backfill_window(index, query, sequence, timestamp, *window_ms); state.threshold_window = backfill_window(snapshot, query, sequence, timestamp, *window_ms);
state.threshold_backfilled = true; state.threshold_backfilled = true;
} }
match eval_alert_query(query, sequence, timestamp, text) { match eval_alert_query(query, sequence, timestamp, text) {
Ok(true) => { Ok(true) => {
state.threshold_window.push(timestamp, *window_ms); state.threshold_window.push(timestamp, *window_ms);
let count = state.threshold_window.count() as f64; let count = state.threshold_window.count() as f64;
apply_compare_op(count.total_cmp(threshold), *op).then(|| { op.matches(count.total_cmp(threshold)).then(|| {
format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)") format!("{rule_name}: match count {count} {op:?} threshold {threshold} (window {window_ms}ms)")
}) })
} }
@@ -328,11 +298,11 @@ fn evaluate_condition(
return None; return None;
} }
if !state.ratio_a_backfilled { if !state.ratio_a_backfilled {
state.ratio_a_window = backfill_window(index, query_a, sequence, timestamp, *window_ms); state.ratio_a_window = backfill_window(snapshot, query_a, sequence, timestamp, *window_ms);
state.ratio_a_backfilled = true; state.ratio_a_backfilled = true;
} }
if !state.ratio_b_backfilled { if !state.ratio_b_backfilled {
state.ratio_b_window = backfill_window(index, query_b, sequence, timestamp, *window_ms); state.ratio_b_window = backfill_window(snapshot, query_b, sequence, timestamp, *window_ms);
state.ratio_b_backfilled = true; state.ratio_b_backfilled = true;
} }
if matched_a { if matched_a {
@@ -344,11 +314,10 @@ fn evaluate_condition(
let count_a = state.ratio_a_window.count() as f64; let count_a = state.ratio_a_window.count() as f64;
let count_b = state.ratio_b_window.count() as f64; let count_b = state.ratio_b_window.count() as f64;
if count_b == 0.0 { if count_b == 0.0 {
// Zero denominator: skip rather than treat the ratio as infinite.
return None; return None;
} }
let observed = count_a / count_b; let observed = count_a / count_b;
apply_compare_op(observed.total_cmp(ratio), *op).then(|| { op.matches(observed.total_cmp(ratio)).then(|| {
format!( format!(
"{rule_name}: ratio {observed:.3} ({count_a}/{count_b}) {op:?} {ratio} (window {window_ms}ms)" "{rule_name}: ratio {observed:.3} ({count_a}/{count_b}) {op:?} {ratio} (window {window_ms}ms)"
) )
@@ -357,9 +326,6 @@ fn evaluate_condition(
} }
} }
/// Delivers to the single, process-wide ntfy sink configured in Settings. Errors are only logged
/// (no retry queue); spawned off the ingest hot path so a slow/unreachable sink never blocks ingestion.
/// A silently no-op'd delivery (no topic configured) is expected — a rule can be defined before the sink is.
pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) { pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<String>, server: Option<String>) {
let Some(topic) = topic else { let Some(topic) = topic else {
return; return;
@@ -370,7 +336,6 @@ pub async fn deliver_for_rule(rule_name: String, message: String, topic: Option<
} }
} }
/// Standalone and `AlertRule`-free so a "send test notification" endpoint can call it directly.
pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> { pub async fn deliver_ntfy(server: &str, topic: &str, message: &str) -> Result<(), String> {
let url = format!("{}/{}", server.trim_end_matches('/'), topic); let url = format!("{}/{}", server.trim_end_matches('/'), topic);
let response = reqwest::Client::new() let response = reqwest::Client::new()
@@ -464,7 +429,7 @@ mod tests {
fn immediate_fires_on_matching_line_only() { fn immediate_fires_on_matching_line_only() {
let rules = vec![immediate_rule(r#"regex("error")"#)]; let rules = vec![immediate_rule(r#"regex("error")"#)];
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
let (seq, ts, text) = line("all good", 0, 0); let (seq, ts, text) = line("all good", 0, 0);
assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty()); assert!(evaluate_alerts(&rules, &mut states, &index, seq, ts, text).is_empty());
@@ -480,7 +445,7 @@ mod tests {
let mut rule = immediate_rule(r#"regex("error")"#); let mut rule = immediate_rule(r#"regex("error")"#);
rule.enabled = false; rule.enabled = false;
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
let (seq, ts, text) = line("error!", 0, 0); let (seq, ts, text) = line("error!", 0, 0);
assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty()); assert!(evaluate_alerts(&[rule], &mut states, &index, seq, ts, text).is_empty());
} }
@@ -499,14 +464,13 @@ mod tests {
}, },
}; };
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
for (seq, ms) in [(0u64, 0i64), (1, 1000)] { for (seq, ms) in [(0u64, 0i64), (1, 1000)] {
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, seq, Timestamp::from_millis(ms), "error"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, seq, Timestamp::from_millis(ms), "error");
assert!(fired.is_empty(), "should not fire before threshold is crossed"); assert!(fired.is_empty(), "should not fire before threshold is crossed");
} }
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2000), "error"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2000), "error");
assert_eq!(fired.len(), 1); assert_eq!(fired.len(), 1);
// Stays quiet for a non-matching line even though the window is full.
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(2100), "fine"); let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(2100), "fine");
assert!(fired.is_empty()); assert!(fired.is_empty());
} }
@@ -525,9 +489,8 @@ mod tests {
}, },
}; };
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty()); assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
// Second match arrives well after the first has aged out of the window.
assert!(evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(5_000), "error").is_empty()); assert!(evaluate_alerts(&[rule], &mut states, &index, 1, Timestamp::from_millis(5_000), "error").is_empty());
} }
@@ -543,7 +506,7 @@ mod tests {
}, },
}; };
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "agent=curl"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "agent=curl");
assert_eq!(fired.len(), 1); assert_eq!(fired.len(), 1);
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "agent=curl"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "agent=curl");
@@ -567,16 +530,12 @@ mod tests {
}, },
}; };
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
// Only an "error" so far; no "request" yet, so denominator is 0 -> skip.
assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty()); assert!(evaluate_alerts(&[rule.clone()], &mut states, &index, 0, Timestamp::from_millis(0), "error").is_empty());
// One request: ratio 1/1 = 1.0 >= 0.5 -> fires.
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "request"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 1, Timestamp::from_millis(1), "request");
assert_eq!(fired.len(), 1); assert_eq!(fired.len(), 1);
// A second request: ratio 1/2 = 0.5 still >= 0.5 -> fires again.
let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2), "request"); let fired = evaluate_alerts(&[rule.clone()], &mut states, &index, 2, Timestamp::from_millis(2), "request");
assert_eq!(fired.len(), 1); assert_eq!(fired.len(), 1);
// A third request with no more errors: ratio drops to 1/3 -> no longer fires.
let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request"); let fired = evaluate_alerts(&[rule], &mut states, &index, 3, Timestamp::from_millis(3), "request");
assert!(fired.is_empty()); assert!(fired.is_empty());
} }
@@ -585,7 +544,7 @@ mod tests {
fn ottyl_fmt_alert_query_matches_detected_format() { fn ottyl_fmt_alert_query_matches_detected_format() {
let rule = immediate_rule("ottyl_fmt==json"); let rule = immediate_rule("ottyl_fmt==json");
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
let fired = evaluate_alerts( let fired = evaluate_alerts(
&[rule.clone()], &[rule.clone()],
@@ -605,8 +564,7 @@ mod tests {
fn metric_comparison_in_alert_query_is_rejected_not_panicking() { fn metric_comparison_in_alert_query_is_rejected_not_panicking() {
let rule = immediate_rule("some_metric[param]==5"); let rule = immediate_rule("some_metric[param]==5");
let mut states = HashMap::new(); let mut states = HashMap::new();
let index: BTreeMap<IndexKey, DataValue> = BTreeMap::new(); let index = engine::EngineSnapshot::default();
// Should log a warning and simply not fire, never panic.
let fired = evaluate_alerts(&[rule], &mut states, &index, 0, Timestamp::from_millis(0), "anything"); let fired = evaluate_alerts(&[rule], &mut states, &index, 0, Timestamp::from_millis(0), "anything");
assert!(fired.is_empty()); assert!(fired.is_empty());
} }
+96
View File
@@ -0,0 +1,96 @@
use crate::engine::metric_primitive::MetricPrimitive;
use crate::engine::timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParamDeclaration {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
#[serde(default)]
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
#[serde(default)]
pub elapsed_since_last: bool,
#[serde(default)]
pub auto_derived: bool,
#[serde(default)]
pub log_stream_gated: bool,
}
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
let root: serde_json::Value = serde_json::from_str(text).ok()?;
let mut current = root.as_object()?;
let mut segments = field_path.split('.').peekable();
loop {
let segment = segments.next()?;
let next_value = current.get(segment)?;
if segments.peek().is_none() {
return match next_value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
};
}
current = next_value.as_object()?;
}
}
pub fn flatten_json_object(text: &str) -> Vec<(String, String)> {
let Ok(serde_json::Value::Object(root)) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut results = Vec::new();
flatten_json_object_into(&root, "", &mut results);
results
}
fn flatten_json_object_into(
object: &serde_json::Map<String, serde_json::Value>,
prefix: &str,
results: &mut Vec<(String, String)>,
) {
for (key, value) in object {
let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
match value {
serde_json::Value::String(s) => results.push((path, s.clone())),
serde_json::Value::Number(n) => results.push((path, n.to_string())),
serde_json::Value::Bool(b) => results.push((path, b.to_string())),
serde_json::Value::Object(nested) => flatten_json_object_into(nested, &path, results),
serde_json::Value::Null | serde_json::Value::Array(_) => {}
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
Duration,
Boolean,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum DataValue {
LogLiteral { text: String },
ParamDeclaration(ParamDeclaration),
MetricDeclaration {
message_match_regex: String,
label_params: Vec<String>,
value_param: Option<String>,
matched_labels: Option<BTreeMap<String, String>>,
unmatched_labels: Option<BTreeMap<String, String>>,
},
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
LastEventTimestamp {
timestamp: Timestamp,
},
}
@@ -1,21 +1,12 @@
//! Datetime parsing for the filter query language's timestamp comparisons: relative
//! humantime, time-of-day (resolved against the UTC calendar day of `now`), or a
//! full date/datetime literal.
// `#![allow(dead_code)]` stays since most of this module's surface is still only
// exercised by its own unit tests below.
#![allow(dead_code)] #![allow(dead_code)]
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
/// Error returned when a datetime filter literal can't be resolved to a [`Timestamp`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeParseError { pub enum DateTimeParseError {
/// The input matched a supported format's shape but its value was invalid, e.g. `25:99`.
Invalid { input: String, reason: String }, Invalid { input: String, reason: String },
/// The input didn't match any supported format at all.
Unparseable(String), Unparseable(String),
} }
@@ -37,8 +28,6 @@ impl std::fmt::Display for DateTimeParseError {
impl std::error::Error for DateTimeParseError {} impl std::error::Error for DateTimeParseError {}
/// Parses a filter datetime literal into an absolute [`Timestamp`]; `now` is a parameter
/// (rather than calling `Timestamp::now()` internally) so callers can pass a fixed value in tests.
pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> { pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTimeParseError> {
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -60,8 +49,6 @@ pub fn parse_datetime(input: &str, now: Timestamp) -> Result<Timestamp, DateTime
Err(DateTimeParseError::Unparseable(input.to_string())) Err(DateTimeParseError::Unparseable(input.to_string()))
} }
/// Tries to interpret `input` as a signed relative humantime duration (`5h`, `-3m`);
/// returns `None` if it doesn't look like one at all, so the caller can try other formats.
fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> { fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTimeParseError>> {
let (sign, rest) = match input.strip_prefix('-') { let (sign, rest) = match input.strip_prefix('-') {
Some(rest) => (-1i128, rest), Some(rest) => (-1i128, rest),
@@ -71,7 +58,6 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
}, },
}; };
// Require a digit followed by an alphabetic unit so date-like inputs (e.g. "2024-01-15") fall through to the date parser instead of erroring here as an invalid duration.
let looks_like_duration = rest let looks_like_duration = rest
.chars() .chars()
.next() .next()
@@ -97,9 +83,7 @@ fn try_relative(input: &str, now: Timestamp) -> Option<Result<Timestamp, DateTim
} }
} }
/// Tries to interpret `input` as a full date or RFC3339-ish datetime literal.
fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> { fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError>> {
// Only attempt this path for inputs shaped like a date, e.g. "2024-01-15".
let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-'; let looks_like_date = input.len() >= 8 && input.as_bytes()[4] == b'-';
if !looks_like_date { if !looks_like_date {
return None; return None;
@@ -123,7 +107,6 @@ fn try_full_datetime(input: &str) -> Option<Result<Timestamp, DateTimeParseError
} }
} }
/// Tries to interpret `input` as a bare time-of-day, resolved against the UTC calendar day of `now`.
fn try_time_of_day( fn try_time_of_day(
input: &str, input: &str,
now: Timestamp, now: Timestamp,
@@ -147,7 +130,6 @@ fn try_time_of_day(
let now_millis = now.as_millis(); let now_millis = now.as_millis();
let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| { let now_dt = Utc.timestamp_millis_opt(now_millis).single().unwrap_or_else(|| {
// Fall back to the epoch date; shouldn't happen since `Timestamp` is always a valid instant.
Utc.timestamp_millis_opt(0).single().expect("epoch is valid") Utc.timestamp_millis_opt(0).single().expect("epoch is valid")
}); });
let today = now_dt.date_naive(); let today = now_dt.date_naive();
+30
View File
@@ -0,0 +1,30 @@
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
file = { SOI ~ or_expr ~ EOI }
or_expr = { and_expr ~ (or_kw ~ and_expr)* }
and_expr = { term ~ (and_kw ~ term)* }
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
regex_expr = { "regex" ~ "(" ~ string_literal ~ ")" }
string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
escaped_char = @{ "\\" ~ ANY }
comparison = _{ json_comparison | metric_comparison }
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
metric = @{ ident }
param = @{ ident }
ident = @{ (ASCII_ALPHANUMERIC | "_")+ }
op = { "<=" | ">=" | "==" | "!=" | "<" | ">" }
value = @{ (!(WHITESPACE | ")") ~ ANY)+ }
@@ -1,5 +1,3 @@
//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
//! Evaluating the AST against real log data is left to `engine.rs`.
#![allow(dead_code)] #![allow(dead_code)]
use pest::Parser; use pest::Parser;
@@ -8,23 +6,18 @@ use pest_derive::Parser as PestParser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(PestParser)] #[derive(PestParser)]
#[grammar = "filter_parser.pest"] #[grammar = "engine/filter_parser.pest"]
struct FilterGrammar; struct FilterGrammar;
/// The parsed AST for a FilterBar query.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum FilterExpr { pub enum FilterExpr {
/// `regex("...")` — raw-text regex search over log lines.
Regex(String), Regex(String),
/// `metric[param] <op> value` — a single metric comparison.
Comparison { Comparison {
metric: String, metric: String,
param: Option<String>, param: Option<String>,
op: CompareOp, op: CompareOp,
value: FilterValue, value: FilterValue,
}, },
/// `json[field] <op> value` — a comparison against a field of the line's parsed JSON.
/// Lines that aren't valid JSON (or lack the field) simply don't match.
JsonComparison { JsonComparison {
field: String, field: String,
op: CompareOp, op: CompareOp,
@@ -34,8 +27,6 @@ pub enum FilterExpr {
Or(Box<FilterExpr>, Box<FilterExpr>), Or(Box<FilterExpr>, Box<FilterExpr>),
} }
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CompareOp { pub enum CompareOp {
@@ -47,15 +38,12 @@ pub enum CompareOp {
Ne, Ne,
} }
/// The right-hand side of a comparison: values that parse as a number become
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum FilterValue { pub enum FilterValue {
Number(f64), Number(f64),
Raw(String), Raw(String),
} }
/// A located, human-readable parse error.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct FilterParseError { pub struct FilterParseError {
message: String, message: String,
@@ -79,12 +67,10 @@ impl std::error::Error for FilterParseError {}
impl From<pest::error::Error<Rule>> for FilterParseError { impl From<pest::error::Error<Rule>> for FilterParseError {
fn from(err: pest::error::Error<Rule>) -> Self { fn from(err: pest::error::Error<Rule>) -> Self {
// pest's Display already renders a located message, so use it and add a short prefix.
FilterParseError::new(format!("failed to parse filter expression: {err}")) FilterParseError::new(format!("failed to parse filter expression: {err}"))
} }
} }
/// Parse a FilterBar query string into a [`FilterExpr`] AST.
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> { pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
let mut pairs = FilterGrammar::parse(Rule::file, input)?; let mut pairs = FilterGrammar::parse(Rule::file, input)?;
@@ -102,7 +88,6 @@ pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> { fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::or_expr); debug_assert_eq!(pair.as_rule(), Rule::or_expr);
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
let mut and_exprs = pair let mut and_exprs = pair
.into_inner() .into_inner()
.filter(|p| p.as_rule() != Rule::or_kw); .filter(|p| p.as_rule() != Rule::or_kw);
@@ -119,7 +104,6 @@ fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> { fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::and_expr); debug_assert_eq!(pair.as_rule(), Rule::and_expr);
// Same reasoning as `build_or_expr` for `and_kw`.
let mut terms = pair let mut terms = pair
.into_inner() .into_inner()
.filter(|p| p.as_rule() != Rule::and_kw); .filter(|p| p.as_rule() != Rule::and_kw);
@@ -237,7 +221,6 @@ fn parse_value(s: &str) -> FilterValue {
} }
} }
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
fn unescape(s: &str) -> String { fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
let mut chars = s.chars(); let mut chars = s.chars();
+32
View File
@@ -0,0 +1,32 @@
use crate::engine::metric_primitive::MetricPrimitive;
use crate::engine::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey {
Start,
IngestedLog {
sequence: u64,
timestamp: Timestamp,
},
LogParamSplit,
ParamDeclaration {
name: String,
},
ParamMetricSplit,
MetricDeclaration {
name: String,
},
MetricBucketSplit,
Bucket {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
LastMetricEvent {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
End,
}
+16 -38
View File
@@ -1,20 +1,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::engine::{EngineMessage, IngestedLine, MetricSample}; use crate::consts::{METRICS_POLL_INTERVAL, STDIN_POLL_INTERVAL as POLL_INTERVAL};
use crate::timestamp::Timestamp; use crate::engine::{EngineHandle, EngineMessage, IngestedLine, MetricSample};
use crate::engine::timestamp::Timestamp;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
const POLL_INTERVAL: Duration = Duration::from_millis(200); pub async fn run(engine: EngineHandle) {
const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(15);
/// 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 stdin = tokio::io::stdin();
let mut lines = BufReader::new(stdin).lines(); let mut lines = BufReader::new(stdin).lines();
let mut sequence: u64 = 0; let mut sequence: u64 = 0;
@@ -27,13 +20,12 @@ pub async fn run(engine: mpsc::Sender<EngineMessage>) {
}; };
sequence += 1; sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() { if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
break; break;
} }
} }
} }
/// Identifies a specific file instance on disk, so a recreated path can be told apart from the same file simply growing.
#[derive(PartialEq, Eq, Clone, Copy)] #[derive(PartialEq, Eq, Clone, Copy)]
struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ()); struct FileId(#[cfg(unix)] u64, #[cfg(not(unix))] ());
@@ -56,10 +48,7 @@ async fn open(path: &Path) -> Option<(File, FileId)> {
Some((file, file_id(&meta))) Some((file, file_id(&meta)))
} }
/// Tails `path` like `tail -f`, resuming from the start if the file is deleted/recreated or pub async fn run_file(engine: EngineHandle, path: PathBuf) {
/// truncated in place rather than exiting or getting stuck; `sequence` keeps incrementing
/// across a recreation since it's ottyl's own line counter, not tied to the source file.
pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut sequence: u64 = 0; let mut sequence: u64 = 0;
'reopen: loop { 'reopen: loop {
@@ -78,7 +67,6 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
let mut chunk = String::new(); let mut chunk = String::new();
match reader.read_line(&mut chunk).await { match reader.read_line(&mut chunk).await {
Ok(0) => { Ok(0) => {
// EOF: decide whether the file was truncated/recreated or just hasn't grown yet.
match tokio::fs::metadata(&path).await { match tokio::fs::metadata(&path).await {
Ok(meta) => { Ok(meta) => {
let new_id = file_id(&meta); let new_id = file_id(&meta);
@@ -87,9 +75,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
} }
id = new_id; id = new_id;
} }
Err(_) => { Err(_) => {}
// Path gone (mid-recreation); wait for it to reappear and reopen fresh.
}
} }
tokio::time::sleep(POLL_INTERVAL).await; tokio::time::sleep(POLL_INTERVAL).await;
} }
@@ -109,11 +95,10 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
}; };
sequence += 1; sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() { if engine.tx.send(EngineMessage::Ingest(line)).await.is_err() {
return; return;
} }
} }
// else: partial line at EOF (write in progress) — keep buffering.
} }
Err(_) => continue 'reopen, Err(_) => continue 'reopen,
} }
@@ -121,10 +106,7 @@ pub async fn run_file(engine: mpsc::Sender<EngineMessage>, path: PathBuf) {
} }
} }
/// Polls `url` on a fixed interval for a Prometheus/OpenMetrics-format response, pushing every parsed pub async fn run_metrics_poll(engine: EngineHandle, url: String) {
/// sample straight into the engine. Errors (unreachable endpoint, non-2xx response) are only logged —
/// polling keeps retrying on the next tick rather than exiting.
pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String) {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
loop { loop {
match client.get(&url).send().await { match client.get(&url).send().await {
@@ -133,7 +115,7 @@ pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String)
Ok(body) => { Ok(body) => {
let now = Timestamp::now(); let now = Timestamp::now();
for sample in parse_openmetrics(&body, now) { for sample in parse_openmetrics(&body, now) {
if engine.send(EngineMessage::IngestMetricSample(sample)).await.is_err() { if engine.tx.send(EngineMessage::IngestMetricSample(sample)).await.is_err() {
return; return;
} }
} }
@@ -148,10 +130,6 @@ pub async fn run_metrics_poll(engine: mpsc::Sender<EngineMessage>, url: String)
} }
} }
/// Parses the Prometheus text exposition / OpenMetrics format: one sample per non-comment,
/// non-empty line, as `metric_name{label="value",...} value [timestamp_ms]` (labels and the
/// timestamp are both optional). A line that doesn't parse is skipped rather than aborting the
/// whole scrape.
fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> { fn parse_openmetrics(body: &str, now: Timestamp) -> Vec<MetricSample> {
body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect() body.lines().filter_map(|line| parse_openmetrics_line(line, now)).collect()
} }
@@ -185,8 +163,6 @@ fn parse_openmetrics_line(line: &str, now: Timestamp) -> Option<MetricSample> {
Some(MetricSample { metric_name, labels, value, timestamp }) Some(MetricSample { metric_name, labels, value, timestamp })
} }
/// Splits on commas without handling an escaped comma inside a quoted label value — fine for the
/// common case this targets, not a full OpenMetrics-grammar parser.
fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> { fn parse_openmetrics_labels(raw: &str) -> BTreeMap<String, String> {
raw.split(',') raw.split(',')
.filter_map(|pair| pair.split_once('=')) .filter_map(|pair| pair.split_once('='))
@@ -238,7 +214,9 @@ mod openmetrics_tests {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::time::Duration;
use std::io::Write; use std::io::Write;
use crate::engine::test_handle;
use tokio::sync::mpsc; use tokio::sync::mpsc;
async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String { async fn recv_text(rx: &mut mpsc::Receiver<EngineMessage>) -> String {
@@ -258,7 +236,7 @@ mod tests {
let path = dir.path().join("app.log"); let path = dir.path().join("app.log");
std::fs::write(&path, "first\n").unwrap(); std::fs::write(&path, "first\n").unwrap();
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone())); tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "first"); assert_eq!(recv_text(&mut rx).await, "first");
@@ -274,7 +252,7 @@ mod tests {
let path = dir.path().join("app.log"); let path = dir.path().join("app.log");
std::fs::write(&path, "before-restart\n").unwrap(); std::fs::write(&path, "before-restart\n").unwrap();
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone())); tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "before-restart"); assert_eq!(recv_text(&mut rx).await, "before-restart");
@@ -291,7 +269,7 @@ mod tests {
let path = dir.path().join("app.log"); let path = dir.path().join("app.log");
std::fs::write(&path, "long line before truncation\n").unwrap(); std::fs::write(&path, "long line before truncation\n").unwrap();
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone())); tokio::spawn(run_file(tx, path.clone()));
assert_eq!(recv_text(&mut rx).await, "long line before truncation"); assert_eq!(recv_text(&mut rx).await, "long line before truncation");
@@ -312,7 +290,7 @@ mod tests {
let path = dir.path().join("app.log"); let path = dir.path().join("app.log");
std::fs::write(&path, "one\n").unwrap(); std::fs::write(&path, "one\n").unwrap();
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = test_handle(16);
tokio::spawn(run_file(tx, path.clone())); tokio::spawn(run_file(tx, path.clone()));
let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv()) let first = match tokio::time::timeout(Duration::from_secs(2), rx.recv())
+524
View File
@@ -0,0 +1,524 @@
use nom::branch::alt;
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
use nom::character::complete::{char, none_of, one_of};
use nom::error::{Error, ErrorKind};
use nom::sequence::delimited;
use nom::IResult;
use regex::Regex;
use std::sync::LazyLock;
use crate::consts::{COLUMN_SEPARATOR_PATTERN, COLUMN_VALUE_PATTERN};
fn key(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')(input)
}
fn quoted_value(input: &str) -> IResult<&str, &str> {
delimited(
char('"'),
alt((escaped(none_of("\\\""), '\\', one_of("\"\\nt")), nom::combinator::success(""))),
char('"'),
)(input)
}
fn bareword_value(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| !c.is_whitespace())(input)
}
fn looks_like_base64_padding_artifact(value: &str) -> bool {
value.starts_with('=')
}
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
let (input, k) = key(input)?;
let (input, _) = char('=')(input)?;
if let Ok((rest, v)) = quoted_value(input) {
Ok((rest, (k, v, true)))
} else {
let (rest, v) = bareword_value(input)?;
if looks_like_base64_padding_artifact(v) {
return Err(nom::Err::Error(Error::new(input, ErrorKind::Verify)));
}
Ok((rest, (k, v, false)))
}
}
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
let mut results = Vec::new();
let mut rest = line;
while !rest.is_empty() {
match pair(rest) {
Ok((remaining, found)) => {
results.push(found);
rest = remaining;
}
Err(_) => {
let mut chars = rest.chars();
chars.next();
rest = chars.as_str();
}
}
}
results
}
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
)
.expect("static log_stream regex must compile")
});
pub fn is_log_stream_line(line: &str) -> bool {
LOG_STREAM_RE.is_match(line)
}
fn bounded_column_value_pattern(width: usize) -> String {
if width <= 1 {
r"\S".to_string()
} else {
format!(r"\S(?:.{{0,{}}}\S)?", width - 2)
}
}
fn is_separator_run(run: &str) -> bool {
run.len() >= 2 || run.contains('\t')
}
static WHITESPACE_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
let mut columns = Vec::new();
let mut start = 0usize;
let mut consumed = 0usize;
for m in WHITESPACE_RUN.find_iter(line) {
if max_columns.is_some_and(|max| consumed + 1 >= max) {
break;
}
let run = &line[m.start()..m.end()];
if is_separator_run(run) {
columns.push((start, &line[start..m.start()]));
start = m.end();
consumed += 1;
}
}
columns.push((start, &line[start..]));
columns
}
fn split_columns(line: &str, max_columns: Option<usize>) -> Vec<&str> {
split_columns_with_offsets(line, max_columns).into_iter().map(|(_, s)| s).collect()
}
static COLUMN_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
#[derive(Clone)]
pub struct LogStreamColumn {
pub name: String,
start: usize,
}
#[derive(Clone)]
pub struct LogStreamHeader {
pub columns: Vec<LogStreamColumn>,
tab_delimited: bool,
}
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
if end.is_some_and(|end| end <= start) {
return "";
}
let mut indices = s.char_indices();
let byte_start = indices.nth(start).map(|(i, _)| i).unwrap_or(s.len());
let byte_end = match end {
Some(end) => s.char_indices().nth(end).map(|(i, _)| i).unwrap_or(s.len()),
None => s.len(),
};
&s[byte_start..byte_end.max(byte_start)]
}
impl LogStreamHeader {
pub fn detect(line: &str) -> Option<LogStreamHeader> {
if is_log_stream_line(line) {
return None;
}
let mut columns = Vec::new();
for (byte_start, slice) in split_columns_with_offsets(line, None) {
let trimmed = slice.trim();
if trimmed.is_empty() {
continue;
}
let leading_ws = slice.len() - slice.trim_start().len();
let start = line[..byte_start + leading_ws].chars().count();
columns.push(LogStreamColumn { name: trimmed.to_string(), start });
}
if columns.len() < 2 || !columns.iter().all(|c| COLUMN_NAME.is_match(&c.name)) {
return None;
}
let tab_delimited = WHITESPACE_RUN.find_iter(line).any(|m| m.as_str().contains('\t'));
Some(LogStreamHeader { columns, tab_delimited })
}
pub fn split_row<'a>(&self, line: &'a str) -> Vec<&'a str> {
if self.tab_delimited {
return split_columns(line, Some(self.columns.len() + 1));
}
let mut result = Vec::with_capacity(self.columns.len() + 1);
for (i, column) in self.columns.iter().enumerate() {
match self.columns.get(i + 1) {
Some(next) => result.push(char_slice(line, column.start, Some(next.start)).trim()),
None => {
let tail = char_slice(line, column.start, None);
let pieces = split_columns(tail, Some(2));
result.push(pieces[0].trim());
if let Some(message) = pieces.get(1) {
result.push(message);
}
}
}
}
result
}
pub fn column_pattern(&self, index: usize, capture_group: &str) -> String {
if self.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..index {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>{COLUMN_VALUE_PATTERN})"));
return pattern;
}
let start = self.columns[index].start;
let value_pattern = match self.columns.get(index + 1) {
Some(next) => bounded_column_value_pattern(next.start.saturating_sub(start)),
None => COLUMN_VALUE_PATTERN.to_string(),
};
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
}
pub fn message_pattern(&self, capture_group: &str) -> String {
if self.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..self.columns.len() {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>.*)$"));
return pattern;
}
let start = self.columns.last().map(|c| c.start).unwrap_or(0);
format!("^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$")
}
}
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
let (input, _) = tag("{\"time\":\"")(input)?;
let (input, time) = take_until("\"")(input)?;
let (input, _) = tag("\",\"level\":\"")(input)?;
let (input, level) = take_until("\"")(input)?;
let (input, _) = tag("\",\"msg\":\"")(input)?;
let (input, msg) = take_until("\"")(input)?;
let (input, _) = char('"')(input)?;
Ok((input, (time, level, msg)))
}
pub fn is_go_log_line(text: &str) -> bool {
match go_log_prefix(text) {
Ok((rest, _)) => rest.trim_end().ends_with('}'),
Err(_) => false,
}
}
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
let (input, _) = char(' ')(input)?;
let (input, time) = take_while1(|c: char| c.is_ascii_digit() || c == ':' || c == ',')(input)?;
let (input, _) = char(' ')(input)?;
let (input, level) = take_while1(|c: char| c.is_ascii_uppercase())(input)?;
let (input, _) = char(' ')(input)?;
let (input, logger) =
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')(input)?;
let (input, _) = char(' ')(input)?;
Ok((input, (date, time, level, logger)))
}
pub fn is_py_log_line(text: &str) -> bool {
match py_log_prefix(text) {
Ok((rest, _)) => !extract_pairs(rest).is_empty(),
Err(_) => false,
}
}
pub fn detect_format(text: &str) -> &'static str {
if is_log_stream_line(text) {
"log_stream"
} else if is_go_log_line(text) {
"go-log"
} else if is_py_log_line(text) {
"py-log"
} else if serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_object()) {
"json"
} else if !extract_pairs(text).is_empty() {
"logfmt"
} else {
"none"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_quoted_value() {
let pairs = extract_pairs(r#"msg="entity_moved""#);
assert_eq!(pairs, vec![("msg", "entity_moved", true)]);
}
#[test]
fn extracts_unquoted_bareword_value() {
let pairs = extract_pairs("x1=-4");
assert_eq!(pairs, vec![("x1", "-4", false)]);
}
#[test]
fn extracts_mixed_quoted_and_unquoted_pairs_in_one_line() {
let pairs = extract_pairs(r#"ts=1788648105773 msg="entity_moved" x1=-4 y1=34"#);
assert_eq!(
pairs,
vec![
("ts", "1788648105773", false),
("msg", "entity_moved", true),
("x1", "-4", false),
("y1", "34", false),
]
);
}
#[test]
fn returns_empty_for_lines_with_no_pairs() {
assert!(extract_pairs("just some plain prose here").is_empty());
assert!(extract_pairs(r#"{"foo": {"bar": 42}}"#).is_empty());
}
#[test]
fn does_not_panic_on_empty_value_at_end_of_line() {
assert_eq!(extract_pairs("key="), Vec::<(&str, &str, bool)>::new());
assert_eq!(extract_pairs(r#"key="""#), vec![("key", "", true)]);
}
#[test]
fn keeps_escaped_quote_inside_quoted_value() {
let pairs = extract_pairs(r#"msg="he said \"hi\"""#);
assert_eq!(pairs, vec![("msg", r#"he said \"hi\""#, true)]);
}
#[test]
fn detects_json_object_lines_as_json() {
assert_eq!(detect_format(r#"{"level":"error","code":500}"#), "json");
}
#[test]
fn detects_key_value_lines_as_logfmt() {
assert_eq!(detect_format(r#"ts=1788648105773 msg="entity_moved" x1=-4"#), "logfmt");
}
#[test]
fn detects_plain_prose_as_none() {
assert_eq!(detect_format("just some plain prose here"), "none");
}
#[test]
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
assert_eq!(detect_format("[1,2,3]"), "none");
assert_eq!(detect_format("42"), "none");
}
#[test]
fn rejects_base64_padding_fragment_as_a_pair() {
let pairs = extract_pairs(
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
);
assert!(
pairs.iter().all(|(k, _, _)| *k != "rKTEtG7JmnThhoKPbZiVrA"),
"base64 padding fragment must not be extracted as a pair: {pairs:?}"
);
}
const LOG_STREAM_FIXTURE: &str = include_str!("../fixtures/log_stream_sample.txt");
#[test]
fn detects_log_stream_shaped_lines() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert_eq!(detect_format(data_line), "log_stream");
assert!(is_log_stream_line(data_line));
}
#[test]
fn header_row_is_not_detected_as_log_stream() {
let header = LOG_STREAM_FIXTURE.lines().next().unwrap();
assert!(!is_log_stream_line(header));
}
fn column_names(header: &LogStreamHeader) -> Vec<&str> {
header.columns.iter().map(|c| c.name.as_str()).collect()
}
#[test]
fn detects_log_stream_header_columns_generically() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).expect("fixture header must be detected");
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type", "Activity", "PID", "TTL"]);
assert!(!header.tab_delimited);
}
#[test]
fn a_log_stream_data_line_is_never_detected_as_its_own_header() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert!(LogStreamHeader::detect(data_line).is_none());
}
#[test]
fn plain_prose_is_not_detected_as_a_log_stream_header() {
assert!(LogStreamHeader::detect("just some plain prose here").is_none());
assert!(LogStreamHeader::detect("ts=1788648105773 msg=\"entity_moved\"").is_none());
}
#[test]
fn splits_log_stream_row_into_named_columns_plus_trailing_message() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let data_line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let row = header.split_row(data_line);
assert_eq!(row.len(), header.columns.len() + 1);
assert_eq!(row[0], "2026-09-06 01:48:54.518117+0100");
assert_eq!(row[1], "0x4dfed2");
assert_eq!(row[2], "Default");
assert_eq!(row[3], "0x0");
assert_eq!(row[4], "606");
assert_eq!(row[5], "0");
assert_eq!(row[6], "WindowServer: (HSTouchHIDService) something happened");
}
#[test]
fn a_single_tab_separates_columns_exactly_like_a_run_of_spaces() {
let header = LogStreamHeader::detect("Timestamp\tThread\tType").unwrap();
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
assert!(header.tab_delimited);
let spaced = header.split_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message");
let tabbed = header.split_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message");
assert_eq!(spaced[0], "2026-09-06 01:48:54");
assert_eq!(spaced[1], "0x4dfed2");
assert_eq!(spaced[2], "Default trailing message");
assert_eq!(tabbed[0], "2026-09-06 01:48:54");
assert_eq!(tabbed[1], "0x4dfed2");
assert_eq!(tabbed[2], "Default trailing message");
}
#[test]
fn column_and_message_patterns_capture_the_same_fields_split_log_stream_row_finds() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let type_re = Regex::new(&header.column_pattern(2, "Type")).unwrap();
assert_eq!(type_re.captures(line).unwrap().name("Type").unwrap().as_str(), "Default");
let pid_re = Regex::new(&header.column_pattern(4, "PID")).unwrap();
assert_eq!(pid_re.captures(line).unwrap().name("PID").unwrap().as_str(), "606");
let message_re = Regex::new(&header.message_pattern("message")).unwrap();
assert_eq!(
message_re.captures(line).unwrap().name("message").unwrap().as_str(),
"WindowServer: (HSTouchHIDService) something happened"
);
let timestamp_re = Regex::new(&header.column_pattern(0, "Timestamp")).unwrap();
assert_eq!(
timestamp_re.captures(line).unwrap().name("Timestamp").unwrap().as_str(),
"2026-09-06 01:48:54.518117+0100"
);
}
#[test]
fn is_log_stream_line_rejects_non_columnar_lines_that_a_fixed_offset_column_regex_would_still_match() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = LogStreamHeader::detect(header_line).unwrap();
let timestamp_re = Regex::new(&header.column_pattern(0, "Timestamp")).unwrap();
let continuation_line = "so_gencnt: 4042707 so_state: 0x0";
assert!(timestamp_re.captures(continuation_line).is_some(), "sanity: regex alone still matches garbage");
assert!(!is_log_stream_line(continuation_line));
assert!(!is_log_stream_line(header_line));
assert!(!is_log_stream_line("}"));
}
#[test]
fn a_wifi_stat_dump_line_is_still_recognized_as_log_stream_despite_embedded_key_value_pairs() {
let line = LOG_STREAM_FIXTURE
.lines()
.find(|line| line.contains("txRTSFrm="))
.expect("fixture must contain the WiFi stat dump line");
assert_eq!(detect_format(line), "log_stream");
assert!(!extract_pairs(line).is_empty());
}
const GO_LOG_FIXTURE: &str = include_str!("../fixtures/go_log_sample.txt");
const PY_LOG_FIXTURE: &str = include_str!("../fixtures/py_log_sample.txt");
#[test]
fn detects_go_log_shaped_lines() {
for line in GO_LOG_FIXTURE.lines() {
assert!(is_go_log_line(line), "expected go-log line to match: {line}");
assert_eq!(detect_format(line), "go-log");
}
}
#[test]
fn detects_py_log_shaped_lines() {
for line in PY_LOG_FIXTURE.lines() {
assert!(is_py_log_line(line), "expected py-log line to match: {line}");
assert_eq!(detect_format(line), "py-log");
}
}
#[test]
fn a_generic_json_object_is_not_detected_as_go_log() {
let line = r#"{"level":"error","code":500}"#;
assert!(!is_go_log_line(line));
assert_eq!(detect_format(line), "json");
}
#[test]
fn a_generic_logfmt_line_is_not_detected_as_py_log() {
let line = r#"ts=1788648105773 msg="entity_moved" x1=-4"#;
assert!(!is_py_log_line(line));
assert_eq!(detect_format(line), "logfmt");
}
#[test]
fn go_log_prefix_extracts_time_level_and_msg() {
let (rest, (time, level, msg)) =
go_log_prefix(r#"{"time":"2026-09-06T10:35:34.499921+01:00","level":"WARN","msg":"eventC","labelC":1}"#)
.unwrap();
assert_eq!(time, "2026-09-06T10:35:34.499921+01:00");
assert_eq!(level, "WARN");
assert_eq!(msg, "eventC");
assert_eq!(rest, r#","labelC":1}"#);
}
#[test]
fn py_log_prefix_extracts_date_time_level_and_logger() {
let (rest, (date, time, level, logger)) =
py_log_prefix("2026-09-06 10:35:55,217 INFO delete-py-log event=eventC labelC=1").unwrap();
assert_eq!(date, "2026-09-06");
assert_eq!(time, "10:35:55,217");
assert_eq!(level, "INFO");
assert_eq!(logger, "delete-py-log");
assert_eq!(rest, "event=eventC labelC=1");
}
}
@@ -1,18 +1,15 @@
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
use serde::Serialize; use serde::Serialize;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt; use std::fmt;
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub enum MetricPrimitive { pub enum MetricPrimitive {
String(String), String(String),
Number(Number), Number(Number),
Timestamp(Timestamp), Timestamp(Timestamp),
/// Milliseconds elapsed; distinct from `Number` so it reads unambiguously in JSON/label form.
Duration(i64), Duration(i64),
/// A `Boolean`-kind param's value: whether `message_match_regex` matched the line. See `MetricPrimitiveKind::Boolean`.
Boolean(bool), Boolean(bool),
} }
@@ -51,7 +48,6 @@ impl Ord for MetricPrimitive {
} }
} }
/// Textual form used to compare label filters (query-string values) against a formatted `MetricPrimitive` rather than parsing them into one.
impl fmt::Display for MetricPrimitive { impl fmt::Display for MetricPrimitive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
@@ -65,7 +61,6 @@ impl fmt::Display for MetricPrimitive {
} }
} }
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize)] #[derive(Debug, Clone, Copy, Serialize)]
pub enum Number { pub enum Number {
@@ -74,7 +69,6 @@ pub enum Number {
} }
impl Number { impl Number {
/// Widens to `f64` for cross-kind numeric comparisons; `pub(crate)` since the filter evaluator in `engine.rs` needs it.
pub(crate) fn as_f64(&self) -> f64 { pub(crate) fn as_f64(&self) -> f64 {
match self { match self {
Number::Int(v) => *v as f64, Number::Int(v) => *v as f64,
+4433
View File
File diff suppressed because it is too large Load Diff
+37 -59
View File
@@ -1,21 +1,12 @@
//! Hand-rolled parser for a small PromQL subset: metric name + label matchers, an optional `[5m]` range, and a top-level `sum()`/`avg()`/`count()`/`rate()`/`histogram()`/`gauge()` wrapper.
//! Only exact-match (`=`) label matchers are supported; `rate()` requires a duration range selector, while `histogram(metric[width])` reuses that `[...]` slot for a bare bucket width instead.
//! `sum()`/`avg()` may additionally wrap a `rate(...)` call directly (e.g. `sum(rate(requests[5m]))`), and either may be followed by a `by (label, ...)` clause to preserve one series per distinct value of the named labels instead of collapsing to a single series.
//! `count()` is a plain vector selector wrapper only (no `rate()` nesting) — it counts occurrences per group regardless of the metric's value, which is the deliberate opt-in for occurrence counting that `sum()`/`avg()` no longer fall back to for a `String`-kind metric (see `crate::engine::evaluate_promql`).
use std::time::Duration; use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggOp { pub enum AggOp {
Sum, Sum,
Avg, Avg,
/// Occurrence count per group, ignoring the metric's stored value entirely — the explicit,
/// opt-in-by-name alternative to summing a `String`-kind metric's coerced-to-1.0 values.
Count, Count,
Rate, Rate,
/// Buckets counter values into fixed-width buckets; requires `bucket_width`.
Histogram, Histogram,
/// The most-recent counter value per label combination.
Gauge, Gauge,
} }
@@ -30,26 +21,19 @@ pub struct PromqlQuery {
pub agg: Option<AggOp>, pub agg: Option<AggOp>,
pub metric_name: String, pub metric_name: String,
pub matchers: Vec<LabelMatcher>, pub matchers: Vec<LabelMatcher>,
/// Duration range selector, e.g. `[5m]`; only meaningful for `rate()`.
pub range: Option<Duration>, pub range: Option<Duration>,
/// Bare-number range selector, e.g. `[10]`; only meaningful for `histogram()`, where it's the bucket width.
pub bucket_width: Option<f64>, pub bucket_width: Option<f64>,
/// Set when `agg` is `Sum`/`Avg` and it wraps an inner `rate(metric[range])` rather than a bare vector
/// selector, e.g. `sum(rate(requests[5m]))`. `range` above carries the inner `rate()`'s duration in that case.
pub inner_rate: bool, pub inner_rate: bool,
/// Labels named in a trailing `by (...)` clause; empty means "collapse to a single series" (the historical
/// behavior of `sum()`/`avg()`). When non-empty, one series is kept per distinct combination of these labels.
pub by_labels: Vec<String>, pub by_labels: Vec<String>,
} }
/// What a `[...]` range selector turned out to hold: a duration, or a bare width.
enum RangeSelector { enum RangeSelector {
Duration(Duration), Duration(Duration),
BareWidth(f64), BareWidth(f64),
} }
/// Parses a PromQL query string, returning a human-readable error message on malformed input. impl PromqlQuery {
pub fn parse_query(input: &str) -> Result<PromqlQuery, String> { pub fn parse(input: &str) -> Result<PromqlQuery, String> {
let mut parser = Parser::new(input); let mut parser = Parser::new(input);
let query = parser.parse_query()?; let query = parser.parse_query()?;
parser.skip_whitespace(); parser.skip_whitespace();
@@ -66,6 +50,7 @@ pub fn parse_query(input: &str) -> Result<PromqlQuery, String> {
} }
Ok(query) Ok(query)
} }
}
struct Parser<'a> { struct Parser<'a> {
input: &'a str, input: &'a str,
@@ -177,7 +162,6 @@ impl<'a> Parser<'a> {
Ok(matchers) Ok(matchers)
} }
/// Parses `[<digits><unit>]` (a duration, e.g. `[5m]`) or a bare `[<digits>]` (a bucket width).
fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> { fn parse_range_selector(&mut self) -> Result<Option<RangeSelector>, String> {
self.skip_whitespace(); self.skip_whitespace();
if self.peek() != Some('[') { if self.peek() != Some('[') {
@@ -239,7 +223,6 @@ impl<'a> Parser<'a> {
}) })
} }
/// Parses a trailing `by (label, ...)` clause, if present; returns an empty vec otherwise.
fn parse_by_clause(&mut self) -> Result<Vec<String>, String> { fn parse_by_clause(&mut self) -> Result<Vec<String>, String> {
self.skip_whitespace(); self.skip_whitespace();
let checkpoint = self.pos; let checkpoint = self.pos;
@@ -289,14 +272,11 @@ impl<'a> Parser<'a> {
_ => None, _ => None,
}; };
self.skip_whitespace(); self.skip_whitespace();
// `sum by (label) (metric)`: a `by (...)` clause may also precede the aggregation's
// parenthesized argument, not just follow it.
let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() }; let prefix_by_labels = if agg.is_some() { self.parse_by_clause()? } else { Vec::new() };
self.skip_whitespace(); self.skip_whitespace();
if agg.is_some() && self.peek() == Some('(') { if agg.is_some() && self.peek() == Some('(') {
self.expect_char('(')?; self.expect_char('(')?;
self.skip_whitespace(); self.skip_whitespace();
// `sum()`/`avg()` may wrap `rate(metric[range])` directly instead of a bare vector selector.
let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) { let mut inner = if matches!(agg, Some(AggOp::Sum) | Some(AggOp::Avg)) {
let inner_checkpoint = self.pos; let inner_checkpoint = self.pos;
let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate"); let took_inner_rate = self.parse_identifier().is_ok_and(|id| id == "rate");
@@ -338,7 +318,6 @@ impl<'a> Parser<'a> {
if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_by_labels }; if prefix_by_labels.is_empty() { trailing_by_labels } else { prefix_by_labels };
Ok(inner) Ok(inner)
} else { } else {
// Rewind and parse as a plain vector selector, covering metric names that collide with an agg_op keyword.
self.pos = checkpoint; self.pos = checkpoint;
self.parse_vector_selector() self.parse_vector_selector()
} }
@@ -351,7 +330,7 @@ mod tests {
#[test] #[test]
fn parses_bare_metric_name() { fn parses_bare_metric_name() {
let query = parse_query("requests").unwrap(); let query = PromqlQuery::parse("requests").unwrap();
assert_eq!(query.agg, None); assert_eq!(query.agg, None);
assert_eq!(query.metric_name, "requests"); assert_eq!(query.metric_name, "requests");
assert!(query.matchers.is_empty()); assert!(query.matchers.is_empty());
@@ -360,7 +339,7 @@ mod tests {
#[test] #[test]
fn parses_metric_with_single_label_matcher() { fn parses_metric_with_single_label_matcher() {
let query = parse_query(r#"requests{path="/a"}"#).unwrap(); let query = PromqlQuery::parse(r#"requests{path="/a"}"#).unwrap();
assert_eq!(query.metric_name, "requests"); assert_eq!(query.metric_name, "requests");
assert_eq!( assert_eq!(
query.matchers, query.matchers,
@@ -370,7 +349,7 @@ mod tests {
#[test] #[test]
fn parses_metric_with_multiple_label_matchers() { fn parses_metric_with_multiple_label_matchers() {
let query = parse_query(r#"requests{path="/a", method="GET"}"#).unwrap(); let query = PromqlQuery::parse(r#"requests{path="/a", method="GET"}"#).unwrap();
assert_eq!(query.matchers.len(), 2); assert_eq!(query.matchers.len(), 2);
assert_eq!(query.matchers[0].name, "path"); assert_eq!(query.matchers[0].name, "path");
assert_eq!(query.matchers[1].name, "method"); assert_eq!(query.matchers[1].name, "method");
@@ -378,68 +357,67 @@ mod tests {
#[test] #[test]
fn parses_range_selector() { fn parses_range_selector() {
let query = parse_query("requests[5m]").unwrap(); let query = PromqlQuery::parse("requests[5m]").unwrap();
assert_eq!(query.range, Some(Duration::from_secs(300))); assert_eq!(query.range, Some(Duration::from_secs(300)));
} }
#[test] #[test]
fn parses_label_matchers_and_range_together() { fn parses_label_matchers_and_range_together() {
let query = parse_query(r#"requests{path="/a"}[1h]"#).unwrap(); let query = PromqlQuery::parse(r#"requests{path="/a"}[1h]"#).unwrap();
assert_eq!(query.matchers.len(), 1); assert_eq!(query.matchers.len(), 1);
assert_eq!(query.range, Some(Duration::from_secs(3600))); assert_eq!(query.range, Some(Duration::from_secs(3600)));
} }
#[test] #[test]
fn parses_sum_aggregation() { fn parses_sum_aggregation() {
let query = parse_query(r#"sum(requests{path="/a"})"#).unwrap(); let query = PromqlQuery::parse(r#"sum(requests{path="/a"})"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Sum)); assert_eq!(query.agg, Some(AggOp::Sum));
assert_eq!(query.metric_name, "requests"); assert_eq!(query.metric_name, "requests");
} }
#[test] #[test]
fn parses_avg_aggregation() { fn parses_avg_aggregation() {
let query = parse_query("avg(requests)").unwrap(); let query = PromqlQuery::parse("avg(requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Avg)); assert_eq!(query.agg, Some(AggOp::Avg));
} }
#[test] #[test]
fn parses_rate_with_range() { fn parses_rate_with_range() {
let query = parse_query("rate(requests[5m])").unwrap(); let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
assert_eq!(query.agg, Some(AggOp::Rate)); assert_eq!(query.agg, Some(AggOp::Rate));
assert_eq!(query.range, Some(Duration::from_secs(300))); assert_eq!(query.range, Some(Duration::from_secs(300)));
} }
#[test] #[test]
fn rejects_rate_without_range() { fn rejects_rate_without_range() {
let err = parse_query("rate(requests)").unwrap_err(); let err = PromqlQuery::parse("rate(requests)").unwrap_err();
assert!(err.contains("range selector")); assert!(err.contains("range selector"));
} }
#[test] #[test]
fn rejects_empty_input() { fn rejects_empty_input() {
assert!(parse_query("").is_err()); assert!(PromqlQuery::parse("").is_err());
} }
#[test] #[test]
fn rejects_unclosed_brace() { fn rejects_unclosed_brace() {
assert!(parse_query(r#"requests{path="/a""#).is_err()); assert!(PromqlQuery::parse(r#"requests{path="/a""#).is_err());
} }
#[test] #[test]
fn rejects_unclosed_bracket() { fn rejects_unclosed_bracket() {
assert!(parse_query("requests[5m").is_err()); assert!(PromqlQuery::parse("requests[5m").is_err());
} }
#[test] #[test]
fn rejects_unknown_aggregation() { fn rejects_unknown_aggregation() {
// `median` isn't a supported agg_op, so this parses as a bare metric name and trips on the trailing `(...)`. let err = PromqlQuery::parse("median(requests)").unwrap_err();
let err = parse_query("median(requests)").unwrap_err();
assert!(err.contains("unexpected trailing input")); assert!(err.contains("unexpected trailing input"));
} }
#[test] #[test]
fn parses_count_aggregation() { fn parses_count_aggregation() {
let query = parse_query("count(requests)").unwrap(); let query = PromqlQuery::parse("count(requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count)); assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.metric_name, "requests"); assert_eq!(query.metric_name, "requests");
assert!(!query.inner_rate); assert!(!query.inner_rate);
@@ -447,37 +425,37 @@ mod tests {
#[test] #[test]
fn parses_count_with_by_clause() { fn parses_count_with_by_clause() {
let query = parse_query("count(requests) by (path)").unwrap(); let query = PromqlQuery::parse("count(requests) by (path)").unwrap();
assert_eq!(query.agg, Some(AggOp::Count)); assert_eq!(query.agg, Some(AggOp::Count));
assert_eq!(query.by_labels, vec!["path".to_string()]); assert_eq!(query.by_labels, vec!["path".to_string()]);
} }
#[test] #[test]
fn rejects_regex_label_matcher() { fn rejects_regex_label_matcher() {
let err = parse_query(r#"requests{path=~"/a"}"#).unwrap_err(); let err = PromqlQuery::parse(r#"requests{path=~"/a"}"#).unwrap_err();
assert!(err.contains("regex label matchers")); assert!(err.contains("regex label matchers"));
} }
#[test] #[test]
fn rejects_missing_metric_name() { fn rejects_missing_metric_name() {
assert!(parse_query(r#"{path="/a"}"#).is_err()); assert!(PromqlQuery::parse(r#"{path="/a"}"#).is_err());
} }
#[test] #[test]
fn rejects_unknown_duration_unit() { fn rejects_unknown_duration_unit() {
let err = parse_query("requests[5x]").unwrap_err(); let err = PromqlQuery::parse("requests[5x]").unwrap_err();
assert!(err.contains("duration unit")); assert!(err.contains("duration unit"));
} }
#[test] #[test]
fn rejects_trailing_garbage() { fn rejects_trailing_garbage() {
let err = parse_query("requests extra").unwrap_err(); let err = PromqlQuery::parse("requests extra").unwrap_err();
assert!(err.contains("unexpected trailing input")); assert!(err.contains("unexpected trailing input"));
} }
#[test] #[test]
fn parses_histogram_with_bare_width() { fn parses_histogram_with_bare_width() {
let query = parse_query("histogram(latency_ms[10])").unwrap(); let query = PromqlQuery::parse("histogram(latency_ms[10])").unwrap();
assert_eq!(query.agg, Some(AggOp::Histogram)); assert_eq!(query.agg, Some(AggOp::Histogram));
assert_eq!(query.metric_name, "latency_ms"); assert_eq!(query.metric_name, "latency_ms");
assert_eq!(query.bucket_width, Some(10.0)); assert_eq!(query.bucket_width, Some(10.0));
@@ -486,27 +464,27 @@ mod tests {
#[test] #[test]
fn rejects_histogram_without_width() { fn rejects_histogram_without_width() {
let err = parse_query("histogram(latency_ms)").unwrap_err(); let err = PromqlQuery::parse("histogram(latency_ms)").unwrap_err();
assert!(err.contains("bucket width")); assert!(err.contains("bucket width"));
} }
#[test] #[test]
fn parses_gauge_with_no_range_selector() { fn parses_gauge_with_no_range_selector() {
let query = parse_query(r#"gauge(connections{host="a"})"#).unwrap(); let query = PromqlQuery::parse(r#"gauge(connections{host="a"})"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Gauge)); assert_eq!(query.agg, Some(AggOp::Gauge));
assert_eq!(query.matchers, vec![LabelMatcher { name: "host".to_string(), value: "a".to_string() }]); assert_eq!(query.matchers, vec![LabelMatcher { name: "host".to_string(), value: "a".to_string() }]);
} }
#[test] #[test]
fn rate_range_selector_is_still_a_duration_not_a_bare_width() { fn rate_range_selector_is_still_a_duration_not_a_bare_width() {
let query = parse_query("rate(requests[5m])").unwrap(); let query = PromqlQuery::parse("rate(requests[5m])").unwrap();
assert_eq!(query.range, Some(Duration::from_secs(300))); assert_eq!(query.range, Some(Duration::from_secs(300)));
assert_eq!(query.bucket_width, None); assert_eq!(query.bucket_width, None);
} }
#[test] #[test]
fn parses_sum_of_rate() { fn parses_sum_of_rate() {
let query = parse_query("sum(rate(requests[1m]))").unwrap(); let query = PromqlQuery::parse("sum(rate(requests[1m]))").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum)); assert_eq!(query.agg, Some(AggOp::Sum));
assert!(query.inner_rate); assert!(query.inner_rate);
assert_eq!(query.metric_name, "requests"); assert_eq!(query.metric_name, "requests");
@@ -516,7 +494,7 @@ mod tests {
#[test] #[test]
fn parses_avg_of_rate_with_label_matchers() { fn parses_avg_of_rate_with_label_matchers() {
let query = parse_query(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap(); let query = PromqlQuery::parse(r#"avg(rate(requests{path="/a"}[5m]))"#).unwrap();
assert_eq!(query.agg, Some(AggOp::Avg)); assert_eq!(query.agg, Some(AggOp::Avg));
assert!(query.inner_rate); assert!(query.inner_rate);
assert_eq!(query.matchers, vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]); assert_eq!(query.matchers, vec![LabelMatcher { name: "path".to_string(), value: "/a".to_string() }]);
@@ -524,13 +502,13 @@ mod tests {
#[test] #[test]
fn rejects_sum_of_rate_without_range() { fn rejects_sum_of_rate_without_range() {
let err = parse_query("sum(rate(requests))").unwrap_err(); let err = PromqlQuery::parse("sum(rate(requests))").unwrap_err();
assert!(err.contains("range selector")); assert!(err.contains("range selector"));
} }
#[test] #[test]
fn parses_sum_with_by_clause() { fn parses_sum_with_by_clause() {
let query = parse_query("sum(requests) by (path)").unwrap(); let query = PromqlQuery::parse("sum(requests) by (path)").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum)); assert_eq!(query.agg, Some(AggOp::Sum));
assert!(!query.inner_rate); assert!(!query.inner_rate);
assert_eq!(query.by_labels, vec!["path".to_string()]); assert_eq!(query.by_labels, vec!["path".to_string()]);
@@ -538,44 +516,44 @@ mod tests {
#[test] #[test]
fn parses_sum_of_rate_with_by_clause_multiple_labels() { fn parses_sum_of_rate_with_by_clause_multiple_labels() {
let query = parse_query("sum(rate(requests[1m])) by (path, method)").unwrap(); let query = PromqlQuery::parse("sum(rate(requests[1m])) by (path, method)").unwrap();
assert!(query.inner_rate); assert!(query.inner_rate);
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]); assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
} }
#[test] #[test]
fn sum_without_by_clause_has_empty_by_labels() { fn sum_without_by_clause_has_empty_by_labels() {
let query = parse_query("sum(requests)").unwrap(); let query = PromqlQuery::parse("sum(requests)").unwrap();
assert!(query.by_labels.is_empty()); assert!(query.by_labels.is_empty());
} }
#[test] #[test]
fn rejects_empty_by_clause() { fn rejects_empty_by_clause() {
let err = parse_query("sum(requests) by ()").unwrap_err(); let err = PromqlQuery::parse("sum(requests) by ()").unwrap_err();
assert!(err.contains("at least one label")); assert!(err.contains("at least one label"));
} }
#[test] #[test]
fn rejects_unterminated_by_clause() { fn rejects_unterminated_by_clause() {
assert!(parse_query("sum(requests) by (path").is_err()); assert!(PromqlQuery::parse("sum(requests) by (path").is_err());
} }
#[test] #[test]
fn parses_sum_with_prefix_by_clause() { fn parses_sum_with_prefix_by_clause() {
let query = parse_query("sum by (path) (requests)").unwrap(); let query = PromqlQuery::parse("sum by (path) (requests)").unwrap();
assert_eq!(query.agg, Some(AggOp::Sum)); assert_eq!(query.agg, Some(AggOp::Sum));
assert_eq!(query.by_labels, vec!["path".to_string()]); assert_eq!(query.by_labels, vec!["path".to_string()]);
} }
#[test] #[test]
fn parses_sum_with_prefix_by_clause_multiple_labels() { fn parses_sum_with_prefix_by_clause_multiple_labels() {
let query = parse_query("sum by (path, method) (requests)").unwrap(); let query = PromqlQuery::parse("sum by (path, method) (requests)").unwrap();
assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]); assert_eq!(query.by_labels, vec!["path".to_string(), "method".to_string()]);
} }
#[test] #[test]
fn rejects_by_clause_both_before_and_after_aggregation() { fn rejects_by_clause_both_before_and_after_aggregation() {
let err = parse_query("sum by (path) (requests) by (method)").unwrap_err(); let err = PromqlQuery::parse("sum by (path) (requests) by (method)").unwrap_err();
assert!(err.contains("only appear once")); assert!(err.contains("only appear once"));
} }
} }
+1 -36
View File
@@ -1,24 +1,7 @@
//! Ticket 040: the single windowed-rate/count implementation in the codebase, shared by
//! PromQL's `rate()` (`crate::engine::evaluate_promql`, via the `windowed_rate_series` adapter
//! there) and alert `Threshold`/`Ratio` conditions (`crate::alerts`). Previously each computed
//! its own trailing window independently — PromQL scanning indexed bucket data, alerts scanning
//! raw log-line matches with a hand-rolled `VecDeque` — and could disagree for equivalent-looking
//! rules simply because the window boundary was implemented twice.
//!
//! `count_in_window` is the one place that decides what "falls in the trailing window" means;
//! everything else in this module is built on top of it, either by batch-rescanning a full point
//! series (`rate_at`/`windowed_rate_series`, fine for PromQL's historical/one-shot evaluation) or
//! incrementally (`RateWindow`, for alerts, which see one point at a time and need cost
//! proportional to the window rather than a full rescan per ingested line).
use std::collections::VecDeque; use std::collections::VecDeque;
use crate::timestamp::Timestamp; use crate::engine::timestamp::Timestamp;
/// How many `points` fall in the trailing `(now - window_ms, now]` window. Points are occurrence
/// markers rather than a continuous reading, so only their timestamps matter — the boundary is
/// exclusive on the old end and inclusive of `now` itself, matching the original
/// `windowed_rate_series` behavior this replaces.
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize { fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
let now_ms = now.as_millis(); let now_ms = now.as_millis();
let window_start = now_ms - window_ms; let window_start = now_ms - window_ms;
@@ -31,33 +14,21 @@ fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64)
.count() .count()
} }
/// Trailing-window occurrence rate (points per second) ending at `now`: how many `points` fall in
/// `(now - window_ms, now]`, divided by the window width in seconds.
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 { pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
let window_seconds = window_ms as f64 / 1000.0; let window_seconds = window_ms as f64 / 1000.0;
count_in_window(points, now, window_ms) as f64 / window_seconds count_in_window(points, now, window_ms) as f64 / window_seconds
} }
/// Evaluates `rate_at` at every one of `points`' own timestamps, turning an occurrence series
/// into a windowed rate series — used by PromQL's `rate()` so it plots as a real line rather than
/// one trailing point.
pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> { pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> {
points.iter().map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms))).collect() points.iter().map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms))).collect()
} }
/// Incrementally maintained trailing window: for a caller that sees points arrive one at a time
/// (alert `Threshold`/`Ratio` conditions, evaluated per ingested line) and needs cost proportional
/// to the window rather than `count_in_window`'s full rescan per call. Assumes points are pushed
/// in non-decreasing timestamp order, true for ingested log lines.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct RateWindow { pub struct RateWindow {
timestamps: VecDeque<Timestamp>, timestamps: VecDeque<Timestamp>,
} }
impl RateWindow { impl RateWindow {
/// Builds a window pre-populated from already-seen history (e.g. currently-ingested logs
/// matching an alert's query), so a freshly-created window starts with whatever of that
/// history already falls in the trailing window instead of empty.
pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self { pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self {
let mut window = Self::default(); let mut window = Self::default();
for ts in timestamps { for ts in timestamps {
@@ -67,14 +38,11 @@ impl RateWindow {
window window
} }
/// Records one occurrence at `timestamp` and drops anything that has aged out of the
/// resulting trailing window.
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) { pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
self.timestamps.push_back(timestamp); self.timestamps.push_back(timestamp);
self.prune(timestamp, window_ms); self.prune(timestamp, window_ms);
} }
/// Same boundary as `count_in_window`: drops entries at or before `now - window_ms`.
fn prune(&mut self, now: Timestamp, window_ms: i64) { fn prune(&mut self, now: Timestamp, window_ms: i64) {
let window_start = now.as_millis() - window_ms; let window_start = now.as_millis() - window_ms;
while let Some(&front) = self.timestamps.front() { while let Some(&front) = self.timestamps.front() {
@@ -102,9 +70,7 @@ mod tests {
(Timestamp::from_millis(1000), 1.0), (Timestamp::from_millis(1000), 1.0),
(Timestamp::from_millis(2000), 1.0), (Timestamp::from_millis(2000), 1.0),
]; ];
// (1000, 2000]: only the point at 2000.
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0); assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0);
// (0, 2000]: points at 1000 and 2000 -> 2 points / 2s.
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0); assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
} }
@@ -136,7 +102,6 @@ mod tests {
#[test] #[test]
fn rate_window_backfill_seeds_from_history_within_the_window() { fn rate_window_backfill_seeds_from_history_within_the_window() {
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis); let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
// now=2500ms, window=1000ms -> window (1500, 2500]: only 2000 and 2500 qualify.
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000); let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
assert_eq!(window.count(), 2); assert_eq!(window.count(), 2);
} }
@@ -1,7 +1,6 @@
use serde::Serialize; use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
/// Wall-clock instant stored as nanoseconds since the Unix epoch (not `std::time::Instant`, which has no epoch and can't be serialized).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Timestamp(i64); pub struct Timestamp(i64);
-49
View File
@@ -1,49 +0,0 @@
// Grammar for the FilterBar query language.
//
// Supports:
// - regex("...") raw-text regex search over log lines
// - metric_name[param] <op> value metric comparisons
// - json[field] <op> value JSON field comparisons (line parsed as JSON)
// - ottyl_seq <op> value reserved metric name (no [param])
// - ottyl_ts <op> value reserved metric name (no [param])
// - ottyl_fmt <op> value reserved metric name (no [param])
// - `and` / `or` boolean combinators, with `and` binding tighter than `or`
// - parenthesized sub-expressions
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }
file = { SOI ~ or_expr ~ EOI }
or_expr = { and_expr ~ (or_kw ~ and_expr)* }
and_expr = { term ~ (and_kw ~ term)* }
term = _{ regex_expr | comparison | "(" ~ or_expr ~ ")" }
// These must be atomic (`@`): a non-atomic rule would let pest's implicit
// whitespace insertion slip in between the keyword and its own boundary
// check below, which breaks the lookahead (it would see the first
// non-whitespace character *after* skipping the separating space, instead
// of the character immediately following the keyword).
and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") }
or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") }
regex_expr = { "regex" ~ "(" ~ string_literal ~ ")" }
string_literal = ${ "\"" ~ inner_str ~ "\"" }
inner_str = @{ (escaped_char | !("\"" | "\\") ~ ANY)* }
escaped_char = @{ "\\" ~ ANY }
comparison = _{ json_comparison | metric_comparison }
// `json[field] <op> value` must be tried before `metric_comparison` since "json" is
// otherwise a syntactically valid (if never declared) metric name.
json_comparison = { "json" ~ "[" ~ param ~ "]" ~ op ~ value }
metric_comparison = { metric ~ ("[" ~ param ~ "]")? ~ op ~ value }
metric = @{ ident }
param = @{ ident }
ident = @{ (ASCII_ALPHANUMERIC | "_")+ }
op = { "<=" | ">=" | "==" | "!=" | "<" | ">" }
value = @{ (!(WHITESPACE | ")") ~ ANY)+ }
-39
View File
@@ -1,39 +0,0 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
/// Key type for the engine's primary index. Variant order matters: derived `Ord` compares the discriminant first, carving the map into contiguous ranges in declaration order (logs, params, metric declarations, 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>,
},
/// Internal bookkeeping tracking the last event timestamp per metric+label, so a `Duration` param's first occurrence (which pushes no `Bucket`) still lets the next occurrence compute elapsed time.
LastMetricEvent {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
/// Marker for the end of the whole range. Nothing sorts after this.
End,
}
-651
View File
@@ -1,651 +0,0 @@
//! Fast, tolerant `key=value`/`key="value"` tokenizer (logfmt-ish), used by
//! `crate::engine::auto_declare_params` to discover param names as lines are ingested. Scans
//! anywhere in the line rather than requiring the whole line to be logfmt, so it works on lines
//! with an arbitrary non-logfmt prefix/suffix (e.g. `ts=... msg="..." x1=-4 y1=34`).
use nom::branch::alt;
use nom::bytes::complete::{escaped, tag, take_until, take_while1};
use nom::character::complete::{char, none_of, one_of};
use nom::error::{Error, ErrorKind};
use nom::sequence::delimited;
use nom::IResult;
use regex::Regex;
use std::sync::LazyLock;
fn key(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')(input)
}
fn quoted_value(input: &str) -> IResult<&str, &str> {
delimited(
char('"'),
alt((escaped(none_of("\\\""), '\\', one_of("\"\\nt")), nom::combinator::success(""))),
char('"'),
)(input)
}
fn bareword_value(input: &str) -> IResult<&str, &str> {
take_while1(|c: char| !c.is_whitespace())(input)
}
/// A bareword value starting with a bare `=` means the raw text actually had `key==...`/`key===...` —
/// never a real logfmt pair. This is exactly what `pair` would otherwise produce when it lands
/// mid-way through a base64/hash blob whose padding (`=`/`==`) happens to sit right after a run of
/// alnum chars that `key()` greedily consumed — e.g. the `huig_dump.txt` fixture's
/// `<mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>,`: `key()` eats `rKTEtG7JmnThhoKPbZiVrA`, the first `=` is
/// consumed as the pair's `=`, leaving a bareword value of `='>,` (ticket 050). Rejected here rather
/// than post-filtered by a caller so `extract_pairs` never reports the false positive to anyone.
fn looks_like_base64_padding_artifact(value: &str) -> bool {
value.starts_with('=')
}
/// One `key=value` pair found on a line: the key, the raw captured value (unescaped as-is, quotes
/// stripped but any inner `\"`/`\\` left literal since callers only need it for numeric-kind
/// inference), and whether the value was quoted.
fn pair(input: &str) -> IResult<&str, (&str, &str, bool)> {
let (input, k) = key(input)?;
let (input, _) = char('=')(input)?;
if let Ok((rest, v)) = quoted_value(input) {
Ok((rest, (k, v, true)))
} else {
let (rest, v) = bareword_value(input)?;
if looks_like_base64_padding_artifact(v) {
return Err(nom::Err::Error(Error::new(input, ErrorKind::Verify)));
}
Ok((rest, (k, v, false)))
}
}
/// Finds every `key=value`/`key="value"` pair anywhere in `line`. A position that doesn't start a
/// pair is skipped one char at a time, so this never fails outright — a line with no pairs at all
/// (plain prose, a JSON blob) just returns an empty vec.
pub fn extract_pairs(line: &str) -> Vec<(&str, &str, bool)> {
let mut results = Vec::new();
let mut rest = line;
while !rest.is_empty() {
match pair(rest) {
Ok((remaining, found)) => {
results.push(found);
rest = remaining;
}
Err(_) => {
let mut chars = rest.chars();
chars.next();
rest = chars.as_str();
}
}
}
results
}
/// Fixed-column shape of macOS `log stream` output (ticket 050/054): a line starts with
/// `Timestamp Thread Type Activity PID TTL` (date, time+tz, a hex thread id, a bareword type, a hex
/// activity id, a decimal pid, a decimal ttl) followed by a free-text `process: message` tail — see
/// `huig_dump.txt`'s header row and body. Anchored at the very start of the line and requires the
/// hex/decimal shape of each column, so it won't accidentally fire on ordinary logfmt/JSON/prose that
/// merely resembles it in one column. Checked ahead of JSON/logfmt in `detect_format` since it's the
/// most specific of the three (an all-columns-plus-hex-ids match is a much stronger signal than an
/// incidental `key=value` substring), and is deliberately a *format label* rather than a dedicated
/// `ottyl_fmt` value change to JSON/logfmt detection — a `log stream` line's message tail can still
/// separately contain real `key=value` pairs (see the WiFi/radio stat lines in the fixture), which
/// `crate::engine::auto_declare_params` keeps discovering via `extract_pairs` regardless of this
/// line's `ottyl_fmt` label.
static LOG_STREAM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\d{4}-\d{2}-\d{2}\s+\S+\s+0x[0-9a-fA-F]+\s+\S+\s+0x[0-9a-fA-F]+\s+\d+\s+\d+\s+[^\s:]+:\s",
)
.expect("static log_stream regex must compile")
});
pub fn is_log_stream_line(line: &str) -> bool {
LOG_STREAM_RE.is_match(line)
}
/// Regex fragment for one column separator under the generic whitespace-columnar rule ticket 059
/// asks for: a run of 2+ whitespace chars, or a lone tab, separates columns; a single plain space does
/// not (so a value that legitimately contains one -- e.g. `log stream`'s `date time+tz` timestamp --
/// stays intact within its own column). `is_separator_run` is the same rule applied at split time;
/// this is that rule re-expressed as a regex fragment for the trailing message field's
/// `message_match_regex`, which re-derives its value the same way whenever a caller queries by it
/// later (declarations don't cache extracted values -- see `crate::engine::extract_param_raw`).
const COLUMN_SEPARATOR_PATTERN: &str = r"(?:\t|[ \t]{2,})";
/// A column's value: one or more non-whitespace tokens joined by single spaces, with no upper bound
/// on length. Used for the trailing (unbounded-width) message field and the last named column, where
/// there's no next column's start position to bound against.
const COLUMN_VALUE_PATTERN: &str = r"\S+(?: \S+)*";
/// Same idea as `COLUMN_VALUE_PATTERN`, but bounded to a known column `width` (in chars) -- needed for
/// every column except the last, since two adjacent `log stream` values can legitimately be separated
/// by only a single space when the first one nearly fills its column's on-screen width (e.g. the
/// `Timestamp` column: its own `date time+tz` value already contains one internal space, so a
/// content-only pattern can't tell that boundary apart from the one-space gap before the next column
/// starts -- seed this from `LogStreamColumn::start` positions instead). Greedily matches up to
/// `width` chars while requiring the match to end on a non-whitespace char, which is what excludes the
/// column's own trailing padding spaces from the capture.
fn bounded_column_value_pattern(width: usize) -> String {
if width <= 1 {
r"\S".to_string()
} else {
format!(r"\S(?:.{{0,{}}}\S)?", width - 2)
}
}
fn is_separator_run(run: &str) -> bool {
run.len() >= 2 || run.contains('\t')
}
static WHITESPACE_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]+").expect("static regex must compile"));
/// Splits `line` at whitespace runs that qualify as a column separator (`is_separator_run`), returning
/// each piece alongside its byte offset in `line`; a lone plain space never splits. Consumes at most
/// `max_columns.map(|n| n - 1)` separators -- the final piece returned is the untouched remainder of
/// the line, including any further whitespace runs inside it. That's what lets a trailing unlabelled
/// `log stream` message tail (whose own internal spacing can be arbitrary) come back as one field
/// instead of being chopped up by it.
fn split_columns_with_offsets(line: &str, max_columns: Option<usize>) -> Vec<(usize, &str)> {
let mut columns = Vec::new();
let mut start = 0usize;
let mut consumed = 0usize;
for m in WHITESPACE_RUN.find_iter(line) {
if max_columns.is_some_and(|max| consumed + 1 >= max) {
break;
}
let run = &line[m.start()..m.end()];
if is_separator_run(run) {
columns.push((start, &line[start..m.start()]));
start = m.end();
consumed += 1;
}
}
columns.push((start, &line[start..]));
columns
}
fn split_columns(line: &str, max_columns: Option<usize>) -> Vec<&str> {
split_columns_with_offsets(line, max_columns).into_iter().map(|(_, s)| s).collect()
}
/// A column-name identifier a generated `message_match_regex`'s named capture group can use as-is
/// (the regex crate requires `[A-Za-z_][0-9A-Za-z_]*`).
static COLUMN_NAME: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_]*$").expect("static regex must compile"));
/// One column named by a detected `log stream` header (ticket 059). `start` is that column's position
/// in `char`s (not bytes -- see `char_slice`) within the header line -- meaningful only when
/// `LogStreamHeader::tab_delimited` is `false` (see that field's doc comment).
pub struct LogStreamColumn {
pub name: String,
start: usize,
}
/// A detected `log stream` header: its named columns, plus whether they were tab- or space-separated.
/// `log stream`'s real output right-pads each header name to a fixed on-screen width using runs of
/// 2+ spaces, and every data line's values land in that same fixed alignment regardless of how much
/// of that width they individually fill -- so for that shape, a data line's column boundaries are
/// best found at the header's own fixed character positions, not by re-splitting the data line's own
/// whitespace (see `split_log_stream_row`'s doc comment for why that alone isn't reliable there).
/// A tab-separated header carries no such alignment guarantee (values aren't padded to a shared
/// width), but a lone tab is itself an unambiguous separator, so that case instead re-splits each data
/// line directly.
pub struct LogStreamHeader {
pub columns: Vec<LogStreamColumn>,
tab_delimited: bool,
}
/// Byte-indexed `str` slicing doesn't work directly with `LogStreamColumn::start` (a *char* offset),
/// since `log stream` output is expected to stay ASCII in practice but callers shouldn't panic if a
/// message contains multi-byte chars before the slice point. `end` of `None` slices to end of string.
fn char_slice(s: &str, start: usize, end: Option<usize>) -> &str {
if end.is_some_and(|end| end <= start) {
return "";
}
let mut indices = s.char_indices();
let byte_start = indices.nth(start).map(|(i, _)| i).unwrap_or(s.len());
let byte_end = match end {
Some(end) => s.char_indices().nth(end).map(|(i, _)| i).unwrap_or(s.len()),
None => s.len(),
};
&s[byte_start..byte_end.max(byte_start)]
}
/// Recognizes a `log stream`-style header row generically (ticket 059), instead of assuming the fixed
/// `Timestamp Thread Type Activity PID TTL` names: a short run of bareword column names separated by
/// qualifying whitespace runs (`split_columns_with_offsets`) that is, critically, *not itself* a
/// `log stream` data line (`is_log_stream_line`) -- so a header is never mistaken for the data line
/// immediately following it. `crate::engine::auto_declare_log_stream_params` caches the result across
/// ingested lines, since `log stream` prints its header once, before any data line, and this only ever
/// sees one line at a time.
pub fn detect_log_stream_header(line: &str) -> Option<LogStreamHeader> {
if is_log_stream_line(line) {
return None;
}
let mut columns = Vec::new();
for (byte_start, slice) in split_columns_with_offsets(line, None) {
let trimmed = slice.trim();
if trimmed.is_empty() {
continue;
}
let leading_ws = slice.len() - slice.trim_start().len();
let start = line[..byte_start + leading_ws].chars().count();
columns.push(LogStreamColumn { name: trimmed.to_string(), start });
}
if columns.len() < 2 || !columns.iter().all(|c| COLUMN_NAME.is_match(&c.name)) {
return None;
}
let tab_delimited = WHITESPACE_RUN.find_iter(line).any(|m| m.as_str().contains('\t'));
Some(LogStreamHeader { columns, tab_delimited })
}
/// Splits a `log stream` data line into `header`'s named columns plus, when present, one trailing
/// unlabelled tail -- `crate::engine::auto_declare_log_stream_params` maps the first
/// `header.columns.len()` pieces onto the header's names and the optional last piece onto the
/// `message` param.
///
/// For a space-padded fixed-width header, every column but the last is sliced at the header's own
/// fixed character positions (`LogStreamColumn::start`) rather than by re-splitting the data line's
/// own whitespace, since two adjacent values can legitimately be joined by only a single space when
/// the first nearly fills its column's width (e.g. `log stream`'s `Timestamp` column, whose value
/// already contains one internal space). Only the last named column's end is whitespace-run-detected
/// (`split_columns`), since that's what distinguishes its own value from the free-text message tail
/// `log stream` appends after it.
///
/// For a tab-delimited header, there's no shared on-screen width to align against, but a lone tab is
/// itself unambiguous, so this instead re-splits the data line directly on qualifying separators.
pub fn split_log_stream_row<'a>(line: &'a str, header: &LogStreamHeader) -> Vec<&'a str> {
if header.tab_delimited {
return split_columns(line, Some(header.columns.len() + 1));
}
let mut result = Vec::with_capacity(header.columns.len() + 1);
for (i, column) in header.columns.iter().enumerate() {
match header.columns.get(i + 1) {
Some(next) => result.push(char_slice(line, column.start, Some(next.start)).trim()),
None => {
let tail = char_slice(line, column.start, None);
let pieces = split_columns(tail, Some(2));
result.push(pieces[0].trim());
if let Some(message) = pieces.get(1) {
result.push(message);
}
}
}
}
result
}
/// Builds the `message_match_regex` for the `index`-th (0-based) of `header`'s named columns.
///
/// Fixed-width case: skips exactly `header.columns[index].start` chars, then captures this column's
/// own value bounded to its known width (`bounded_column_value_pattern`) -- unbounded for the last
/// column, which has no next column to bound against.
///
/// Tab-delimited case: skips `index` preceding columns' own value+separator pairs instead, since
/// there's no shared fixed width to anchor a character-position skip against.
///
/// Anchored at the start of the line since a `log stream` line never carries this fixed-per-run
/// column prefix anywhere but the very start.
pub fn log_stream_column_pattern(header: &LogStreamHeader, index: usize, capture_group: &str) -> String {
if header.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..index {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>{COLUMN_VALUE_PATTERN})"));
return pattern;
}
let start = header.columns[index].start;
let value_pattern = match header.columns.get(index + 1) {
Some(next) => bounded_column_value_pattern(next.start.saturating_sub(start)),
None => COLUMN_VALUE_PATTERN.to_string(),
};
format!("^.{{{start}}}(?P<{capture_group}>{value_pattern})")
}
/// Builds the `message_match_regex` for the trailing unlabelled tail after `header`'s last named
/// column: everything from the end of that column's own separator to end of line, verbatim -- its own
/// internal whitespace, however irregular, is never treated as a further separator.
pub fn log_stream_message_pattern(header: &LogStreamHeader, capture_group: &str) -> String {
if header.tab_delimited {
let mut pattern = String::from("^");
for _ in 0..header.columns.len() {
pattern.push_str(COLUMN_VALUE_PATTERN);
pattern.push_str(COLUMN_SEPARATOR_PATTERN);
}
pattern.push_str(&format!("(?P<{capture_group}>.*)$"));
return pattern;
}
let start = header.columns.last().map(|c| c.start).unwrap_or(0);
format!("^.{{{start}}}{COLUMN_VALUE_PATTERN}{COLUMN_SEPARATOR_PATTERN}(?P<{capture_group}>.*)$")
}
/// Go `log/slog` `NewJSONHandler` output (the `go-log` label): a single-line JSON object whose first
/// three fields are always `time`, `level`, `msg` in that fixed order (slog's `JSONHandler` writes the
/// built-in attrs before any caller-supplied ones), followed by zero or more arbitrary attrs and a
/// closing `}`. Parsed with nom rather than a full JSON decode since only that fixed prefix shape
/// needs verifying; the variable tail is deliberately not parsed field-by-field the way
/// `crate::engine::auto_declare_params` will separately pick up JSON leaf fields from the line.
/// Checked ahead of the generic JSON object check in `detect_format` since it's the more specific of
/// the two (see that function's doc comment for the general ordering rationale).
fn go_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str)> {
let (input, _) = tag("{\"time\":\"")(input)?;
let (input, time) = take_until("\"")(input)?;
let (input, _) = tag("\",\"level\":\"")(input)?;
let (input, level) = take_until("\"")(input)?;
let (input, _) = tag("\",\"msg\":\"")(input)?;
let (input, msg) = take_until("\"")(input)?;
let (input, _) = char('"')(input)?;
Ok((input, (time, level, msg)))
}
pub fn is_go_log_line(text: &str) -> bool {
match go_log_prefix(text) {
Ok((rest, _)) => rest.trim_end().ends_with('}'),
Err(_) => false,
}
}
/// Python `logging` module output in the shape `"%(asctime)s %(levelname)s %(name)s %(message)s"`
/// (the `py-log` label): a `YYYY-MM-DD HH:MM:SS,mmm` timestamp, an uppercase level word, a logger
/// name, then a message tail that (for `delete-py-log`, and any caller using `logger.info("event=%s
/// %s=%s", ...)`-style calls) is itself logfmt `key=value` pairs. Only the fixed prefix is parsed with
/// nom; the tail is handed to `extract_pairs` both to confirm the shape (a py-log line with no pairs
/// at all wouldn't be distinguishable from plain prose after this prefix) and because that's the same
/// extraction `crate::engine::auto_declare_params` needs anyway. Checked ahead of the generic logfmt
/// check in `detect_format` since it's the more specific of the two.
fn py_log_prefix(input: &str) -> IResult<&str, (&str, &str, &str, &str)> {
let (input, date) = take_while1(|c: char| c.is_ascii_digit() || c == '-')(input)?;
let (input, _) = char(' ')(input)?;
let (input, time) = take_while1(|c: char| c.is_ascii_digit() || c == ':' || c == ',')(input)?;
let (input, _) = char(' ')(input)?;
let (input, level) = take_while1(|c: char| c.is_ascii_uppercase())(input)?;
let (input, _) = char(' ')(input)?;
let (input, logger) =
take_while1(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')(input)?;
let (input, _) = char(' ')(input)?;
Ok((input, (date, time, level, logger)))
}
pub fn is_py_log_line(text: &str) -> bool {
match py_log_prefix(text) {
Ok((rest, _)) => !extract_pairs(rest).is_empty(),
Err(_) => false,
}
}
/// The value of the `ottyl_fmt` reserved param (`crate::engine::compile_filter`/`eval_alert_expr`):
/// which format a line was recognized as when parsed. Checked most-specific-first: `log stream`'s
/// fixed-column shape (see `LOG_STREAM_RE`'s doc comment for why it's the most specific), then the two
/// named-tool shapes `go-log` (a `time`/`level`/`msg`-prefixed JSON object) and `py-log` (a
/// timestamp/level/logger prefix over logfmt pairs) — each is a strict subset of the generic JSON/logfmt
/// case checked right after it, so they'd otherwise never be reached. JSON is checked next since a JSON
/// object can incidentally contain `key=value`-shaped substrings that `extract_pairs` would otherwise
/// pick up; only a top-level object counts (arrays/scalars aren't what `crate::engine::eval_json_comparison`
/// treats as JSON either). Anything else falls back to logfmt-style pair extraction, and a line with
/// none of the above is `"none"`.
pub fn detect_format(text: &str) -> &'static str {
if is_log_stream_line(text) {
"log_stream"
} else if is_go_log_line(text) {
"go-log"
} else if is_py_log_line(text) {
"py-log"
} else if serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_object()) {
"json"
} else if !extract_pairs(text).is_empty() {
"logfmt"
} else {
"none"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_quoted_value() {
let pairs = extract_pairs(r#"msg="entity_moved""#);
assert_eq!(pairs, vec![("msg", "entity_moved", true)]);
}
#[test]
fn extracts_unquoted_bareword_value() {
let pairs = extract_pairs("x1=-4");
assert_eq!(pairs, vec![("x1", "-4", false)]);
}
#[test]
fn extracts_mixed_quoted_and_unquoted_pairs_in_one_line() {
let pairs = extract_pairs(r#"ts=1788648105773 msg="entity_moved" x1=-4 y1=34"#);
assert_eq!(
pairs,
vec![
("ts", "1788648105773", false),
("msg", "entity_moved", true),
("x1", "-4", false),
("y1", "34", false),
]
);
}
#[test]
fn returns_empty_for_lines_with_no_pairs() {
assert!(extract_pairs("just some plain prose here").is_empty());
assert!(extract_pairs(r#"{"foo": {"bar": 42}}"#).is_empty());
}
#[test]
fn does_not_panic_on_empty_value_at_end_of_line() {
assert_eq!(extract_pairs("key="), Vec::<(&str, &str, bool)>::new());
assert_eq!(extract_pairs(r#"key="""#), vec![("key", "", true)]);
}
#[test]
fn keeps_escaped_quote_inside_quoted_value() {
let pairs = extract_pairs(r#"msg="he said \"hi\"""#);
assert_eq!(pairs, vec![("msg", r#"he said \"hi\""#, true)]);
}
#[test]
fn detects_json_object_lines_as_json() {
assert_eq!(detect_format(r#"{"level":"error","code":500}"#), "json");
}
#[test]
fn detects_key_value_lines_as_logfmt() {
assert_eq!(detect_format(r#"ts=1788648105773 msg="entity_moved" x1=-4"#), "logfmt");
}
#[test]
fn detects_plain_prose_as_none() {
assert_eq!(detect_format("just some plain prose here"), "none");
}
#[test]
fn a_bare_json_array_or_scalar_is_not_treated_as_json() {
// Not an object, so falls through to logfmt/none like `eval_json_comparison` would treat it.
assert_eq!(detect_format("[1,2,3]"), "none");
assert_eq!(detect_format("42"), "none");
}
#[test]
fn rejects_base64_padding_fragment_as_a_pair() {
// The exact false-positive shape from ticket 050's fixture: `key()` greedily consumes the
// tail of the base64 blob as if it were a key, landing right before the `==` padding.
let pairs = extract_pairs(
"qname: <mask.hash: 'rKTEtG7JmnThhoKPbZiVrA=='>, qtype: A, client pid: 12260 (firefox)",
);
assert!(
pairs.iter().all(|(k, _, _)| *k != "rKTEtG7JmnThhoKPbZiVrA"),
"base64 padding fragment must not be extracted as a pair: {pairs:?}"
);
}
const LOG_STREAM_FIXTURE: &str = include_str!("fixtures/log_stream_sample.txt");
#[test]
fn detects_log_stream_shaped_lines() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert_eq!(detect_format(data_line), "log_stream");
assert!(is_log_stream_line(data_line));
}
#[test]
fn header_row_is_not_detected_as_log_stream() {
let header = LOG_STREAM_FIXTURE.lines().next().unwrap();
assert!(!is_log_stream_line(header));
}
fn column_names(header: &LogStreamHeader) -> Vec<&str> {
header.columns.iter().map(|c| c.name.as_str()).collect()
}
#[test]
fn detects_log_stream_header_columns_generically() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).expect("fixture header must be detected");
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type", "Activity", "PID", "TTL"]);
assert!(!header.tab_delimited);
}
#[test]
fn a_log_stream_data_line_is_never_detected_as_its_own_header() {
let data_line = LOG_STREAM_FIXTURE.lines().nth(1).unwrap();
assert!(detect_log_stream_header(data_line).is_none());
}
#[test]
fn plain_prose_is_not_detected_as_a_log_stream_header() {
assert!(detect_log_stream_header("just some plain prose here").is_none());
assert!(detect_log_stream_header("ts=1788648105773 msg=\"entity_moved\"").is_none());
}
#[test]
fn splits_log_stream_row_into_named_columns_plus_trailing_message() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).unwrap();
let data_line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let row = split_log_stream_row(data_line, &header);
assert_eq!(row.len(), header.columns.len() + 1);
assert_eq!(row[0], "2026-09-06 01:48:54.518117+0100");
assert_eq!(row[1], "0x4dfed2");
assert_eq!(row[2], "Default");
assert_eq!(row[3], "0x0");
assert_eq!(row[4], "606");
assert_eq!(row[5], "0");
// The message tail is never chopped up by its own internal single/double spacing.
assert_eq!(row[6], "WindowServer: (HSTouchHIDService) something happened");
}
#[test]
fn a_single_tab_separates_columns_exactly_like_a_run_of_spaces() {
let header = detect_log_stream_header("Timestamp\tThread\tType").unwrap();
assert_eq!(column_names(&header), vec!["Timestamp", "Thread", "Type"]);
assert!(header.tab_delimited);
// Two tabs in a row still qualify as a single separator (like a run of 2+ spaces), so this
// still cleanly separates Timestamp/Thread/Type despite the doubled tab before "Default".
let spaced = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\t\tDefault trailing message", &header);
let tabbed = split_log_stream_row("2026-09-06 01:48:54\t0x4dfed2\tDefault trailing message", &header);
assert_eq!(spaced[0], "2026-09-06 01:48:54");
assert_eq!(spaced[1], "0x4dfed2");
assert_eq!(spaced[2], "Default trailing message");
assert_eq!(tabbed[0], "2026-09-06 01:48:54");
assert_eq!(tabbed[1], "0x4dfed2");
assert_eq!(tabbed[2], "Default trailing message");
}
#[test]
fn column_and_message_patterns_capture_the_same_fields_split_log_stream_row_finds() {
let header_line = LOG_STREAM_FIXTURE.lines().next().unwrap();
let header = detect_log_stream_header(header_line).unwrap();
let line = "2026-09-06 01:48:54.518117+0100 0x4dfed2 Default 0x0 606 0 WindowServer: (HSTouchHIDService) something happened";
let type_re = Regex::new(&log_stream_column_pattern(&header, 2, "Type")).unwrap();
assert_eq!(type_re.captures(line).unwrap().name("Type").unwrap().as_str(), "Default");
let pid_re = Regex::new(&log_stream_column_pattern(&header, 4, "PID")).unwrap();
assert_eq!(pid_re.captures(line).unwrap().name("PID").unwrap().as_str(), "606");
let message_re = Regex::new(&log_stream_message_pattern(&header, "message")).unwrap();
assert_eq!(
message_re.captures(line).unwrap().name("message").unwrap().as_str(),
"WindowServer: (HSTouchHIDService) something happened"
);
// Also exercise the Timestamp column specifically -- its own value contains one internal
// space, so a naive content-only pattern would previously over-consume into the next column.
let timestamp_re = Regex::new(&log_stream_column_pattern(&header, 0, "Timestamp")).unwrap();
assert_eq!(
timestamp_re.captures(line).unwrap().name("Timestamp").unwrap().as_str(),
"2026-09-06 01:48:54.518117+0100"
);
}
#[test]
fn a_wifi_stat_dump_line_is_still_recognized_as_log_stream_despite_embedded_key_value_pairs() {
let line = LOG_STREAM_FIXTURE
.lines()
.find(|line| line.contains("txRTSFrm="))
.expect("fixture must contain the WiFi stat dump line");
assert_eq!(detect_format(line), "log_stream");
// The embedded key=value pairs are still there for auto_declare_params to see.
assert!(!extract_pairs(line).is_empty());
}
const GO_LOG_FIXTURE: &str = include_str!("fixtures/go_log_sample.txt");
const PY_LOG_FIXTURE: &str = include_str!("fixtures/py_log_sample.txt");
#[test]
fn detects_go_log_shaped_lines() {
for line in GO_LOG_FIXTURE.lines() {
assert!(is_go_log_line(line), "expected go-log line to match: {line}");
assert_eq!(detect_format(line), "go-log");
}
}
#[test]
fn detects_py_log_shaped_lines() {
for line in PY_LOG_FIXTURE.lines() {
assert!(is_py_log_line(line), "expected py-log line to match: {line}");
assert_eq!(detect_format(line), "py-log");
}
}
#[test]
fn a_generic_json_object_is_not_detected_as_go_log() {
let line = r#"{"level":"error","code":500}"#;
assert!(!is_go_log_line(line));
assert_eq!(detect_format(line), "json");
}
#[test]
fn a_generic_logfmt_line_is_not_detected_as_py_log() {
let line = r#"ts=1788648105773 msg="entity_moved" x1=-4"#;
assert!(!is_py_log_line(line));
assert_eq!(detect_format(line), "logfmt");
}
#[test]
fn go_log_prefix_extracts_time_level_and_msg() {
let (rest, (time, level, msg)) =
go_log_prefix(r#"{"time":"2026-09-06T10:35:34.499921+01:00","level":"WARN","msg":"eventC","labelC":1}"#)
.unwrap();
assert_eq!(time, "2026-09-06T10:35:34.499921+01:00");
assert_eq!(level, "WARN");
assert_eq!(msg, "eventC");
assert_eq!(rest, r#","labelC":1}"#);
}
#[test]
fn py_log_prefix_extracts_date_time_level_and_logger() {
let (rest, (date, time, level, logger)) =
py_log_prefix("2026-09-06 10:35:55,217 INFO delete-py-log event=eventC labelC=1").unwrap();
assert_eq!(date, "2026-09-06");
assert_eq!(time, "10:35:55,217");
assert_eq!(level, "INFO");
assert_eq!(logger, "delete-py-log");
assert_eq!(rest, "event=eventC labelC=1");
}
}
+4 -240
View File
@@ -1,246 +1,10 @@
mod alerts;
mod api; mod api;
mod data_value; mod cli;
mod dto; mod config;
mod consts;
mod engine; mod engine;
mod filter_datetime;
mod filter_parser;
mod index_key;
mod ingest;
mod logfmt;
mod mcp_server;
mod metric_primitive;
mod profile;
mod promql;
mod rate;
mod sessions;
mod settings;
mod timestamp;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use settings::Settings;
/// The resolved default settings path, rendered into `--help` so `--settings-path`'s help text
/// names a real, concrete location instead of only describing where to look for one.
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Path to the settings file. Defaults to {}.",
settings::default_settings_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "a per-OS config location".to_string())
)
});
/// Observability dashboard from terminal output.
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
/// Port to serve the API and webapp on. Defaults to 0, letting the OS assign
/// an ephemeral free port.
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like `tail -f`.
#[arg(long)]
file: Option<PathBuf>,
/// URL of a Prometheus/OpenMetrics-compatible endpoint to poll for metrics, in addition to log ingestion.
#[arg(long)]
metrics_url: Option<String>,
/// Name to register this session under, so `ottyl mcp --session <name>` or `ottyl sessions`
/// can find it later. Defaults to a random human-readable name (e.g. "comic-gazelle").
/// Starting a second instance under the same name as one still running overwrites its
/// registration (the older process keeps running, just without a discoverable session entry).
#[arg(long)]
name: Option<String>,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Start an MCP (Model Context Protocol) server over stdio, proxying tool calls to a running
/// ottyl instance's HTTP API. Register the printed command with an MCP client (e.g. Claude
/// Code's `.mcp.json`).
Mcp {
/// Name of a running ottyl session to connect to (see `ottyl sessions`).
#[arg(long)]
session: Option<String>,
/// Port of a running ottyl instance to connect to, instead of resolving `--session` by name.
#[arg(long)]
port: Option<u16>,
},
/// List known ottyl sessions and the ports they're serving on.
Sessions,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = Args::parse(); cli::run().await;
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
settings::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
match args.command {
Some(Command::Mcp { session, port }) => {
run_mcp(&settings_path, session, port).await;
return;
}
Some(Command::Sessions) => {
print_sessions(&settings_path);
return;
}
None => {}
}
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let (engine_tx, engine_rx) = mpsc::channel(1024);
tokio::spawn(engine::run(engine_rx, settings.clone()));
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
// Reload the active profile's declarations before ingest starts: there's no way to
// reprocess early lines later, so declaring after ingest starts could miss lines that raced ahead.
{
let (active_profile_name, active_profile) = {
let mut settings_guard = settings.lock().unwrap();
settings_guard
.ensure_default_profile(&settings_path)
.expect("failed to ensure the Default profile exists");
let active_profile_name = settings_guard.active_profile.clone();
let active_profile = active_profile_name
.as_deref()
.and_then(|name| settings_guard.profile(name).cloned());
(active_profile_name, active_profile)
};
match (active_profile_name, active_profile) {
(Some(_), Some(active_profile)) => {
profile::load_into_engine(&engine_tx, &active_profile).await;
}
(Some(profile_name), None) => {
eprintln!(
"warning: active profile '{profile_name}' not found in {}; skipping",
settings_path.display()
);
}
(None, _) => {}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(ingest::run(engine_tx.clone()));
}
}
if let Some(metrics_url) = args.metrics_url.clone() {
tokio::spawn(ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
}
let app = api::router(engine_tx, settings, settings_path.clone());
let listener = TcpListener::bind(("0.0.0.0", args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
match sessions::register_session(&settings_path, &session_name, bound_port) {
Ok(_) => eprintln!(
"ottyl serving on http://localhost:{} ({}), session name: {}",
bound_port, source, session_name
),
Err(err) => eprintln!(
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
bound_port, source, session_name, err
),
}
axum::serve(listener, app)
.await
.expect("server error");
}
/// Resolves `--session`/`--port` into a target base URL and runs the MCP server over stdio until
/// the client disconnects.
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
use rmcp::ServiceExt;
let port = match (port, session) {
(Some(port), _) => port,
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
Some(info) => info.port,
None => {
eprintln!("error: no running ottyl session named '{name}'");
std::process::exit(1);
}
},
(None, None) => {
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
std::process::exit(1);
}
};
let base_url = format!("http://localhost:{port}");
let service = mcp_server::OttylMcp::new(base_url)
.serve(rmcp::transport::stdio())
.await
.expect("failed to start MCP server");
service.waiting().await.expect("MCP server error");
}
fn print_sessions(settings_path: &std::path::Path) {
let dir = sessions::sessions_dir(settings_path);
let sessions = sessions::list_sessions(&dir);
if sessions.is_empty() {
println!("no known ottyl sessions");
return;
}
for session in sessions {
println!("{}\tport={}\tpid={}", session.name, session.port, session.pid);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
} }
@@ -0,0 +1,43 @@
# 070 — CLI functionality needs to be greater — scope this out
## Summary
Wishlist feedback: "CLI functionality needs to be greater - todo
fleshout." This is a scoping ticket, not an implementation one — the
request as given doesn't say which CLI surface is thin (flags to
`ottyl` itself vs. profile/dashboard management vs. something
scriptable/headless) or what "greater" should mean in practice.
Current CLI surface, for reference: `main.rs`'s arg parsing wires
stdin/`--file` tailing, `--metrics-url` polling, and profile
selection/loading (see `settings.rs`/`profile.rs`) into engine startup.
Anything beyond piping a command and viewing the dashboard in a browser
(inspecting state, managing profiles, exporting data, scripting against
a running instance) has no CLI path today — it's all through the web
UI's API calls.
## Scope
- Talk to Hugh about what's actually missing before writing an
implementation ticket. Candidate directions worth raising:
- A way to inspect current state (params/metrics/alert rules, log
counts) from the terminal without opening the browser.
- Profile management from the CLI (list/create/switch profiles)
rather than only through the web UI's settings.
- A non-interactive/headless mode for CI-type use (e.g. run for N
seconds against piped input, dump a summary, exit) — distinct from
today's "serve until the piped process exits" model.
- Flags to declare params/metrics/dashboards at launch without
hand-editing the profile file first.
- Once scoped, split into one or more concrete tickets with specific
acceptance criteria — this ticket should be closed by replacement,
not by a partial implementation guessing at the ask.
## Acceptance criteria
- N/A — this ticket's output is a conversation and a follow-up
ticket (or tickets), not code.
## Depends on
None.
+29
View File
@@ -69,6 +69,23 @@ function saveLogViewerHeight(px: number) {
} }
} }
// Ticket 074: a sort helper index's one-time full-scan build (`build_sort_helper_index`) runs
// inline in the engine actor, so a `sort_by` poll can occasionally take much longer than the usual
// sub-poll-interval round trip — without this, that stall looks exactly like "sorting is broken"
// rather than "still indexing". Only flips `setBuilding(true)` if the fetch is still in flight after
// `thresholdMs`, so an ordinary fast poll (helper index already built/incrementally maintained)
// never flashes the indicator.
const SORT_BUILDING_INDICATOR_THRESHOLD_MS = 400
function trackSlowSortedFetch<T>(sortBy: string | null, setBuilding: (building: boolean) => void, promise: Promise<T>): Promise<T> {
if (!sortBy) return promise
const timer = setTimeout(() => setBuilding(true), SORT_BUILDING_INDICATOR_THRESHOLD_MS)
return promise.finally(() => {
clearTimeout(timer)
setBuilding(false)
})
}
/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move /// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move
/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so /// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so
/// the final size can be persisted once, not on every pixel of movement. /// the final size can be persisted once, not on every pixel of movement.
@@ -286,6 +303,7 @@ function StandaloneLog() {
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null) const [filterError, setFilterError] = useState<string | null>(null)
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings) const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
function handleSortSettingsChange(next: LogSortSettings) { function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next) setSortSettings(next)
@@ -341,6 +359,9 @@ function StandaloneLog() {
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined, bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined, filter: filter || undefined,
}), }),
trackSlowSortedFetch(
sortSettings.sortBy,
setSortIndexBuilding,
fetchLogs({ fetchLogs({
startMs: effectiveRange?.startMs, startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs, endMs: effectiveRange?.endMs,
@@ -349,6 +370,7 @@ function StandaloneLog() {
sortBy: sortSettings.sortBy ?? undefined, sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined, sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}), }),
),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit. // Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(), fetchParamDeclarations(),
]) ])
@@ -393,6 +415,7 @@ function StandaloneLog() {
paramNames={params.map((p) => p.name)} paramNames={params.map((p) => p.name)}
sortSettings={sortSettings} sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange} onSortSettingsChange={handleSortSettingsChange}
sortIndexBuilding={sortIndexBuilding}
/> />
<LogPreview logs={logs} params={params} /> <LogPreview logs={logs} params={params} />
</div> </div>
@@ -423,6 +446,7 @@ function FullApp() {
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer') const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight()) const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings) const [sortSettings, setSortSettings] = useState<LogSortSettings>(loadLogSortSettings)
const [sortIndexBuilding, setSortIndexBuilding] = useState(false)
function handleSortSettingsChange(next: LogSortSettings) { function handleSortSettingsChange(next: LogSortSettings) {
setSortSettings(next) setSortSettings(next)
@@ -497,6 +521,9 @@ function FullApp() {
bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined, bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined,
filter: filter || undefined, filter: filter || undefined,
}), }),
trackSlowSortedFetch(
sortSettings.sortBy,
setSortIndexBuilding,
fetchLogs({ fetchLogs({
startMs: effectiveRange?.startMs, startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs, endMs: effectiveRange?.endMs,
@@ -505,6 +532,7 @@ function FullApp() {
sortBy: sortSettings.sortBy ?? undefined, sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined, sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}), }),
),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit. // Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(), fetchParamDeclarations(),
]) ])
@@ -566,6 +594,7 @@ function FullApp() {
paramNames={params.map((p) => p.name)} paramNames={params.map((p) => p.name)}
sortSettings={sortSettings} sortSettings={sortSettings}
onSortSettingsChange={handleSortSettingsChange} onSortSettingsChange={handleSortSettingsChange}
sortIndexBuilding={sortIndexBuilding}
/> />
<LogPreview logs={logs} params={params} /> <LogPreview logs={logs} params={params} />
</div> </div>
+65 -32
View File
@@ -78,41 +78,62 @@ const SECTIONS: DocSection[] = [
id: 'filtering', id: 'filtering',
title: 'Filtering', title: 'Filtering',
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
<p> <p>
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
by <Code>and</Code>/<Code>or</Code>. Examples: by <Code>and</Code>/<Code>or</Code>. The server evaluates the filter string; a parse error shows as inline
text under the filter bar.
</p>
<div>
<p className="font-semibold text-text">Regex match</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error")'}</Code> — lines containing "error". See the <Code>@</Code> icon next to the
filter bar for the exact regex syntax supported (Rust's <Code>regex</Code> crate — no lookaround or
backreferences).
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Metric/param comparisons</p>
<ul className="list-disc pl-5">
<li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
<Code>duration</Code> param over 100. <Code>[param]</Code> comparisons only work against a{' '}
<em>declared metric's</em> labels, not a bare param declared on its own, and values are unquoted (no
surrounding <Code>"..."</Code>).
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Reserved names (always filterable)</p>
<p>
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are present on every log line
without declaring anything, and also appear (flagged <Code>reserved</Code>) in{' '}
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them.
</p> </p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <li>
<Code>{'regex("error")'}</Code> — lines containing "error". <Code>{'ottyl_seq<5'}</Code> — ingest sequence number under 5.
</li> </li>
<li> <li>
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5. <Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes (see Date/time formats below).
</li> </li>
<li> <li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '} <Code>{'ottyl_fmt==json'}</Code> — the line's auto-detected text format:{' '}
<Code>duration</Code> param over 100. <Code>log_stream</Code> (macOS <Code>log stream</Code> output — its columns are also auto-declared as
</li> their own named params, e.g. <Code>PID</Code>/<Code>Thread</Code>, from that line's own header), <Code>json</Code>,{' '}
<li> <Code>logfmt</Code>, or <Code>none</Code>.
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
</li>
<li>
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
</li> </li>
</ul> </ul>
<p> </div>
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are reserved metric names present
on every log line, so they're always filterable without declaring anything. <Code>ottyl_fmt</Code> holds the <div>
auto-detected format of the line's text — <Code>log_stream</Code> (macOS <Code>log stream</Code> output),{' '}
<Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>. A <Code>log_stream</Code> line also gets{' '}
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> params derived automatically from its
fixed-column header. All three reserved names also appear (flagged <Code>reserved</Code>) in{' '}
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them — see also{' '}
<em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel query.
</p>
<p className="font-semibold text-text">Date/time formats</p> <p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept three forms:</p> <p>Timestamp comparisons (e.g. against <Code>ottyl_ts</Code>) accept three forms:</p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <li>
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago) relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
@@ -124,9 +145,22 @@ const SECTIONS: DocSection[] = [
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code> full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
</li> </li>
</ul> </ul>
<p> </div>
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
</p> <div>
<p className="font-semibold text-text">Combining clauses</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
</li>
<li>
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code> — 5xx responses on one path, given a{' '}
<Code>requests</Code> metric with a <Code>path</Code> label param.
</li>
</ul>
</div>
<div>
<p className="font-semibold text-text">Worked examples</p> <p className="font-semibold text-text">Worked examples</p>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li> <li>
@@ -137,16 +171,15 @@ const SECTIONS: DocSection[] = [
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '} Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code> <Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
</li> </li>
<li>
5xx responses on one path, given a <Code>requests</Code> metric with a <Code>path</Code> label param —
note that <Code>[param]</Code> comparisons only work against a <em>declared metric's</em> labels, not a
bare param declared on its own, and values are unquoted (no surrounding <Code>"..."</Code>):{' '}
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
</li>
<li> <li>
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code> Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
</li> </li>
</ul> </ul>
<p className="mt-1 text-text-subtle">
See also <em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel
query.
</p>
</div>
</div> </div>
), ),
}, },
+5 -4
View File
@@ -41,6 +41,11 @@ export function FilterBar({
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<HelpIcon sectionId="filtering" label="Help: filter syntax" /> <HelpIcon sectionId="filtering" label="Help: filter syntax" />
<span className="text-xs text-text-subtle">Filter syntax</span> <span className="text-xs text-text-subtle">Filter syntax</span>
<span className="mx-0.5 h-4 w-px bg-border-soft" aria-hidden="true" />
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="text-xs text-text-faint" title="Cheat sheet for regex(...) clauses specifically">
regex(...)
</span>
</span> </span>
{collapseToggle} {collapseToggle}
<input <input
@@ -49,10 +54,6 @@ export function FilterBar({
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
/> />
<span className="flex items-center gap-1" title="Cheat sheet for regex(...) clauses specifically">
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="text-xs text-text-faint">regex(...)</span>
</span>
<button <button
type="submit" type="submit"
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2" className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2"
+9
View File
@@ -141,6 +141,7 @@ export function LogTimeline({
paramNames, paramNames,
sortSettings, sortSettings,
onSortSettingsChange, onSortSettingsChange,
sortIndexBuilding,
}: { }: {
buckets: TimelineBucket[] buckets: TimelineBucket[]
range: TimeRange | null range: TimeRange | null
@@ -151,6 +152,9 @@ export function LogTimeline({
paramNames?: string[] paramNames?: string[]
sortSettings?: LogSortSettings sortSettings?: LogSortSettings
onSortSettingsChange?: (next: LogSortSettings) => void onSortSettingsChange?: (next: LogSortSettings) => void
/** Ticket 074: set by the caller when a `sort_by` fetch has been in flight long enough that it's
* likely the engine is running `build_sort_helper_index`'s one-time full scan, not just stalled. */
sortIndexBuilding?: boolean
}) { }) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null) const [dragStartX, setDragStartX] = useState<number | null>(null)
@@ -224,6 +228,11 @@ export function LogTimeline({
{sortSettings && onSortSettingsChange && ( {sortSettings && onSortSettingsChange && (
<SortPicker paramNames={paramNames ?? []} settings={sortSettings} onChange={onSortSettingsChange} /> <SortPicker paramNames={paramNames ?? []} settings={sortSettings} onChange={onSortSettingsChange} />
)} )}
{sortIndexBuilding && (
<span className="text-text-muted" title="First sort by this param scans every ingested line once; later polls reuse the built index.">
Building sort index…
</span>
)}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{TIME_RANGE_PRESETS.map((preset) => ( {TIME_RANGE_PRESETS.map((preset) => (
+13
View File
@@ -41,6 +41,16 @@ function schemeLabel(scheme: ColorScheme): string {
return scheme.replace(/_/g, ' ') return scheme.replace(/_/g, ' ')
} }
/** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */
function formatIngestLag(ms: number): string {
if (ms < 1000) return `${ms}ms`
const totalSeconds = ms / 1000
if (totalSeconds < 60) return `${totalSeconds % 1 === 0 ? totalSeconds : totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.round(totalSeconds % 60)
return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`
}
export function NavMenu({ stats }: { stats: Stats | null }) { export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null) const [openPanel, setOpenPanel] = useState<PanelName>(null)
const panel = useSettingsPanel() const panel = useSettingsPanel()
@@ -102,6 +112,9 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0} seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
</div> </div>
<div>{stats.approx_bytes_total.toLocaleString()} bytes</div> <div>{stats.approx_bytes_total.toLocaleString()} bytes</div>
<div title="Highest ingestion-to-engine-processing lag seen in the last 5s">
lag {formatIngestLag(stats.ingest_lag_high_watermark_ms)}
</div>
</> </>
) : ( ) : (
<div>connecting…</div> <div>connecting…</div>
+2
View File
@@ -49,6 +49,8 @@ export interface Stats {
last_timestamp_ms: number | null last_timestamp_ms: number | null
/** Ticket 011: running approximate byte size of all currently-ingested logs. */ /** Ticket 011: running approximate byte size of all currently-ingested logs. */
approx_bytes_total: number approx_bytes_total: number
/** Ticket 073: highest ingestion-to-engine-processing lag (ms) seen within the trailing 5s. */
ingest_lag_high_watermark_ms: number
} }
export interface MetricDeclaration { export interface MetricDeclaration {