From 1c4c9c874743e8c961f9f5d1a297b19887d0a953 Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 21:03:02 -0300 Subject: [PATCH] feat: pair with Relay Cards and Log Howl --- CHANGELOG.md | 1 + README.md | 24 ++++++++++++++ docs/architecture.md | 1 + src/cli.tsx | 13 ++++++-- src/cli/options.ts | 18 ++++++++++ src/core/entry.ts | 12 ++++++- src/core/log-howl.ts | 37 +++++++++++++++++++++ src/format/cards.ts | 30 +++++++++++++++++ src/format/json.ts | 13 ++++++++ src/index.ts | 3 ++ tests/pairing.test.ts | 77 +++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/core/log-howl.ts create mode 100644 src/format/cards.ts create mode 100644 src/format/json.ts create mode 100644 tests/pairing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8847998..ca650ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,5 @@ - Added `-f` to follow files across rotation and truncation, and `-n` to start with the last lines of each file. - Added `-l` to filter by level and `-g` and `-x` to include or exclude lines by pattern, keeping stack frames with their entry. - Added the interactive viewer with following, level toggles, and regular-expression search with highlighting, and `--plain` to print lines instead. +- Added `--format cards` for piping into Relay Cards, `--format json`, and reading of Log Howl alerts. - Added `parseLine`, which recognizes text, JSON, and logfmt lines, and `sanitizeLine`, which removes terminal control sequences. diff --git a/README.md b/README.md index 85b51d0..b079bb4 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,35 @@ relay-logs -f -g timeout -g refused -x /health api.log `-g` shows only lines matching a regular expression, and `-x` hides lines matching one. Both may be repeated. Patterns ignore case unless they contain an uppercase letter. +## Use with Relay Cards + +`--format cards` writes each entry in the form [Relay Cards](https://github.com/relay-cli/relay-cards) turns into a card, so a followed log can be read as cards: + +```sh +relay-logs -f --format cards api.log | relay-cards +``` + +Errors and fatal entries become error cards that collect the stack frames printed after them, warnings become warning cards, and JSON records become JSON cards. Use `-l` and `-g` to decide what reaches Relay Cards. + +`--format json` writes one JSON object per entry with `time`, `level`, `source`, and `message`, whatever format the log was in. + +## Use with Log Howl + +Relay Logs reads the alerts [Log Howl](https://github.com/dispersal-wolves/log-howl) prints. Each alert is shown at its severity, with the rule that raised it before the matched line and the watched file as its source: + +```sh +log-howl watch --config rules.json >> alerts.jsonl & +relay-logs -f alerts.jsonl +``` + +Writing alerts to a file and following it keeps the interactive viewer available. Alerts can also be piped straight through, for example `log-howl watch --config rules.json | relay-logs --format cards | relay-cards`. + ## Recognized lines - Text lines with an optional leading timestamp and a level such as `ERROR`, `[warn]`, ``, or `FATAL:` - JSON records with `level`, `lvl`, `severity`, or `levelname`, including pino and bunyan numeric levels - logfmt lines with `level=` and `msg=` +- alerts from Log Howl, read at their severity with the rule name A line whose level cannot be recognized is still shown as it was written. Terminal control sequences are removed from every line before it is printed, so a log cannot move the cursor or change terminal settings. diff --git a/docs/architecture.md b/docs/architecture.md index 3ea64c7..7728550 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,3 +9,4 @@ Relay Logs turns lines from files or standard input into log entries and display 5. `createFilter` decides which entries are shown by level and pattern, carrying the decision for an entry over to its indented continuation lines. 6. `LogBuffer` holds the entries the viewer can scroll through, drops the oldest past its capacity, and notifies the viewer in batches. 7. `LogViewer` renders the buffer with Ink. Its state lives in `viewer/model.ts` as plain functions: which entries are visible, which one is selected, and where search matches are. The selection is kept by sequence number, so it stays on the same line as new lines arrive and old ones are dropped. +8. `formatCards` and `formatJson` write entries for other tools, and `readLogHowlAlert` reads alerts from Log Howl. Relay Logs depends on neither project; they meet at the line format. diff --git a/src/cli.tsx b/src/cli.tsx index 401302f..35b719f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -5,6 +5,8 @@ import {type CliOptions, helpText, parseCliOptions} from './cli/options.js'; import {type LogEntry, parseLine} from './core/entry.js'; import {createFilter} from './core/filter.js'; import {concatSources, type LogSource, mergeSources, openSources} from './core/sources.js'; +import {formatCards} from './format/cards.js'; +import {formatJson} from './format/json.js'; import {formatPlain} from './format/plain.js'; import {LogBuffer} from './viewer/buffer.js'; import {LogViewer} from './viewer/LogViewer.js'; @@ -44,16 +46,22 @@ const runPlain = async (options: CliOptions): Promise => { if (error.code === 'EPIPE') process.exit(0); throw error; }); - const format = { + const plain = { color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined), showSource: options.files.length > 1, }; + const format = + options.format === 'json' + ? formatJson + : options.format === 'cards' + ? formatCards + : (entry: LogEntry) => formatPlain(entry, plain); const onError = (source: LogSource, error: unknown): void => { process.stderr.write(`relay-logs: ${source.label ?? 'stdin'}: ${errorMessage(error)}\n`); process.exitCode = 1; }; for await (const entry of readEntries(options, undefined, onError)) { - process.stdout.write(`${formatPlain(entry, format)}\n`); + process.stdout.write(`${format(entry)}\n`); } }; @@ -108,6 +116,7 @@ const main = async (): Promise => { // than the log itself. const interactive = !options.plain && + options.format === 'text' && process.stdout.isTTY && process.stdin.isTTY && options.files.length > 0 && diff --git a/src/cli/options.ts b/src/cli/options.ts index 00cc979..1523e6e 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,6 +1,10 @@ import {compilePattern} from '../core/filter.js'; import {type Level, levels, parseLevelName} from '../core/levels.js'; +export const outputFormats = ['text', 'json', 'cards'] as const; + +export type OutputFormat = (typeof outputFormats)[number]; + export interface CliOptions { help: boolean; version: boolean; @@ -10,6 +14,8 @@ export interface CliOptions { follow: boolean; /** Print lines instead of opening the interactive viewer. */ plain: boolean; + /** How printed lines are written. Any format other than text implies `plain`. */ + format: OutputFormat; /** Start with only the last lines of each file. */ lines: number | undefined; minLevel: Level | undefined; @@ -35,6 +41,8 @@ Options: -g, --grep Show only lines matching this pattern; repeat to match any of several -x, --exclude Hide lines matching this pattern; may be repeated --plain Print lines instead of opening the viewer + --format Print lines as text (default), json, or cards for piping into + relay-cards --color Always color output --no-color Never color output -h, --help Show this help @@ -48,6 +56,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { color: undefined, follow: false, plain: false, + format: 'text', lines: undefined, minLevel: undefined, include: [], @@ -75,6 +84,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { else if (name === '-g' || name === '--grep') options.include.push(compilePattern(value())); else if (name === '-x' || name === '--exclude') options.exclude.push(compilePattern(value())); else if (argument === '--plain') options.plain = true; + else if (name === '--format') options.format = parseFormat(value()); else if (argument === '--color') options.color = true; else if (argument === '--no-color') options.color = false; else throw new Error(`Unknown option: ${argument}`); @@ -104,3 +114,11 @@ const parseLevel = (text: string): Level => { } return level; }; + +const parseFormat = (text: string): OutputFormat => { + const format = outputFormats.find((candidate) => candidate === text); + if (format === undefined) { + throw new Error(`Unknown format: ${text}. Use one of ${outputFormats.join(', ')}`); + } + return format; +}; diff --git a/src/core/entry.ts b/src/core/entry.ts index 8f54cca..12fd6d8 100644 --- a/src/core/entry.ts +++ b/src/core/entry.ts @@ -1,4 +1,5 @@ import {type EntryLevel, parseLevelName, parseLevelNumber} from './levels.js'; +import {readLogHowlAlert} from './log-howl.js'; import {sanitizeLine} from './sanitize.js'; export interface LogEntry { @@ -41,7 +42,16 @@ export const parseLine = (line: string, context: ParseContext): LogEntry => { raw, ...(context.source === undefined ? {} : {source: context.source}), }; - return {...base, ...(parseJson(raw) ?? parseLogfmt(raw) ?? parseText(raw))}; + const parsed = parseJson(raw) ?? parseLogfmt(raw) ?? parseText(raw); + const alert = parsed.fields === undefined ? undefined : readLogHowlAlert(parsed.fields); + if (alert === undefined) return {...base, ...parsed}; + return { + ...base, + ...parsed, + level: alert.level, + message: alert.message, + ...(context.source === undefined && alert.source !== undefined ? {source: alert.source} : {}), + }; }; type Parsed = Pick; diff --git a/src/core/log-howl.ts b/src/core/log-howl.ts new file mode 100644 index 0000000..0c9047e --- /dev/null +++ b/src/core/log-howl.ts @@ -0,0 +1,37 @@ +import {type EntryLevel, parseLevelName} from './levels.js'; +import {sanitizeLine} from './sanitize.js'; + +/** The schema Log Howl writes on every alert it prints. */ +export const logHowlSchema = 'dispersal-wolves/log-howl-event/v1'; + +export interface LogHowlAlert { + level: EntryLevel; + /** The rule name followed by the matched line, such as `failed-login: Failed password…`. */ + message: string; + /** The name of the log file the line was found in. */ + source?: string; +} + +// Log Howl severities are free-form rule settings; these cover the common scales. +const severities: Record = {low: 'info', medium: 'warn', high: 'error'}; + +/** + * Read an alert printed by Log Howl (github.com/dispersal-wolves/log-howl), so that + * `log-howl watch | relay-logs` shows each alert at its severity with the rule that raised it. + */ +export const readLogHowlAlert = ( + fields: Readonly>, +): LogHowlAlert | undefined => { + if (fields.schema !== logHowlSchema) return undefined; + const {severity, rule, message, source} = fields; + const level = + typeof severity === 'string' + ? (parseLevelName(severity) ?? severities[severity.toLowerCase()] ?? 'unknown') + : 'unknown'; + const text = typeof message === 'string' ? sanitizeLine(message) : ''; + return { + level, + message: typeof rule === 'string' ? `${sanitizeLine(rule)}: ${text}` : text, + ...(typeof source === 'string' ? {source: sanitizeLine(source)} : {}), + }; +}; diff --git a/src/format/cards.ts b/src/format/cards.ts new file mode 100644 index 0000000..369315d --- /dev/null +++ b/src/format/cards.ts @@ -0,0 +1,30 @@ +import type {LogEntry} from '../core/entry.js'; +import {logHowlSchema} from '../core/log-howl.js'; + +/** + * Render an entry as a line Relay Cards (github.com/relay-cli/relay-cards) turns into the + * matching card: errors and fatal entries become error cards that collect the stack frames + * printed after them, warnings become warning cards, and JSON records become JSON cards. + * Log Howl alerts are written as text so the rule name stays in view. The time and source go + * in a leading bracket, which Relay Cards accepts before a level. + */ +export const formatCards = (entry: LogEntry): string => { + // Stack frames and other continuation lines pass through so they attach to the error above. + if (entry.level === 'unknown' && /^\s/.test(entry.raw)) return entry.raw; + const tag = [entry.time, entry.source] + .filter((part) => part !== undefined) + .join(' ') + .replaceAll(']', ')'); + const prefix = tag === '' ? '' : `[${tag}] `; + const record = entry.fields !== undefined && entry.fields.schema !== logHowlSchema; + switch (entry.level) { + case 'fatal': + case 'error': + case 'warn': + return `${prefix}${entry.level.toUpperCase()}: ${entry.message}`; + case 'unknown': + return record ? entry.raw : `${prefix}${entry.raw}`; + default: + return record ? entry.raw : `${prefix}${entry.level.toUpperCase()} ${entry.message}`; + } +}; diff --git a/src/format/json.ts b/src/format/json.ts new file mode 100644 index 0000000..fdcc9bd --- /dev/null +++ b/src/format/json.ts @@ -0,0 +1,13 @@ +import type {LogEntry} from '../core/entry.js'; + +/** + * Render an entry as one JSON object with the fields every log format shares, so mixed text, + * JSON, and logfmt logs come out in one shape for scripts and other tools. + */ +export const formatJson = (entry: LogEntry): string => + JSON.stringify({ + ...(entry.time === undefined ? {} : {time: entry.time}), + ...(entry.level === 'unknown' ? {} : {level: entry.level}), + ...(entry.source === undefined ? {} : {source: entry.source}), + message: entry.message, + }); diff --git a/src/index.ts b/src/index.ts index f666189..501cebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ export * from './core/lines.js'; export * from './core/follow.js'; export * from './core/sanitize.js'; export * from './core/sources.js'; +export * from './core/log-howl.js'; +export * from './format/cards.js'; +export * from './format/json.js'; export * from './format/plain.js'; export * from './viewer/index.js'; export {relayLogsVersion} from './version.js'; diff --git a/tests/pairing.test.ts b/tests/pairing.test.ts new file mode 100644 index 0000000..e23b784 --- /dev/null +++ b/tests/pairing.test.ts @@ -0,0 +1,77 @@ +import {describe, expect, it} from 'vitest'; +import {parseLine} from '../src/core/entry.js'; +import {formatCards} from '../src/format/cards.js'; +import {formatJson} from '../src/format/json.js'; + +const alert = JSON.stringify({ + schema: 'dispersal-wolves/log-howl-event/v1', + time: '2026-10-21T10:00:00+00:00', + rule: 'failed-login', + severity: 'warning', + source: 'auth.log', + message: 'Failed password for invalid user admin', +}); + +describe('Log Howl alerts', () => { + it('reads the severity, rule, and source of an alert', () => { + expect(parseLine(alert, {seq: 1})).toMatchObject({ + level: 'warn', + message: 'failed-login: Failed password for invalid user admin', + source: 'auth.log', + time: '2026-10-21T10:00:00+00:00', + }); + }); + + it('keeps the file label when the alert comes from a file', () => { + expect(parseLine(alert, {seq: 1, source: 'alerts.jsonl'}).source).toBe('alerts.jsonl'); + }); + + it('writes alerts as text cards that keep the rule name', () => { + expect(formatCards(parseLine(alert.replace('"warning"', '"notice"'), {seq: 1}))).toBe( + '[2026-10-21T10:00:00+00:00 auth.log] INFO failed-login: Failed password for invalid user admin', + ); + }); + + it('maps common severity scales', () => { + const high = alert.replace('"warning"', '"high"'); + const notice = alert.replace('"warning"', '"notice"'); + expect(parseLine(high, {seq: 1}).level).toBe('error'); + expect(parseLine(notice, {seq: 1}).level).toBe('info'); + }); +}); + +describe('formatCards', () => { + const cards = (line: string, source?: string) => + formatCards(parseLine(line, {seq: 1, ...(source === undefined ? {} : {source})})); + + it('writes errors and warnings in the form Relay Cards turns into cards', () => { + expect(cards('2026-10-21T10:00:00Z ERROR db down', 'api.log')).toBe( + '[2026-10-21T10:00:00Z api.log] ERROR: db down', + ); + expect(cards('[warn] disk at 91%')).toBe('WARN: disk at 91%'); + expect(cards('FATAL out of memory')).toBe('FATAL: out of memory'); + }); + + it('passes stack frames through so they join the error above', () => { + expect(cards(' at handler (app.js:3:5)')).toBe(' at handler (app.js:3:5)'); + }); + + it('keeps JSON records as JSON unless they are warnings or errors', () => { + expect(cards('{"level":"info","msg":"ready"}')).toBe('{"level":"info","msg":"ready"}'); + expect(cards('{"level":"error","msg":"boom"}')).toBe('ERROR: boom'); + }); + + it('keeps unrecognized lines as they were written', () => { + expect(cards('GET / 200')).toBe('GET / 200'); + expect(cards('GET / 200', 'a]b.log')).toBe('[a)b.log] GET / 200'); + }); +}); + +describe('formatJson', () => { + it('writes the shared fields of any log format', () => { + expect(formatJson(parseLine('12:00:01 INFO ready', {seq: 1, source: 'api.log'}))).toBe( + '{"time":"12:00:01","level":"info","source":"api.log","message":"ready"}', + ); + expect(formatJson(parseLine('plain', {seq: 1}))).toBe('{"message":"plain"}'); + }); +});