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 @@ -5,4 +5,5 @@
- Added the `relay-logs` command, which prints files or standard input colored by level.
- 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 `parseLine`, which recognizes text, JSON, and logfmt lines, and `sanitizeLine`, which removes terminal control sequences.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Relay Logs

Relay Logs reads log files and command output in the terminal. It recognizes the level, timestamp, and message of each line, whether the line is plain text, JSON, or logfmt, and colors it by level.
Relay Logs is a live log viewer for the terminal. It follows files as they grow, recognizes the level, timestamp, and message of each line, whether the line is plain text, JSON, or logfmt, and lets you filter by level and search as lines arrive.

## Read a log

Expand All @@ -11,6 +11,32 @@ npm run dev 2>&1 | relay-logs
```

When several files are given, each line is prefixed with the name of the file it came from. Use `-` to read standard input alongside files.
In a terminal, files open in the interactive viewer described below. When output is piped or the log arrives on standard input, each line is printed colored by its level, prefixed with the name of its file when several files are given. Use `-` to read standard input alongside files.

## View a log

```sh
relay-logs -f api.log worker.log
```

When standard input and output are both terminals, Relay Logs opens an interactive viewer. Each line shows its time, level, source, and message, and the newest line stays in view until you scroll up. Press `?` for the keyboard reference and `q` to quit.

| Key | Action |
| --------------- | ------------------------------------------------------------- |
| `↑`, `k` | Select the previous line and pause following |
| `↓`, `j` | Select the next line; reaching the newest line resumes |
| `PgUp`, `b` | Move up a page |
| `PgDn`, `space` | Move down a page |
| `g`, `G` | Jump to the first line or follow the newest one |
| `f` | Pause or resume following |
| `1`–`6` | Show or hide trace, debug, info, warn, error, and fatal lines |
| `/` | Search with a regular expression and highlight matches |
| `n`, `N` | Jump to the next newer or older match |
| `&` | Show only matching lines, or all lines again |
| `Esc` | Clear the search |
| `c` | Clear the lines read so far |

The viewer keeps the newest 50,000 lines. Output that is piped or redirected, and logs read from standard input, are printed as plain lines instead; `--plain` does the same in a terminal.

## Follow a log

Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ Relay Logs turns lines from files or standard input into log entries and display
3. `parseLine` sanitizes a line and recognizes its level, timestamp, and message. The original text is kept in `raw`, so nothing is lost when a line is not recognized.
4. `formatPlain` prints an entry for pipes and plain terminals.
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.
128 changes: 91 additions & 37 deletions src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,29 @@
#!/usr/bin/env node

import {helpText, parseCliOptions} from './cli/options.js';
import {parseLine} from './core/entry.js';
import {render} from 'ink';
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 {formatPlain} from './format/plain.js';
import {LogBuffer} from './viewer/buffer.js';
import {LogViewer} from './viewer/LogViewer.js';
import {relayLogsVersion} from './version.js';

const main = async (): Promise<void> => {
let options;
try {
options = parseCliOptions(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n\n${helpText}`,
);
process.exitCode = 2;
return;
}
if (options.help) return void process.stdout.write(helpText);
if (options.version) return void process.stdout.write(`${relayLogsVersion}\n`);
if (options.files.length === 0 && process.stdin.isTTY) {
process.stderr.write(`Pass a log file or pipe output into relay-logs.\n\n${helpText}`);
process.exitCode = 2;
return;
}
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

// Stop quietly when the reader goes away, as in `relay-logs app.log | head`.
process.stdout.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EPIPE') process.exit(0);
throw error;
});

const format = {
color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined),
showSource: options.files.length > 1,
};
/** Read the chosen sources and yield the entries that pass the command-line filters. */
async function* readEntries(
options: CliOptions,
signal: AbortSignal | undefined,
onError: (source: LogSource, error: unknown) => void,
): AsyncGenerator<LogEntry> {
const sources = openSources(options.files, {
follow: options.follow,
...(options.lines === undefined ? {} : {lines: options.lines}),
...(signal === undefined ? {} : {signal}),
});
const onError = (source: LogSource, error: unknown): void => {
process.stderr.write(
`relay-logs: ${source.label ?? 'stdin'}: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
};
const lines = options.follow ? mergeSources(sources, onError) : concatSources(sources, onError);
const shown = createFilter({
...(options.minLevel === undefined ? {} : {minLevel: options.minLevel}),
Expand All @@ -56,9 +34,85 @@ const main = async (): Promise<void> => {
for await (const {label, line} of lines) {
seq += 1;
const entry = parseLine(line, {seq, ...(label === undefined ? {} : {source: label})});
if (!shown(entry)) continue;
if (shown(entry)) yield entry;
}
}

const runPlain = async (options: CliOptions): Promise<void> => {
// Stop quietly when the reader goes away, as in `relay-logs app.log | head`.
process.stdout.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EPIPE') process.exit(0);
throw error;
});
const format = {
color: options.color ?? (process.stdout.isTTY && process.env.NO_COLOR === undefined),
showSource: options.files.length > 1,
};
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`);
}
};

