Icons, docs formats

This commit is contained in:
Hugh Kaznowski
2026-09-06 10:36:26 +01:00
parent 17efe222df
commit 8f71f2d056
6 changed files with 203 additions and 18 deletions
+34
View File
@@ -67,6 +67,40 @@ pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
} }
} }
/// 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". /// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+84 -21
View File
@@ -356,37 +356,79 @@ fn auto_derived_param_count(index: &BTreeMap<IndexKey, DataValue>) -> usize {
param_declarations_range(index).filter(|declaration| declaration.6).count() param_declarations_range(index).filter(|declaration| declaration.6).count()
} }
/// Scans `text` for `key=value`/`key="value"` pairs (`crate::logfmt::extract_pairs`) and declares a /// One param worth auto-declaring, found on an ingested line by either extraction path
/// standalone param for any key not already declared — auto or manual. A manual declaration for a /// `auto_declare_params` supports: a logfmt `key=value` pair (name, and a regex/capture-group built
/// name always wins and is never touched here; this only fills in names nobody has declared yet. /// from it) or a JSON leaf field (name being its dot-separated path, extracted via `json_field_path`
/// Run on every ingested line (`EngineMessage::Ingest`), same as `apply_all_metrics`, but note param /// instead of a regex). Lets `auto_declare_params` share one insert-and-budget loop across both.
/// *values* are still recomputed from raw text at read time (`derive_log_params`), not stored here — struct AutoParamCandidate {
/// so a key discovered on line N also back-fills the column for lines before N on the next read. name: String,
/// Bounded by `MAX_AUTO_DECLARED_PARAMS`/`MAX_NEW_AUTO_PARAMS_PER_LINE` (see their doc comments) so a value: String,
/// single burst of unusual lines can't permanently inflate the declared-param set (ticket 050). message_match_regex: String,
capture_group: String,
json_field_path: Option<String>,
}
/// Scans `text` for auto-derivable params and declares a standalone param for any name not already
/// declared — auto or manual. A manual declaration for a name always wins and is never touched here;
/// this only fills in names nobody has declared yet. JSON lines (`crate::logfmt::detect_format`) are
/// scanned via `crate::data_value::flatten_json_object` (dot-path leaf fields, extracted by
/// `json_field_path` rather than regex, since a `key=value` scan finds nothing useful in JSON's
/// `"key":value` shape); anything else falls back to the original `key=value` pair scan
/// (`crate::logfmt::extract_pairs`). Run on every ingested line (`EngineMessage::Ingest`), same as
/// `apply_all_metrics`, but note param *values* are still recomputed from raw text at read time
/// (`derive_log_params`), not stored here — so a key discovered on line N also back-fills the column
/// for lines before N on the next read. Bounded by `MAX_AUTO_DECLARED_PARAMS`/
/// `MAX_NEW_AUTO_PARAMS_PER_LINE` (see their doc comments) so a single burst of unusual lines can't
/// permanently inflate the declared-param set (ticket 050).
fn auto_declare_params(index: &mut BTreeMap<IndexKey, DataValue>, text: &str) { fn auto_declare_params(index: &mut BTreeMap<IndexKey, DataValue>, text: &str) {
let mut total = auto_derived_param_count(index); let mut total = auto_derived_param_count(index);
if total >= MAX_AUTO_DECLARED_PARAMS { if total >= MAX_AUTO_DECLARED_PARAMS {
return; return;
} }
let mut new_this_line = 0; let candidates: Vec<AutoParamCandidate> = if crate::logfmt::detect_format(text) == "json" {
for (key, value, quoted) in crate::logfmt::extract_pairs(text) { crate::data_value::flatten_json_object(text)
if new_this_line >= MAX_NEW_AUTO_PARAMS_PER_LINE || total >= MAX_AUTO_DECLARED_PARAMS { .into_iter()
break; .map(|(path, value)| AutoParamCandidate {
} name: path.clone(),
let index_key = IndexKey::ParamDeclaration { name: key.to_string() }; value,
if index.contains_key(&index_key) { message_match_regex: String::new(),
continue; capture_group: String::new(),
} json_field_path: Some(path),
})
.collect()
} else {
crate::logfmt::extract_pairs(text)
.into_iter()
.map(|(key, value, quoted)| {
let group = sanitize_group_name(key); let group = sanitize_group_name(key);
index.insert( AutoParamCandidate {
index_key,
DataValue::ParamDeclaration(ParamDeclaration {
name: key.to_string(), name: key.to_string(),
value: value.to_string(),
message_match_regex: build_auto_param_pattern(key, &group, quoted), message_match_regex: build_auto_param_pattern(key, &group, quoted),
capture_group: group, capture_group: group,
json_field_path: None, json_field_path: None,
kind: infer_kind(value), }
})
.collect()
};
let mut new_this_line = 0;
for candidate in candidates {
if new_this_line >= MAX_NEW_AUTO_PARAMS_PER_LINE || total >= MAX_AUTO_DECLARED_PARAMS {
break;
}
let index_key = IndexKey::ParamDeclaration { name: candidate.name.clone() };
if index.contains_key(&index_key) {
continue;
}
index.insert(
index_key,
DataValue::ParamDeclaration(ParamDeclaration {
name: candidate.name,
message_match_regex: candidate.message_match_regex,
capture_group: candidate.capture_group,
json_field_path: candidate.json_field_path,
kind: infer_kind(&candidate.value),
elapsed_since_last: false, elapsed_since_last: false,
auto_derived: true, auto_derived: true,
}), }),
@@ -2012,6 +2054,27 @@ mod tests {
assert_eq!(params.get("msg"), Some(&MetricPrimitive::String("entity_moved".to_string()))); assert_eq!(params.get("msg"), Some(&MetricPrimitive::String("entity_moved".to_string())));
} }
#[test]
fn auto_declare_params_discovers_json_leaf_fields_via_json_field_path() {
let mut index = BTreeMap::new();
auto_declare_params(&mut index, r#"{"level":"error","code":500,"request":{"path":"/x"}}"#);
let declarations: HashMap<&str, _> =
param_declarations_range(&index).map(|d| (d.0, d)).collect();
assert_eq!(declarations.len(), 3);
assert_eq!(declarations["code"].4, MetricPrimitiveKind::Number);
assert_eq!(declarations["code"].3, Some("code"));
assert!(declarations["level"].6, "expected level to be flagged auto_derived");
assert_eq!(declarations["request.path"].3, Some("request.path"));
let compiled = compiled_param_declarations(&index);
let params =
derive_log_params(r#"{"level":"error","code":500,"request":{"path":"/x"}}"#, &compiled);
assert_eq!(params.get("level"), Some(&MetricPrimitive::String("error".to_string())));
assert_eq!(params.get("code"), Some(&MetricPrimitive::Number(Number::Int(500))));
assert_eq!(params.get("request.path"), Some(&MetricPrimitive::String("/x".to_string())));
}
#[test] #[test]
fn auto_declare_params_never_overwrites_an_existing_declaration() { fn auto_declare_params_never_overwrites_an_existing_declaration() {
let mut index = BTreeMap::new(); let mut index = BTreeMap::new();
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 669 B

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 669 B

+59
View File
@@ -150,6 +150,65 @@ const SECTIONS: DocSection[] = [
</div> </div>
), ),
}, },
{
id: 'auto-formats',
title: 'Auto-detected formats',
body: (
<div className="flex flex-col gap-2">
<p>
Every ingested line is classified into one of four formats, exposed as the reserved{' '}
<Code>ottyl_fmt</Code> param (see <em>Filtering</em> above). The format also decides what — if anything —
gets auto-declared as a param without you doing anything:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">
<Code>log_stream</Code>
</span>{' '}
— macOS <Code>{'log stream'}</Code> output's fixed-column shape (timestamp, thread, type, activity id,
pid, ttl, then <Code>process: message</Code>). The first such line seen auto-declares{' '}
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> as params, parsed straight out of those
columns.
</li>
<li>
<span className="font-semibold text-text">
<Code>json</Code>
</span>{' '}
— the line parses as a JSON object (a bare array or scalar doesn't count). Every leaf scalar field
(string/number/bool) auto-declares a param named after its dot-separated path, extracted by JSON field
path rather than regex — a nested field like <Code>{'{"request":{"path":"/x"}}'}</Code> becomes a param
named <Code>request.path</Code>. Nested objects are walked into automatically; arrays and <Code>null</Code>{' '}
fields are skipped since there's no single scalar value to declare a param against.
</li>
<li>
<span className="font-semibold text-text">
<Code>logfmt</Code>
</span>{' '}
— the line contains at least one <Code>key=value</Code>/<Code>{'key="value"'}</Code> pair anywhere in its
text (it doesn't need to be logfmt end-to-end — a free-text prefix or suffix around the pairs is fine).
Every distinct key auto-declares a param of the same name, type-inferred as <Code>number</Code> if its
first-seen value parses as one, else <Code>string</Code>.
</li>
<li>
<span className="font-semibold text-text">
<Code>none</Code>
</span>{' '}
— none of the above; plain prose with no structure ottyl can key off of. Nothing is auto-declared, though
the line is still fully searchable via <Code>regex(...)</Code> in the filter bar.
</li>
</ul>
<p>
Auto-declared params are flagged <Code>auto_derived</Code> in <Code>GET /api/params</Code> and show up as
log-table columns immediately, same as a manually declared param — but they can't be edited or removed
individually, and they're never persisted to a profile (so they're rediscovered fresh each run rather than
traveling with a profile like manual declarations do). A manual param declaration for a name always takes
precedence and is never overwritten by auto-detection. To keep one unusual line from flooding the params
list, auto-detection is capped at 64 auto-derived params total and 8 new ones per line — once either cap is
hit, further would-be auto-declarations from new keys/fields are simply skipped.
</p>
</div>
),
},
{ {
id: 'parameters', id: 'parameters',
title: 'Parameters', title: 'Parameters',
+5 -1
View File
@@ -6,6 +6,7 @@ import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs' import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon' import { HelpIcon } from './HelpIcon'
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes' import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
import ottylIcon from '../assets/ottyl-icon.svg'
type PanelName = 'settings' | 'docs' | null type PanelName = 'settings' | 'docs' | null
@@ -67,7 +68,10 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
<div className="flex h-full"> <div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3"> <div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
<div> <div>
<div className="mb-4 text-lg font-semibold text-text">ottyl</div> <div className="mb-4 flex items-center gap-2 text-lg font-semibold text-text">
<img src={ottylIcon} alt="" className="h-5 w-5" />
ottyl
</div>
<PresetsDropdown panel={panel} /> <PresetsDropdown panel={panel} />
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted"> <nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
<button <button