@bli-cockpit/log-digest 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `railway` and `railway:<service>`: Tower's Railway services, through the
3
+ * installed `railway` CLI.
4
+ *
5
+ * Project and environment are ALWAYS passed explicitly. `apps/inference`'s
6
+ * `railway link` points at the RUNNER service, so a bare `railway logs` run from
7
+ * a checkout answers for the wrong service; nothing here reads the link.
8
+ *
9
+ * Measured limits (BLI-12833, `railway` 5.54): `--lines` accepts at most 5,000
10
+ * (10,000 is "Invalid input"), and the busiest service (`ladder`, six replicas)
11
+ * writes about 1,000 lines a minute, so a 5,000-line read is five minutes. A
12
+ * longer window is read in pages going back in time with `--until`, newest
13
+ * first, up to a page cap, and the digest says how far back it got. Railway
14
+ * serves the logs of the latest deployment: a redeploy inside the window hides
15
+ * the lines before it, which shows as "read back to" a time later than the
16
+ * window start.
17
+ */
18
+ import { isoOf } from "../window.js";
19
+ import { SourceRefusal } from "./types.js";
20
+ export const RAILWAY_PROJECT_ID = "868238ce-d8fe-4629-8e05-fb7492baff1f"; // bli-cockpit-inference, workspace "canakobli's Projects"
21
+ export const RAILWAY_ENVIRONMENT = "production";
22
+ const SERVICE_NAME_ONLY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
+ const PAGE_LINES = 5000;
24
+ const MAX_PAGES = 4;
25
+ const CONCURRENCY = 4;
26
+ /** Which named refusal a failed `railway` run is, or null when the run is not a refusal. */
27
+ export function railwayRefusal(run, service) {
28
+ if (run.failure === "ENOENT") {
29
+ return new SourceRefusal("railway_cli_missing", "the `railway` CLI is not installed on this machine; install it (`brew install railway`) and run `railway login`");
30
+ }
31
+ const said = `${run.stderr}\n${run.stdout.split("\n").filter((line) => !line.startsWith("{")).join("\n").slice(0, 2000)}`;
32
+ if (/Unauthorized|not logged in|railway login|No token|invalid.*token/i.test(said)) {
33
+ return new SourceRefusal("railway_not_logged_in", "the `railway` CLI has no usable login; run `railway login` with an account on the bli-cockpit-inference project");
34
+ }
35
+ if (/Service '.*' not found|service .* not found/i.test(said)) {
36
+ return new SourceRefusal("railway_service_unknown", `Railway has no service ${service ?? "of that name"} in the bli-cockpit-inference project`);
37
+ }
38
+ if (/Project .* not found|not authorized to access|do not have access|doesn't have access/i.test(said)) {
39
+ return new SourceRefusal("railway_project_unreachable", "this Railway login cannot read the bli-cockpit-inference project");
40
+ }
41
+ return null;
42
+ }
43
+ function parseLines(stdout) {
44
+ const out = [];
45
+ for (const line of stdout.split("\n")) {
46
+ if (!line.startsWith("{"))
47
+ continue;
48
+ try {
49
+ out.push(JSON.parse(line));
50
+ }
51
+ catch {
52
+ // a line cut off by a timeout or the byte ceiling: not a record
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ async function listServices(context) {
58
+ const result = await context.run("railway", ["service", "list", "--json", "--project", RAILWAY_PROJECT_ID, "--environment", RAILWAY_ENVIRONMENT], { timeoutMs: 30_000, maxBytes: 8 * 1024 * 1024 });
59
+ const refusal = railwayRefusal(result);
60
+ if (refusal)
61
+ throw refusal;
62
+ try {
63
+ const parsed = JSON.parse(result.stdout);
64
+ const names = parsed.map((entry) => entry.name).filter((name) => typeof name === "string").sort();
65
+ if (names.length === 0)
66
+ throw new Error("empty");
67
+ return names;
68
+ }
69
+ catch {
70
+ throw new SourceRefusal("railway_failed", `\`railway service list\` returned nothing readable (exit ${result.status ?? "none"})`);
71
+ }
72
+ }
73
+ async function readService(context, service, refuseOnFailure) {
74
+ if (!SERVICE_NAME_ONLY.test(service))
75
+ throw new SourceRefusal("invalid_service_name", "a Railway service name is letters, digits, dot, dash and underscore");
76
+ const { window, run, log } = context;
77
+ const timeoutMs = context.limits?.timeoutMs ?? 60_000;
78
+ const pageLines = context.limits?.railwayLines ?? PAGE_LINES;
79
+ const lines = [];
80
+ let until = null;
81
+ let cappedAt = null;
82
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
83
+ const args = [
84
+ "logs", "--service", service, "--project", RAILWAY_PROJECT_ID, "--environment", RAILWAY_ENVIRONMENT,
85
+ "--since", isoOf(window.fromEpoch), ...(until ? ["--until", until] : []), "--lines", String(pageLines), "--json",
86
+ ];
87
+ const result = await run("railway", args, { timeoutMs, maxBytes: 64 * 1024 * 1024 });
88
+ const refusal = railwayRefusal(result, service);
89
+ if (refusal) {
90
+ if (refuseOnFailure || refusal.reason === "railway_not_logged_in" || refusal.reason === "railway_cli_missing")
91
+ throw refusal;
92
+ log("[logs railway] service unreadable", { service, reason: refusal.reason });
93
+ return { service, lines, cappedAt, problem: refusal.reason };
94
+ }
95
+ if (result.failure && result.failure !== "timeout") {
96
+ if (refuseOnFailure)
97
+ throw new SourceRefusal("railway_failed", `the railway CLI could not run (${result.failure})`);
98
+ return { service, lines, cappedAt, problem: `railway_failed:${result.failure}` };
99
+ }
100
+ if (result.status !== 0 && result.status !== null && !result.stdout.startsWith("{")) {
101
+ const tail = (result.stderr.trim().split("\n").pop() ?? "").slice(0, 160).replace(/\s+/g, " ");
102
+ if (refuseOnFailure)
103
+ throw new SourceRefusal("railway_failed", `railway logs exited ${result.status}: ${tail}`);
104
+ return { service, lines, cappedAt, problem: `railway_failed:exit_${result.status}` };
105
+ }
106
+ const records = parseLines(result.stdout).filter((record) => record.timestamp && (until === null || record.timestamp < until));
107
+ for (const record of records) {
108
+ if (typeof record.message === "string" && record.message) {
109
+ lines.push({ unit: service, message: record.message, ts: Date.parse(record.timestamp) / 1000, level: record.level });
110
+ }
111
+ }
112
+ log("[logs railway] page read", { service, page, lines: records.length, timed_out: result.failure === "timeout" });
113
+ if (records.length < pageLines)
114
+ return { service, lines, cappedAt: null, problem: result.failure === "timeout" ? "timeout" : null };
115
+ until = records.reduce((oldest, record) => (record.timestamp < oldest ? record.timestamp : oldest), records[0].timestamp);
116
+ cappedAt = until;
117
+ }
118
+ return { service, lines, cappedAt, problem: null };
119
+ }
120
+ export async function readRailway(context, service) {
121
+ const services = service ? [service] : await listServices(context);
122
+ const reads = [];
123
+ let next = 0;
124
+ const worker = async () => {
125
+ while (next < services.length) {
126
+ const name = services[next++];
127
+ reads.push(await readService(context, name, Boolean(service)));
128
+ }
129
+ };
130
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, services.length) }, () => worker()));
131
+ reads.sort((a, b) => (a.service < b.service ? -1 : a.service > b.service ? 1 : 0));
132
+ const lines = reads.flatMap((read) => read.lines);
133
+ const notes = [];
134
+ const quiet = reads.filter((read) => read.lines.length === 0 && !read.problem).length;
135
+ notes.push(`${services.length} service${services.length === 1 ? "" : "s"} read, ${quiet} with no lines in the window`);
136
+ const capped = reads.filter((read) => read.cappedAt);
137
+ if (capped.length > 0) {
138
+ notes.push(`capped (newest first, ${MAX_PAGES * (context.limits?.railwayLines ?? PAGE_LINES)} lines a service): ${capped.map((read) => `${read.service} back to ${read.cappedAt.slice(11, 16)}Z`).join(", ")}`);
139
+ }
140
+ // A busy service whose first line is well after the window start was redeployed inside the window: Railway serves
141
+ // the latest deployment's logs only. (A quiet cron's first line is late too, so only services with real volume.)
142
+ const hidden = reads.filter((read) => {
143
+ if (read.cappedAt || read.lines.length < 200)
144
+ return false;
145
+ const first = Math.min(...read.lines.map((line) => line.ts));
146
+ return first - context.window.fromEpoch > Math.max(120, (context.window.toEpoch - context.window.fromEpoch) * 0.1);
147
+ });
148
+ if (hidden.length > 0) {
149
+ notes.push(`latest deployment only: ${hidden.map((read) => `${read.service} from ${isoOf(Math.min(...read.lines.map((line) => line.ts))).slice(11, 16)}Z`).join(", ")} (a redeploy hides the lines before)`);
150
+ }
151
+ const broken = reads.filter((read) => read.problem);
152
+ if (broken.length > 0)
153
+ notes.push(`unreadable: ${broken.map((read) => `${read.service} (${read.problem})`).join(", ")}`);
154
+ context.log("[logs railway] read complete", { services: services.length, lines: lines.length, capped_services: capped.length, unreadable_services: broken.length });
155
+ return { lines, notes };
156
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `-`: lines piped in. Plain text lines are digested as they are; a JSON line is
3
+ * understood when it is a journald export (`MESSAGE`, `_SYSTEMD_UNIT`,
4
+ * `__REALTIME_TIMESTAMP`), `railway logs --json` (`message`, `level`, an ISO
5
+ * `timestamp`) or `vercel logs --json` (`message`, `level`, a millisecond
6
+ * `timestamp`, nested `logs`). Anything else JSON is judged as a JSON log line.
7
+ */
8
+ import { type SourceContext, type SourceRead } from "./types.js";
9
+ export declare function readStdin(context: SourceContext): Promise<SourceRead>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `-`: lines piped in. Plain text lines are digested as they are; a JSON line is
3
+ * understood when it is a journald export (`MESSAGE`, `_SYSTEMD_UNIT`,
4
+ * `__REALTIME_TIMESTAMP`), `railway logs --json` (`message`, `level`, an ISO
5
+ * `timestamp`) or `vercel logs --json` (`message`, `level`, a millisecond
6
+ * `timestamp`, nested `logs`). Anything else JSON is judged as a JSON log line.
7
+ */
8
+ import { SourceRefusal } from "./types.js";
9
+ function epochOf(value) {
10
+ if (typeof value === "number")
11
+ return value > 1e12 ? value / 1000 : value;
12
+ if (typeof value === "string") {
13
+ const parsed = Date.parse(value);
14
+ return Number.isFinite(parsed) ? parsed / 1000 : 0;
15
+ }
16
+ return 0;
17
+ }
18
+ function fromJson(data, fallback) {
19
+ if (typeof data["MESSAGE"] === "string") {
20
+ const micros = Number.parseInt(String(data["__REALTIME_TIMESTAMP"] ?? 0), 10);
21
+ const unit = String(data["UNIT"] ?? data["_SYSTEMD_UNIT"] ?? data["SYSLOG_IDENTIFIER"] ?? "stdin");
22
+ return [{ unit, message: data["MESSAGE"], ts: Number.isFinite(micros) ? micros / 1e6 : 0 }];
23
+ }
24
+ if (Array.isArray(data["logs"]) && typeof data["requestPath"] === "string") {
25
+ const ts = epochOf(data["timestamp"]);
26
+ return data["logs"]
27
+ .filter((entry) => typeof entry["message"] === "string")
28
+ .map((entry) => ({ unit: "stdin", message: String(entry["message"]), ts, level: typeof entry["level"] === "string" ? entry["level"] : undefined }));
29
+ }
30
+ if (typeof data["message"] === "string" && ("level" in data || "timestamp" in data)) {
31
+ return [{ unit: "stdin", message: data["message"], ts: epochOf(data["timestamp"]), level: typeof data["level"] === "string" ? data["level"] : undefined }];
32
+ }
33
+ return [{ unit: "stdin", message: fallback, ts: 0 }];
34
+ }
35
+ export async function readStdin(context) {
36
+ if (context.stdinIsTerminal)
37
+ throw new SourceRefusal("stdin_is_terminal", "`-` reads piped lines; nothing was piped (try `railway logs --json | cockpit logs -`)");
38
+ const text = context.stdinText ?? "";
39
+ const lines = [];
40
+ for (const row of text.split("\n")) {
41
+ if (!row.trim())
42
+ continue;
43
+ if (row.startsWith("{")) {
44
+ try {
45
+ lines.push(...fromJson(JSON.parse(row), row));
46
+ continue;
47
+ }
48
+ catch {
49
+ // not JSON after all: a plain text line
50
+ }
51
+ }
52
+ lines.push({ unit: "stdin", message: row, ts: 0 });
53
+ }
54
+ const notes = lines.length === 0 ? ["stdin was empty"] : [];
55
+ return { lines, notes };
56
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What every log source hands the digest: lines with a unit, a message, a time
3
+ * and optionally the source's own severity word, plus plain-words facts about
4
+ * the read itself (capped, partial, no per-line times).
5
+ */
6
+ import type { LogWindow } from "../window.js";
7
+ export interface SourceLine {
8
+ /** The unit group the line belongs to: a Railway service, a Vercel route, a collector tag. */
9
+ unit: string;
10
+ message: string;
11
+ /** Epoch seconds; 0 when the source has no per-line time. */
12
+ ts: number;
13
+ /** The source's own severity word (`error`, `warn`, `info`). */
14
+ level?: string;
15
+ }
16
+ export interface SourceRead {
17
+ lines: SourceLine[];
18
+ /** Printed under the header exactly as written: what the read could and could not see. */
19
+ notes: string[];
20
+ /** The size of what was read when it is more than the lines' own text (a collector log's indented receipts). */
21
+ rawChars?: number;
22
+ }
23
+ /** The outcome of one command a source runs (`vercel`, `railway`). */
24
+ export interface CliRun {
25
+ status: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ /** `ENOENT` when the program is not installed, `timeout` when it was stopped, `aborted` when its output matched the caller's abort pattern, else a Node error code. */
29
+ failure?: string;
30
+ /** The stdout was cut at the byte ceiling or the time limit; what is here is a prefix. */
31
+ truncated?: boolean;
32
+ }
33
+ export type RunCli = (command: string, args: readonly string[], options: {
34
+ timeoutMs: number;
35
+ maxBytes: number;
36
+ abortOn?: RegExp;
37
+ }) => Promise<CliRun>;
38
+ export type LogFields = Record<string, string | number | boolean | null | undefined>;
39
+ export type LogFn = (message: string, fields: LogFields) => void;
40
+ export interface SourceContext {
41
+ window: LogWindow;
42
+ run: RunCli;
43
+ log: LogFn;
44
+ /** Where the collector keeps its state (`~/.local/state/bli-cockpit`); the caller computes it per platform. */
45
+ stateDir?: string;
46
+ /** Stdin text for the `-` source. */
47
+ stdinText?: string;
48
+ /** Whether stdin is a terminal (nothing was piped). */
49
+ stdinIsTerminal?: boolean;
50
+ /** Read every Vercel record, not just error, warning, fatal and 5xx (slow: the CLI answers 50 records a call). */
51
+ allLevels?: boolean;
52
+ /** Overrides, mostly for tests. */
53
+ limits?: {
54
+ railwayLines?: number;
55
+ timeoutMs?: number;
56
+ };
57
+ }
58
+ /** A named refusal: the reason is computed where it happens and travels intact to the person. */
59
+ export declare class SourceRefusal extends Error {
60
+ readonly reason: string;
61
+ readonly detail: string;
62
+ constructor(reason: string, detail: string);
63
+ }
64
+ export interface SourceSpec {
65
+ /** `collector`, `dashboard`, `railway`, `railway:<service>`, `-`. */
66
+ name: string;
67
+ }
@@ -0,0 +1,13 @@
1
+ /** A named refusal: the reason is computed where it happens and travels intact to the person. */
2
+ export class SourceRefusal extends Error {
3
+ reason;
4
+ detail;
5
+ // No parameter properties: scripts/lib/cli-vocabulary.mjs reads this package from SOURCE through Node's type
6
+ // stripping, which only erases types and cannot compile a constructor that declares fields.
7
+ constructor(reason, detail) {
8
+ super(`${reason}: ${detail}`);
9
+ this.name = "SourceRefusal";
10
+ this.reason = reason;
11
+ this.detail = detail;
12
+ }
13
+ }
@@ -0,0 +1,10 @@
1
+ import { SourceRefusal, type CliRun, type SourceContext, type SourceRead } from "./types.js";
2
+ export declare const VERCEL_PROJECT = "bli-cockpit-dashboard";
3
+ export declare const VERCEL_SCOPE = "bli-inc";
4
+ /** `/api/docs/3f2a…` becomes `/api/docs/:id`; digits, uuids, long hex and long slugs are one placeholder. */
5
+ export declare function routeTemplate(path: string | undefined): string;
6
+ /** Which named refusal a failed `vercel` run is, or null when the run is not a refusal. */
7
+ export declare function vercelRefusal(run: CliRun): SourceRefusal | null;
8
+ /** The refusal-pattern the runner stops on, so an interactive login is never left waiting. */
9
+ export declare const VERCEL_ABORT_PATTERN: RegExp;
10
+ export declare function readDashboard(context: SourceContext): Promise<SourceRead>;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `dashboard`: Tower's Vercel runtime logs, through the installed `vercel` CLI.
3
+ *
4
+ * Project `bli-cockpit-dashboard`, team `bli-inc`. One Vercel record is one
5
+ * invocation (a request) carrying every console line the function wrote, so the
6
+ * source turns each nested line into one digest line, and adds a synthetic line
7
+ * for a request that ended 5xx / 429 / 403 even when the code logged nothing.
8
+ *
9
+ * Measured quirks this works around (BLI-12833, `vercel` 59.x):
10
+ * - `vercel logs --since 1h --limit 10000` returned 10,000 lines of which 599
11
+ * were distinct: a call answers one page of 50 records and then repeats that
12
+ * page until `--limit`. A call that comes back with a full page is therefore
13
+ * treated as possibly incomplete and its time slice is halved until every
14
+ * answer is under a page; every record is de-duplicated by id.
15
+ * - A full hour of every level is about 4,000 records, a few minutes of calls.
16
+ * The default reads only what Vercel marks error, warning or fatal, plus every
17
+ * 5xx (a handful of calls); `allLevels` reads everything.
18
+ * - With no credentials the CLI starts a browser login flow instead of failing.
19
+ * That output is recognised and the process stopped at once, as the named
20
+ * refusal `vercel_not_logged_in`.
21
+ */
22
+ import { isoOf } from "../window.js";
23
+ import { SourceRefusal } from "./types.js";
24
+ export const VERCEL_PROJECT = "bli-cockpit-dashboard";
25
+ export const VERCEL_SCOPE = "bli-inc";
26
+ // The CLI answers a call with at most one page of 50 records; when more exist it repeats that page until
27
+ // `--limit`. A page-full answer is therefore suspect, and the slice is halved until each answer is under a page.
28
+ const PAGE = 50;
29
+ const MIN_SLICE_SECONDS = 4;
30
+ const MAX_CALLS = 240;
31
+ const CONCURRENCY = 4;
32
+ const CALL_TIMEOUT_MS = 45_000;
33
+ // Default: only the records Vercel itself marks as trouble, plus every 5xx. A few calls an hour.
34
+ const PROBLEM_QUERIES = [["--level", "error"], ["--level", "warning"], ["--level", "fatal"], ["--status-code", "5xx"]];
35
+ const ALL_QUERIES = [[]];
36
+ /** `/api/docs/3f2a…` becomes `/api/docs/:id`; digits, uuids, long hex and long slugs are one placeholder. */
37
+ export function routeTemplate(path) {
38
+ if (!path)
39
+ return "";
40
+ const clean = path.split("?")[0];
41
+ return clean
42
+ .split("/")
43
+ .map((segment) => /^\d+$/.test(segment) || (/^[0-9a-f-]{8,}$/i.test(segment) && /\d/.test(segment)) || segment.length > 28 ? ":id" : segment)
44
+ .join("/");
45
+ }
46
+ /** Which named refusal a failed `vercel` run is, or null when the run is not a refusal. */
47
+ export function vercelRefusal(run) {
48
+ if (run.failure === "ENOENT") {
49
+ return new SourceRefusal("vercel_cli_missing", "the `vercel` CLI is not installed on this machine; install it (`npm i -g vercel`) and run `vercel login`");
50
+ }
51
+ // Only the CLI's own words: a log record is a JSON line and may say "forbidden" about the app.
52
+ const said = `${run.stderr}\n${run.stdout.split("\n").filter((line) => !line.startsWith("{")).join("\n").slice(0, 4000)}`;
53
+ if (/No existing credentials|Starting login flow|oauth\/device|not_authorized|token .*rejected|Please log ?in|not logged in/i.test(said)) {
54
+ return new SourceRefusal("vercel_not_logged_in", "the `vercel` CLI has no usable login; run `vercel login` as a member of the bli-inc team");
55
+ }
56
+ if (/forbidden|not a member|do not have access|don't have access|insufficient permissions/i.test(said)) {
57
+ return new SourceRefusal("vercel_scope_denied", `this Vercel login cannot read the ${VERCEL_SCOPE} team's ${VERCEL_PROJECT} logs`);
58
+ }
59
+ if (/project .*not found|could not find project|no such project/i.test(said)) {
60
+ return new SourceRefusal("vercel_project_not_found", `Vercel has no project ${VERCEL_PROJECT} in the ${VERCEL_SCOPE} scope for this login`);
61
+ }
62
+ return null;
63
+ }
64
+ /** The refusal-pattern the runner stops on, so an interactive login is never left waiting. */
65
+ export const VERCEL_ABORT_PATTERN = /No existing credentials|Starting login flow|oauth\/device/;
66
+ function parseRecords(stdout) {
67
+ const records = [];
68
+ for (const line of stdout.split("\n")) {
69
+ if (!line.startsWith("{"))
70
+ continue;
71
+ try {
72
+ records.push(JSON.parse(line));
73
+ }
74
+ catch {
75
+ // a line cut off by a timeout or the byte ceiling: not a record
76
+ }
77
+ }
78
+ return records;
79
+ }
80
+ export async function readDashboard(context) {
81
+ const { window, run, log } = context;
82
+ const allLevels = context.allLevels ?? false;
83
+ const queries = allLevels ? ALL_QUERIES : PROBLEM_QUERIES;
84
+ const seen = new Map();
85
+ const queue = [];
86
+ const width = allLevels ? 120 : Math.max(300, Math.ceil((window.toEpoch - window.fromEpoch) / 4));
87
+ for (const query of queries) {
88
+ for (let end = window.toEpoch; end > window.fromEpoch; end -= width)
89
+ queue.push({ from: Math.max(window.fromEpoch, end - width), to: end, query });
90
+ }
91
+ let calls = 0;
92
+ let splits = 0;
93
+ let incomplete = 0;
94
+ let active = 0;
95
+ let failure = null;
96
+ const attempt = async (task) => {
97
+ calls += 1;
98
+ const result = await run("vercel", ["logs", "--project", VERCEL_PROJECT, "--scope", VERCEL_SCOPE, "--since", isoOf(task.from), "--until", isoOf(task.to), ...task.query, "--json", "--limit", "100", "--no-color", "--non-interactive"], { timeoutMs: context.limits?.timeoutMs ?? CALL_TIMEOUT_MS, maxBytes: 64 * 1024 * 1024, abortOn: VERCEL_ABORT_PATTERN });
99
+ const refusal = vercelRefusal(result);
100
+ if (refusal)
101
+ throw refusal;
102
+ if (result.failure && result.failure !== "timeout")
103
+ throw new SourceRefusal("vercel_failed", `the vercel CLI could not run (${result.failure})`);
104
+ if (result.status !== 0 && result.status !== null && !result.stdout.includes('"id"')) {
105
+ const tail = (result.stderr.trim().split("\n").pop() ?? "").slice(0, 160).replace(/\s+/g, " ");
106
+ throw new SourceRefusal("vercel_failed", `vercel logs exited ${result.status}: ${tail}`);
107
+ }
108
+ const records = parseRecords(result.stdout);
109
+ for (const record of records)
110
+ if (record.id && !seen.has(record.id))
111
+ seen.set(record.id, record);
112
+ const suspect = records.length >= PAGE || result.failure === "timeout";
113
+ if (!suspect)
114
+ return;
115
+ if (task.to - task.from > MIN_SLICE_SECONDS && calls < MAX_CALLS) {
116
+ const middle = (task.from + task.to) / 2;
117
+ queue.push({ ...task, from: task.from, to: middle }, { ...task, from: middle, to: task.to });
118
+ splits += 1;
119
+ }
120
+ else {
121
+ incomplete += 1;
122
+ }
123
+ };
124
+ const worker = async () => {
125
+ for (;;) {
126
+ if (failure)
127
+ return;
128
+ const task = queue.shift();
129
+ if (!task) {
130
+ if (active === 0)
131
+ return;
132
+ await new Promise((resolve) => setTimeout(resolve, 25));
133
+ continue;
134
+ }
135
+ active += 1;
136
+ try {
137
+ await attempt(task);
138
+ }
139
+ catch (error) {
140
+ failure = error instanceof SourceRefusal ? error : new SourceRefusal("vercel_failed", "the vercel read failed unexpectedly");
141
+ }
142
+ finally {
143
+ active -= 1;
144
+ }
145
+ }
146
+ };
147
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
148
+ if (failure)
149
+ throw failure;
150
+ const lines = [];
151
+ const classes = { ok: 0, redirect: 0, client: 0, server: 0 };
152
+ for (const record of seen.values()) {
153
+ const ts = (record.timestamp ?? 0) / 1000;
154
+ if (ts && ts < window.fromEpoch)
155
+ continue;
156
+ const route = routeTemplate(record.requestPath);
157
+ const unit = route ? `dashboard:${route}` : "dashboard";
158
+ const nested = record.logs && record.logs.length > 0 ? record.logs : record.message ? [{ level: record.level, message: record.message }] : [];
159
+ for (const entry of nested) {
160
+ if (typeof entry.message === "string" && entry.message)
161
+ lines.push({ unit, message: entry.message, ts, level: entry.level });
162
+ }
163
+ const status = record.responseStatusCode ?? 0;
164
+ if (status >= 200 && status < 300)
165
+ classes.ok += 1;
166
+ else if (status >= 300 && status < 400)
167
+ classes.redirect += 1;
168
+ else if (status >= 400 && status < 500)
169
+ classes.client += 1;
170
+ else if (status >= 500)
171
+ classes.server += 1;
172
+ if (status >= 500 || status === 429 || status === 403) {
173
+ lines.push({ unit, message: `HTTP ${status} ${record.requestMethod ?? "?"} ${route || "?"}`, ts, level: "error" });
174
+ }
175
+ }
176
+ const notes = [];
177
+ const requests = classes.ok + classes.redirect + classes.client + classes.server;
178
+ notes.push(allLevels
179
+ ? `requests ${requests.toLocaleString("en-US")}: 2xx ${classes.ok.toLocaleString("en-US")}, 3xx ${classes.redirect}, 4xx ${classes.client}, 5xx ${classes.server}`
180
+ : `read only the records Vercel marks error, warning or fatal, and every 5xx (${seen.size} records); info-only requests were not read (--all-levels reads them, slowly)`);
181
+ if (incomplete > 0)
182
+ notes.push(`incomplete: ${incomplete} slice${incomplete === 1 ? "" : "s"} still returned a full page at ${MIN_SLICE_SECONDS} s or at the call limit`);
183
+ log("[logs dashboard] read complete", { calls, splits, incomplete_slices: incomplete, records: seen.size, lines: lines.length, server_errors: classes.server, all_levels: allLevels });
184
+ return { lines, notes };
185
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Tower's own log shape: `[tag] what happened {"reason":"x","http_status":502}`.
3
+ *
4
+ * `docs/engineering/message-standard.md` makes every deciding branch log a tag,
5
+ * a sentence and one JSON object of metadata. Judging such a line by the words
6
+ * anywhere in it misreads its keys (`"failed":{}` is an empty map, not a
7
+ * failure), so this reads the three parts separately:
8
+ *
9
+ * - the SENTENCE is judged by the trouble words (`looksImportant`);
10
+ * - the FIELDS are judged by their values: an HTTP-like status, a bad state
11
+ * word, a `reason` whose words say failure, a non-empty `error`;
12
+ * - the source's own LEVEL: warn is a line the unit itself declared a WARNING
13
+ * (counted, never escalated). The error level is NOT a verdict here: Tower
14
+ * writes ordinary lines with console.error, so Vercel marks them error.
15
+ *
16
+ * Opt-in (`DigestOptions.taggedLines`): Kaiben's journals are not in this shape,
17
+ * and the Python-pinned tests run with it off.
18
+ */
19
+ import { type JsonObject } from "./judge.js";
20
+ /** The line with its zero counters removed, so `failed=0` does not read as a failure. */
21
+ export declare function withoutZeroCounters(text: string): string;
22
+ export interface Tagged {
23
+ tag: string;
24
+ /** The sentence between the tag and the metadata. */
25
+ text: string;
26
+ fields: JsonObject | null;
27
+ }
28
+ /** `[tag] sentence {json}` split into its parts, or null when the line does not start with a tag. */
29
+ export declare function parseTagged(message: string): Tagged | null;
30
+ export interface TaggedJudgement {
31
+ important: boolean;
32
+ /** The template when important; the tag (the routine event name) otherwise. */
33
+ template: string;
34
+ declaredWarning: boolean;
35
+ }
36
+ /** Whether a tagged line can mean trouble, and the template it groups under. */
37
+ export declare function judgeTagged(parsed: Tagged, level: string | undefined): TaggedJudgement;
package/dist/tagged.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tower's own log shape: `[tag] what happened {"reason":"x","http_status":502}`.
3
+ *
4
+ * `docs/engineering/message-standard.md` makes every deciding branch log a tag,
5
+ * a sentence and one JSON object of metadata. Judging such a line by the words
6
+ * anywhere in it misreads its keys (`"failed":{}` is an empty map, not a
7
+ * failure), so this reads the three parts separately:
8
+ *
9
+ * - the SENTENCE is judged by the trouble words (`looksImportant`);
10
+ * - the FIELDS are judged by their values: an HTTP-like status, a bad state
11
+ * word, a `reason` whose words say failure, a non-empty `error`;
12
+ * - the source's own LEVEL: warn is a line the unit itself declared a WARNING
13
+ * (counted, never escalated). The error level is NOT a verdict here: Tower
14
+ * writes ordinary lines with console.error, so Vercel marks them error.
15
+ *
16
+ * Opt-in (`DigestOptions.taggedLines`): Kaiben's journals are not in this shape,
17
+ * and the Python-pinned tests run with it off.
18
+ */
19
+ import { jsonRecords, looksImportant } from "./judge.js";
20
+ import { mask, TEMPLATE_CHARS, cut } from "./mask.js";
21
+ const TAGGED = /^\[([^\]\n]{1,60})\]\s*([\s\S]*)$/;
22
+ // "exhausted" is bad only with a thing that ran out (retries, a budget); alone it is how a paginated read says it reached the end.
23
+ const RAN_OUT_OF = new Set(["retries", "retry", "attempts", "budget", "quota", "credits", "tokens", "memory", "disk"]);
24
+ const BAD_WORDS = new Set([
25
+ "fail", "failed", "failure", "error", "errors", "exception", "refused", "denied", "blocked", "timeout", "timed",
26
+ "fatal", "crash", "crashed", "killed", "abort", "aborted", "rejected", "unreachable", "invalid",
27
+ "missing", "unreadable", "stalled", "throttled",
28
+ ]);
29
+ const BAD_STATE = /^(fail|failed|failure|error|errored|fatal|blocked|refused|timeout|timed_out|crash|crashed|killed|aborted|rejected)$/i;
30
+ const EXPLAIN = [
31
+ "reason", "status", "outcome", "result", "http", "http_status", "code", "step", "what", "error", "error_name",
32
+ "exception", "detail", "server_reason", "sync_reason", "error_code", "error_syscall",
33
+ ];
34
+ const WORD = /[A-Za-z]+/g;
35
+ // `failed=0`, `errors: 0`: a counter at zero is a routine summary, not a failure.
36
+ const ZERO_COUNTER = /\b(?:fail(?:ed|ures?)?|errors?)\s*[=:]\s*0\b/gi;
37
+ /** The line with its zero counters removed, so `failed=0` does not read as a failure. */
38
+ export function withoutZeroCounters(text) {
39
+ return text.replace(ZERO_COUNTER, "");
40
+ }
41
+ /** `[tag] sentence {json}` split into its parts, or null when the line does not start with a tag. */
42
+ export function parseTagged(message) {
43
+ const head = TAGGED.exec(message);
44
+ if (!head)
45
+ return null;
46
+ const rest = head[2];
47
+ let text = rest;
48
+ let fields = null;
49
+ for (let at = rest.indexOf("{"); at >= 0; at = rest.indexOf("{", at + 1)) {
50
+ if (at > 0 && rest[at - 1] !== " ")
51
+ continue;
52
+ const records = jsonRecords(rest.slice(at).trim());
53
+ if (records && records.length === 1) {
54
+ text = rest.slice(0, at).trim();
55
+ fields = records[0];
56
+ break;
57
+ }
58
+ }
59
+ return { tag: head[1], text, fields };
60
+ }
61
+ function fieldsSayTrouble(fields) {
62
+ for (const key of ["http", "http_status", "code", "status"]) {
63
+ const value = fields[key];
64
+ if (typeof value === "number" && Number.isInteger(value) && (value === 403 || value === 429 || (value >= 500 && value <= 599)))
65
+ return true;
66
+ }
67
+ for (const key of ["status", "outcome", "result", "state", "verdict", "sync_status"]) {
68
+ const value = fields[key];
69
+ if (typeof value === "string" && BAD_STATE.test(value))
70
+ return true;
71
+ }
72
+ const reason = fields["reason"];
73
+ if (typeof reason === "string") {
74
+ const words = (reason.match(WORD) ?? []).map((word) => word.toLowerCase());
75
+ if (words.some((word) => BAD_WORDS.has(word)))
76
+ return true;
77
+ if (words.includes("exhausted") && words.some((word) => RAN_OUT_OF.has(word)))
78
+ return true;
79
+ }
80
+ for (const key of ["error", "error_name", "error_detail"]) {
81
+ const value = fields[key];
82
+ if (value !== null && value !== undefined && value !== "" && value !== 0 && value !== false)
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ function brief(value) {
88
+ if (typeof value === "number")
89
+ return Number.isInteger(value) && value >= 100 && value <= 599 ? String(value) : Number.isInteger(value) && value >= -9 && value <= 9 ? String(value) : "#";
90
+ if (typeof value === "boolean" || value === null)
91
+ return String(value);
92
+ if (typeof value === "string")
93
+ return mask(value);
94
+ return Array.isArray(value) ? "<list>" : "<dict>";
95
+ }
96
+ /** Whether a tagged line can mean trouble, and the template it groups under. */
97
+ export function judgeTagged(parsed, level) {
98
+ const lowered = (level ?? "").toLowerCase();
99
+ // Not the error level: Tower writes plenty of ordinary lines with console.error, so Vercel's level
100
+ // "error" is not a verdict (measured 2026-10-07: most error-level lines in an hour were routine).
101
+ const warnLevel = lowered === "warn" || lowered === "warning";
102
+ const important = warnLevel || looksImportant(withoutZeroCounters(parsed.text)) || (parsed.fields !== null && fieldsSayTrouble(parsed.fields));
103
+ if (!important)
104
+ return { important: false, template: parsed.tag, declaredWarning: false };
105
+ const parts = [mask(`[${parsed.tag}] ${parsed.text}`)];
106
+ if (parsed.fields) {
107
+ for (const key of EXPLAIN) {
108
+ const value = parsed.fields[key];
109
+ if (value !== undefined && value !== null && value !== "")
110
+ parts.push(`${key}=${brief(value)}`);
111
+ }
112
+ }
113
+ return { important: true, template: cut(parts.join(" ").replace(/\s+/g, " ").trim(), TEMPLATE_CHARS), declaredWarning: warnLevel };
114
+ }