8.3.101-stable Switch to dev

logs

@library("logs", "0.0.0");

Turns GreyCat’s own runtime log into queryable time series, and ships a dashboard that reads them. Pure GCL; no native code.

The runtime appends every record it emits to files/root/log.csv. This library tails that file from a persisted byte offset, folds each record into a set of series, and exposes them over @exposed endpoints. Schedule one function and the rest happens on its own:

fn main() {
    Scheduler::add(logs::ingest, FixedPeriodicity { every: 1min }, null);
}

What it stores

Series Type Holds
entries nodeTime<Entry> every record, keyed by the instant it was logged
rates nodeTime<LevelCounts> per-minute counts per level
usage nodeTime<DataUsage> per-minute store counters from perf records
sources nodeIndex<String, SourceStats> record totals per module::function
usage by source nodeIndex<String, UsageStats> store counters per module::function

usage fills only when the runtime runs at --log=perf, which is what makes it report store counters per transaction commit: read and write bytes, block and cache hits, and read_wasted, the part of read_bytes pulled off disk and never used. A busy server commits far more than once a minute, so those are accumulated per minute rather than stored per commit - that bounds a range query by its length instead of by how hard the server was working, and puts the series on the same buckets as rates. At any lower log level it stays empty and everything else still works.

At --log=perf the log never quite drains: ingest commits a transaction of its own, so the runtime logs one perf record for it, which the next run picks up. That is one record per run, not a growing backlog.

Querying

logs::extent()                                       // oldest/newest held
logs::records(null, from, to, 500)                   // newest first in a slice
logs::records(runtime::LogLevel::error, null, null, 20)
logs::between(from, to, 500)                         // oldest first, capped
logs::rates_between(from, to)
logs::usage_between(from, to)
logs::source_stats()                                 // records per function
logs::usage_stats()                                  // store counters per function
logs::status()                                       // cursor + what is held

logs::clear() drops the series and keeps the cursor; logs::rewind() moves the cursor back to the start of the log. Both need admin.

The dashboard

The library ships a webapp under lib/logs/webroot/logs, which the server serves at /logs for any project that depends on it. It draws every series as a lane on one shared clock, so a spike in errors can be read against the disk and cache traffic at the same instant: record volume, errors and warnings stacked, disk reads split into used and wasted, bytes written, and the share of block reads served from cache. The three store lanes are replaced by an instruction when the server is not running at --log=perf. Nothing to mount and no route to declare - install the library, start the server, open /logs. The project’s own webroot/ is left alone, so / keeps serving whatever the host application puts there.

The dashboard reads the session cookie and never asks for credentials: it is a page inside your application, and your application owns signing in. With no session it says so and links to /. For a bare local run, mint one against the server first:

LOGS_PASSWORD=secret greycat serve --log=perf
curl -c cookies.txt -X POST -d '["root","secret"]' \
  http://localhost:8080/runtime::Identity::login

Reading the log format

A record is seven columns, level,time,user_id,id,id2,src,data, matching runtime::Log. Only the payload may contain commas, and nothing escapes it, so a record can also span several physical lines when the payload contains a newline. logs::ingest handles both: the payload is read as the greedy last field and re-joined, and a line that parses as no record is appended to the one above it.

As of std 8.3.31-dev, CsvReader<runtime::Log> reads the file directly and this library uses it as-is. Two details still apply to anyone parsing it themselves: CsvFormat.string_delimiter must be '\0', because the payload is unescaped and a " in it would otherwise be eaten as a delimiter; and the payload arrives as one cell per comma, to be rejoined with ,.

Demo

greycat run demo --log=perf

Runs a workload that logs at every level, throws real errors, writes a multi-line record and touches the store, then ingests it and prints what landed in each series.