writeonce/docs/examples/log-watcher/logtail.wo

157 lines
6.2 KiB
Text

-- logtail.wo — LogTail.hx, file for file: bounded tail reads over `fs`.
-- One poll = read at most CHUNK new tail bytes, judge only complete lines
-- (plan 02: never scan a file front to back; torn final line held back).
--
-- The three Haxe imports (sys.FileSystem, sys.io.File, sys.io.FileSeek)
-- collapse into one `use fs`: stat carries the inode, read_at takes the
-- offset as a parameter (no seek state), and the file handle lives and dies
-- inside the builtin — RAII instead of the open/try/close dance.
use fs
-- LogTail.hx:6-11. Field defaults replace LogTail.newState(): construction
-- is the brace literal `TailState {}`, the defaults fill in.
typedef TailState = {
offset: Int = 0 -- next read position (past last complete line)
ino: Int = -1 -- inode at last poll, -1 before first sight
last_level: Text = "" -- level of the last complete entry, "" if none
last_newline_at: Int = 0 -- ms clock when complete lines last arrived
}
typedef PollResult = {
exists: Bool
new_lines: Int
bytes_read: Int
}
const CHUNK = 65536
-- `now` is injected (ms) so tests can drive synthetic time. LogTail.hx:28-75.
pub fn poll(path: Text, mut st: TailState, now: Int) -> PollResult {
let stat = fs.stat(path);
if stat == nil { return PollResult { exists: false, new_lines: 0, bytes_read: 0 }; }
if st.ino == -1 {
-- first sight: start at most CHUNK before EOF; a partial first line is
-- read as a continuation, which is acceptable
st.ino = stat.inode;
st.offset = 0;
if stat.size > CHUNK { st.offset = stat.size - CHUNK; }
} else if stat.inode != st.ino or stat.size < st.offset {
-- rotation (rename/recreate or truncate): restart from the top
st.ino = stat.inode;
st.offset = 0;
st.last_level = "";
}
if stat.size - st.offset > CHUNK { st.offset = stat.size - CHUNK; } -- burst: jump to tail
if stat.size <= st.offset { return PollResult { exists: true, new_lines: 0, bytes_read: 0 }; }
-- Shrunk between stat and read (the Haxe Eof catch): read_at returns what
-- is actually there; the next poll re-syncs.
let chunk = fs.read_at(path, st.offset, stat.size - st.offset);
if len(chunk) == 0 { return PollResult { exists: true, new_lines: 0, bytes_read: 0 }; }
-- only complete lines count: cut at the last newline, hold the rest
let nl = last_index_of(chunk, "\n");
if nl == -1 { return PollResult { exists: true, new_lines: 0, bytes_read: len(chunk) }; }
let lines = split(substr(chunk, 0, nl + 1), "\n");
pop(lines); -- empty piece after the final newline
st.offset = st.offset + nl + 1;
for line in lines {
-- relaxed rule (service-health): timestamped app logs must classify
-- too, or their errors never trip the alert rule
let lv = classify_loose(sanitize(line));
if lv != nil { st.last_level = lv; } -- else continuation: inherits
}
if len(lines) > 0 { st.last_newline_at = now; }
return PollResult { exists: true, new_lines: len(lines), bytes_read: len(chunk) };
}
-- Strict prefix rule for demo/test logs. LogTail.hx:77-82.
pub fn classify(line: Text) -> ?Text {
if starts_with(line, "info") { return "info"; }
if starts_with(line, "warn") { return "warn"; }
if starts_with(line, "error") { return "error"; }
return nil;
}
-- Real app logs put a timestamp first ("2026-07-22T15:40:00 - error: …");
-- the relaxed rule also accepts the level after a leading token. Haxe used
-- an EReg (LogTail.hx:87, ~/^\S+\s+-\s+(info|warn|error)\b/); with no regex
-- in the language the same rule is spelled out: token, lone dash, level
-- with a word boundary. Callers pass a sanitized line — ANSI codes hide
-- the prefix.
pub fn classify_loose(line: Text) -> ?Text {
let lv = classify(line);
if lv != nil { return lv; }
let tokens = split_ws(line);
if len(tokens) < 3 { return nil; }
if tokens[1] != "-" { return nil; }
return level_bounded(tokens[2]);
}
-- The regex's \b: "error:" and "error," carry the level; "errors" does not.
fn level_bounded(tok: Text) -> ?Text {
for lv in ["info", "warn", "error"] {
if starts_with(tok, lv) {
if len(tok) == len(lv) { return lv; }
if is_word_byte(byte_at(tok, len(lv))) { return nil; }
return lv;
}
}
return nil;
}
fn is_word_byte(c: Int) -> Bool {
if c >= 48 and c <= 57 { return true; } -- 0-9
if c >= 65 and c <= 90 { return true; } -- A-Z
if c >= 97 and c <= 122 { return true; } -- a-z
return c == 95; -- _
}
-- Tools.hx:24-34, moved here beside its heaviest caller (poll). Log lines
-- can carry ANSI color sequences and stray control bytes; they would break
-- classification and produce invalid JSON at the client. Strip ESC[…letter
-- sequences, drop other C0 bytes (tab stays). The Haxe EReg becomes an
-- explicit byte scan.
pub fn sanitize(line: Text) -> Text {
let out = "";
let i = 0;
while i < len(line) {
let c = byte_at(line, i);
if c == 27 and i + 1 < len(line) and byte_at(line, i + 1) == 91 {
i = i + 2; -- skip ESC [
while i < len(line) {
let f = byte_at(line, i);
i = i + 1;
if (f >= 65 and f <= 90) or (f >= 97 and f <= 122) { break; }
}
continue;
}
if c >= 32 or c == 9 { out = out + char_of(c); }
i = i + 1;
}
return out;
}
-- Last <= n complete lines from the final CHUNK bytes of the file. A
-- cut-off first line is acceptable (same as first-sight poll); an
-- unterminated final line is dropped. nil when the file is missing.
-- LogTail.hx:98-120.
pub fn last_lines(path: Text, n: Int) -> ?multi Text {
let stat = fs.stat(path);
if stat == nil { return nil; }
let start = 0;
if stat.size > CHUNK { start = stat.size - CHUNK; }
if stat.size - start <= 0 { return []; }
let chunk = fs.read_at(path, start, stat.size - start);
if len(chunk) == 0 { return []; }
let nl = last_index_of(chunk, "\n");
if nl == -1 { return []; }
let lines = split(substr(chunk, 0, nl + 1), "\n");
pop(lines); -- empty piece after the final newline
if start > 0 and len(lines) > 0 { shift(lines); } -- cut-off first line
if len(lines) > n { return slice(lines, len(lines) - n, len(lines)); }
return lines;
}