Skip to main content

On This Page

Streaming journald Logs to the Browser with SSE: A No-Agent Log Tail in 40 Lines

4 min read
Share

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 cat outputs only the MESSAGE field, which in this case is already pino JSON; combining with -f and -n 0 gives 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-journal group permissions cause silent failures; nginx buffering delays SSE responses by ~30 seconds unless disabled via x-accel-buffering: no; idle proxies drop quiet connections unless heartbeat pings (: ping\n\n) are sent every 25 seconds.

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