Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`, `<info>`, 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.

Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 11 additions & 2 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,16 +46,22 @@ const runPlain = async (options: CliOptions): Promise<void> => {
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`);
}
};

Expand Down Expand Up @@ -108,6 +116,7 @@ const main = async (): Promise<void> => {
// than the log itself.
const interactive =
!options.plain &&
options.format === 'text' &&
process.stdout.isTTY &&
process.stdin.isTTY &&
options.files.length > 0 &&
Expand Down
18 changes: 18 additions & 0 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -35,6 +41,8 @@ Options:
-g, --grep <re> Show only lines matching this pattern; repeat to match any of several
-x, --exclude <re> Hide lines matching this pattern; may be repeated
--plain Print lines instead of opening the viewer
--format <fmt> 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
Expand All @@ -48,6 +56,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => {
color: undefined,
follow: false,
plain: false,
format: 'text',
lines: undefined,
minLevel: undefined,
include: [],
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
};
12 changes: 11 additions & 1 deletion src/core/entry.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<LogEntry, 'level' | 'message' | 'time' | 'fields'>;
Expand Down
37 changes: 37 additions & 0 deletions src/core/log-howl.ts
Original file line number Diff line number Diff line change
@@ -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<string, EntryLevel> = {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<Record<string, unknown>>,
): 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)} : {}),
};
};
30 changes: 30 additions & 0 deletions src/format/cards.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
};
13 changes: 13 additions & 0 deletions src/format/json.ts
Original file line number Diff line number Diff line change
@@ -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,
});
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
77 changes: 77 additions & 0 deletions tests/pairing.test.ts
Original file line number Diff line number Diff line change
@@ -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"}');
});
});
Loading