const runViewer = async (options: CliOptions): Promise<void> => {
const controller = new AbortController();
const buffer = new LogBuffer();
const failures: string[] = [];
const app = render(
<LogViewer
buffer={buffer}
title={options.files.join(', ')}
showSource={options.files.length > 1}
onQuit={() => app.unmount()}
/>,
{alternateScreen: true, exitOnCtrlC: false},
);
const onError = (source: LogSource, error: unknown): void => {
failures.push(`relay-logs: ${source.label ?? 'stdin'}: ${errorMessage(error)}`);
};
void (async () => {
for await (const entry of readEntries(options, controller.signal, onError)) buffer.push(entry);
})().catch((error: unknown) => failures.push(`relay-logs: ${errorMessage(error)}`));

const interrupt = (): void => app.unmount();
process.once('SIGINT', interrupt);
process.once('SIGTERM', interrupt);
await app.waitUntilExit();
controller.abort();
for (const failure of failures) process.stderr.write(`${failure}\n`);
// Standard input may still be open, so leave instead of waiting for the readers.
process.exit(failures.length > 0 ? 1 : 0);
};

const main = async (): Promise<void> => {
let options;
try {
options = parseCliOptions(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${errorMessage(error)}\n\n${helpText}`);
process.exitCode = 2;
return;
}
if (options.help) return void process.stdout.write(helpText);
if (options.version) return void process.stdout.write(`${relayLogsVersion}\n`);
if (options.files.length === 0 && process.stdin.isTTY) {
process.stderr.write(`Pass a log file or pipe output into relay-logs.\n\n${helpText}`);
process.exitCode = 2;
return;
}

// The viewer needs the keyboard, so it opens only when standard input is a terminal rather
// than the log itself.
const interactive =
!options.plain &&
process.stdout.isTTY &&
process.stdin.isTTY &&
options.files.length > 0 &&
!options.files.includes('-');
await (interactive ? runViewer(options) : runPlain(options));
};

await main();
9 changes: 7 additions & 2 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface CliOptions {
color: boolean | undefined;
/** Keep reading files as they grow. */
follow: boolean;
/** Print lines instead of opening the interactive viewer. */
plain: boolean;
/** Start with only the last lines of each file. */
lines: number | undefined;
minLevel: Level | undefined;
Expand All @@ -22,8 +24,8 @@ Usage:
relay-logs [options] [file...]
some-command | relay-logs [options]

Reads log files, or standard input when no file is given, and prints each line colored by its
level.
Opens log files in an interactive viewer with search and level filters. When output is
redirected or the log arrives on standard input, prints each line colored by its level instead.

Options:
-f, --follow Keep printing lines as files grow, across rotation and truncation
Expand All @@ -32,6 +34,7 @@ Options:
error, fatal)
-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
--color Always color output
--no-color Never color output
-h, --help Show this help
Expand All @@ -44,6 +47,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => {
version: false,
color: undefined,
follow: false,
plain: false,
lines: undefined,
minLevel: undefined,
include: [],
Expand All @@ -70,6 +74,7 @@ export const parseCliOptions = (arguments_: readonly string[]): CliOptions => {
else if (name === '-l' || name === '--level') options.minLevel = parseLevel(value());
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 (argument === '--color') options.color = true;
else if (argument === '--no-color') options.color = false;
else throw new Error(`Unknown option: ${argument}`);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from './core/follow.js';
export * from './core/sanitize.js';
export * from './core/sources.js';
export * from './format/plain.js';
export * from './viewer/index.js';
export {relayLogsVersion} from './version.js';
37 changes: 37 additions & 0 deletions src/viewer/HelpOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {Box, Text} from 'ink';
import {theme} from './theme.js';

const shortcuts: readonly [string, string][] = [
['↑ / k', 'select the previous line'],
['↓ / j', 'select the next line'],
['PgUp / b', 'move up a page'],
['PgDn / space', 'move down a page'],
['g / G', 'jump to the first line / follow the newest line'],
['f', 'pause or resume following'],
['1–6', 'show or hide trace, debug, info, warn, error, and fatal lines'],
['/', 'search; the pattern is a case-insensitive regular expression'],
['n / N', 'jump to the next newer / older match'],
['&', 'show only matching lines, or all lines again'],
['esc', 'clear the search'],
['c', 'clear the lines read so far'],
['q', 'quit'],
['?', 'open or close this help'],
];

export const HelpOverlay = () => (
<Box borderStyle="double" borderColor={theme.blue} flexDirection="column" paddingX={2}>
<Text bold color={theme.text}>
Relay Logs controls
</Text>
<Box flexDirection="column" marginTop={1}>
{shortcuts.map(([key, description]) => (
<Box key={key} gap={2}>
<Box width={14}>
<Text color={theme.orange}>{key}</Text>
</Box>
<Text color={theme.muted}>{description}</Text>
</Box>
))}
</Box>
</Box>
);
Loading
Loading