Streaming journald Logs to the Browser with SSE: A No-Agent Log Tail in 40 Lines
These articles are AI-generated summaries. Please check the original sources for full details.
Streaming journald logs to the browser with SSE
Dusan Malusev built a real-time log tail in his admin panel by spawning journalctl -f -o cat and piping JSON lines over Server-Sent Events. The entire server-side implementation is roughly forty lines of TypeScript, relying on systemd’s default stdout capture and no external agents.
Why This Matters
Most production setups overcomplicate log streaming with dedicated shippers (e.g., Filebeat, Fluentd), brokers (Kafka), or vendor agents—each adding deployment cost, maintenance overhead, and attack surface. By contrast, using journald’s built-in rotation, filtering, and structured output with a simple SSE endpoint eliminates those layers entirely. This approach is free on any systemd distro (Ubuntu, Debian, Fedora) where the service already writes to stdout; the only additional setup is adding the app user to the systemd-journal group. For teams running small-to-medium workloads who want raw live logs without infrastructure tax, this pattern avoids hours of configuration and ongoing upkeep.
Key Insights
-
- journald captures stdout/stderr from any systemd unit automatically—no socket or library needed—and rotates logs by size/age without logrotate (Ubuntu default behavior).
-
journalctl -o catoutputs only the MESSAGE field, which in this case is already pino JSON; combining with-fand-n 0gives a live stream of structured log entries with no wrapper parsing required.
-
- Server-Sent Events are preferred over WebSockets here because data flows one direction (server→browser) and EventSource provides automatic reconnection—a feature built into browsers since ~2011—eliminating client-side reconnect logic.
-
- Three deployment pitfalls cost real debug time: missing
systemd-journalgroup permissions cause silent failures; nginx buffering delays SSE responses by ~30 seconds unless disabled viax-accel-buffering: no; idle proxies drop quiet connections unless heartbeat pings (: ping\n\n) are sent every 25 seconds.
- Three deployment pitfalls cost real debug time: missing
Working Examples
// src/lib/server/admin/logs.ts (followLogs)
const child = spawn('journalctl', [
'-u', unit,
'-o', 'cat', // just the MESSAGE field, which is our pino JSON
'-f', // follow
'-n', '0', // no backlog, only new lines from now
'--no-pager'
]);
let buf = '';
child.stdout?.setEncoding('utf8');
child.stdout?.on('data', (chunk: string) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line) continue;
const parsed = parseLine(line);
if (!parsed) continue;
const ev = toStreamEvent(parsed);
if (passesFilter(ev, filter)) onEvent(ev);
}
});
type WriterUint8Array> = WritableStreamDefaultWriter<Uint8Array> | {
enqueue(value: Uint8Array): void;
cancel(reason?: any): void;
error(e?: any): void;
abort(reason?: any): Promise<void> | void;
getWriter(): Writer<Uint8Array>;
isClosed: boolean;
isErrored: boolean;
isAborted: boolean;
signal?: AbortSignal;
delegate(delegateOptions?: StreamPipeOptions): Promise<void> | undefined; // not standard but used by sveltekit?
one(name: string | symbol): (...args: any[]) => void;
eventTarget?: EventTarget | null;
type?: string | undefined;
pipedFrom?: ReadableStream<any> | null;
pipedTo?: WritableStream<any> | null;
pipedThrough?: TransformStream<any, any> | null;
disturbed?: boolean;
isDisturbed(): boolean;
cancelPendingAbortController() :void ;
getReader(options? : { mode? : "byob" } ) : ReadableStreamBYOBReader ; getReader(options? : { mode? : "bytes" } ) : ReadableStreamDefaultReader ; pipeThrough<T>(transform : { writable : WritableStream<T>, readable : ReadableStream<T> }, options? : PipeOptions ) : ReadableStream<T> ; pipeTo(dest : WritableStream<Uint8Array>, options? : PipeOptions ) : Promise<void> ; tee() : [ReadableStream<Uint8Array>,ReadableStream<Uint8Array>] ; values(options? : { preventCancel? : boolean } ) : AsyncIterableIterator<Uint8Array> ; [Symbol.asyncIterator](options? : { preventCancel? : boolean } ) : AsyncIterableIterator<Uint8Array>
export function followLogs(
filters: Filters,
onEvent: (ev: LogEvent) => void,
onError: () => void
): FollowHandle {
try{
typescript/lang=typescript&linenums=true#L4-L26}
export async function GET({ url }: RequestEvent): Promise >> {
try{...} catch(e){...}
export async function POST(event: RequestEvent){...}
export const GET: RequestHandler = async ({ url }) => {
def requireAdmin(requestEvent) {
try{
typescript/lang=typescript&linenums=true#L1-L32}
export async function GET({ url }: RequestEvent): Promise >> {
try{...}catch(e){...}
export async function POST(event: RequestEvent){...}
Client-side handler for receiving named events via EventSource.
// src/routes/admin/logs/+page.svelte
let es: EventSource | null = null
function connect() {
es?.close();
es = new EventSource(url)
es.addEventListener('ready', handleReady)
es.addEventListener('log', handleLog)
es.addEventListener('error', handleError)
es.onmessage=null}
es!.addEventListener('log' , e=>[...events].slice(0,capped).push(e.data));
Practical Applications
References:
- From internal analysis
Continue reading
Next article
The First Digital Camera Was Built in 1975 – A Lesson for IoT Engineers
Related Content
GitHub Actions SEO: How to Gate PRs on Broken Links and Schema Validation
Four automated CI checks—broken links, meta tags, JSON-LD, and Lighthouse budget—block PR merges until all pass, catching SEO bugs that code review misses.
Deploying a Task Automation App: Common Pitfalls and a Streamlined Checklist
A developer details their first production deployment, highlighting common issues like relative paths, SPA routing, and build configuration errors.
🚀My First Portfolio Deployment with Nginx on Killercoda: A Step-by-Step DevOps Walkthrough
This guide details deploying a portfolio website using Killercoda and Nginx, achieving a live site in under 10 steps.