Building a Log Aggregation Viewer in React

How to build a log aggregation viewer with filtering, search, real-time log tailing, and structured log parsing — with patterns from Klivvr's Web Ops Console.

technical5 min readBy Klivvr Engineering
Share:

Logs are the primary debugging tool during incidents. When a service is misbehaving, the first thing an engineer does is check the logs. But raw logs from multiple services, environments, and instances create a firehose of text that is impossible to parse without proper tooling. A log aggregation viewer transforms raw log data into a searchable, filterable, and real-time debugging interface.

This article covers the log viewer implementation in Klivvr's Web Ops Console.

Log Data Model

Structured logs provide consistent fields that enable filtering and search. Every log entry in Klivvr's systems follows a standard schema.

interface LogEntry {
  id: string;
  timestamp: string;
  level: "debug" | "info" | "warn" | "error" | "fatal";
  service: string;
  instance: string;
  message: string;
  traceId?: string;
  spanId?: string;
  metadata: Record<string, unknown>;
}
 
interface LogQuery {
  services?: string[];
  levels?: LogEntry["level"][];
  search?: string;
  traceId?: string;
  startTime?: string;
  endTime?: string;
  limit: number;
  offset: number;
}
 
interface LogResponse {
  entries: LogEntry[];
  total: number;
  hasMore: boolean;
}

Log Viewer Component

The log viewer combines search, filters, and a virtualized log list for performance.

import { useState, useCallback, useRef, useEffect } from "react";
 
function LogViewer() {
  const [query, setQuery] = useState<LogQuery>({
    limit: 100,
    offset: 0,
  });
  const [logs, setLogs] = useState<LogEntry[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isTailing, setIsTailing] = useState(false);
 
  const fetchLogs = useCallback(async (q: LogQuery) => {
    setIsLoading(true);
    try {
      const params = new URLSearchParams();
      if (q.services?.length) params.set("services", q.services.join(","));
      if (q.levels?.length) params.set("levels", q.levels.join(","));
      if (q.search) params.set("search", q.search);
      if (q.traceId) params.set("traceId", q.traceId);
      if (q.startTime) params.set("startTime", q.startTime);
      if (q.endTime) params.set("endTime", q.endTime);
      params.set("limit", String(q.limit));
      params.set("offset", String(q.offset));
 
      const response = await fetch(`/api/logs?${params}`);
      const data: LogResponse = await response.json();
      setLogs((prev) => (q.offset === 0 ? data.entries : [...prev, ...data.entries]));
    } finally {
      setIsLoading(false);
    }
  }, []);
 
  useEffect(() => {
    fetchLogs(query);
  }, [query, fetchLogs]);
 
  return (
    <div className="flex h-full flex-col">
      <LogFilters
        query={query}
        onChange={(updates) => setQuery({ ...query, ...updates, offset: 0 })}
      />
      <LogToolbar
        isTailing={isTailing}
        onToggleTail={() => setIsTailing(!isTailing)}
        onClear={() => setLogs([])}
        resultCount={logs.length}
      />
      <LogList
        entries={logs}
        isLoading={isLoading}
        isTailing={isTailing}
        onLoadMore={() =>
          setQuery((q) => ({ ...q, offset: q.offset + q.limit }))
        }
      />
    </div>
  );
}

Filter Panel

The filter panel provides controls for narrowing down log output by service, level, and time range.

function LogFilters({ query, onChange }: {
  query: LogQuery;
  onChange: (updates: Partial<LogQuery>) => void;
}) {
  const [searchInput, setSearchInput] = useState(query.search ?? "");
 
  const handleSearch = () => {
    onChange({ search: searchInput || undefined });
  };
 
  return (
    <div className="flex flex-wrap items-center gap-3 border-b bg-gray-50 px-4 py-3">
      <div className="flex-1">
        <input
          type="text"
          placeholder="Search logs... (supports regex)"
          value={searchInput}
          onChange={(e) => setSearchInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSearch()}
          className="w-full rounded border px-3 py-1.5 text-sm font-mono"
        />
      </div>
 
      <ServiceSelector
        selected={query.services ?? []}
        onChange={(services) => onChange({ services })}
      />
 
      <LevelSelector
        selected={query.levels ?? []}
        onChange={(levels) => onChange({ levels })}
      />
 
      <TimeRangeSelector
        startTime={query.startTime}
        endTime={query.endTime}
        onChange={(start, end) => onChange({ startTime: start, endTime: end })}
      />
 
      <button onClick={handleSearch} className="btn-primary text-sm">
        Search
      </button>
    </div>
  );
}
 
function LevelSelector({ selected, onChange }: {
  selected: LogEntry["level"][];
  onChange: (levels: LogEntry["level"][]) => void;
}) {
  const levels: Array<{ value: LogEntry["level"]; color: string }> = [
    { value: "debug", color: "bg-gray-100 text-gray-600" },
    { value: "info", color: "bg-blue-100 text-blue-700" },
    { value: "warn", color: "bg-yellow-100 text-yellow-700" },
    { value: "error", color: "bg-red-100 text-red-700" },
    { value: "fatal", color: "bg-red-200 text-red-900" },
  ];
 
  const toggle = (level: LogEntry["level"]) => {
    if (selected.includes(level)) {
      onChange(selected.filter((l) => l !== level));
    } else {
      onChange([...selected, level]);
    }
  };
 
  return (
    <div className="flex gap-1">
      {levels.map(({ value, color }) => (
        <button
          key={value}
          onClick={() => toggle(value)}
          className={`rounded px-2 py-1 text-xs font-medium ${
            selected.includes(value) ? color : "bg-gray-50 text-gray-400"
          }`}
        >
          {value.toUpperCase()}
        </button>
      ))}
    </div>
  );
}

Log Entry Rendering

Each log entry is rendered with syntax highlighting, expandable metadata, and clickable trace IDs for distributed tracing.

function LogEntryRow({ entry }: { entry: LogEntry }) {
  const [expanded, setExpanded] = useState(false);
 
  const levelColors: Record<string, string> = {
    debug: "text-gray-400",
    info: "text-blue-500",
    warn: "text-yellow-500",
    error: "text-red-500",
    fatal: "text-red-700 font-bold",
  };
 
  return (
    <div
      className={`border-b px-4 py-1.5 font-mono text-xs hover:bg-gray-50 cursor-pointer ${
        entry.level === "error" || entry.level === "fatal" ? "bg-red-50/30" : ""
      }`}
      onClick={() => setExpanded(!expanded)}
    >
      <div className="flex gap-3">
        <span className="text-gray-400 shrink-0">
          {new Date(entry.timestamp).toISOString().slice(11, 23)}
        </span>
        <span className={`w-12 shrink-0 uppercase ${levelColors[entry.level]}`}>
          {entry.level}
        </span>
        <span className="text-purple-600 shrink-0 w-28 truncate">
          {entry.service}
        </span>
        <span className="text-gray-800 flex-1 truncate">{entry.message}</span>
        {entry.traceId && (
          <a
            href={`/traces/${entry.traceId}`}
            onClick={(e) => e.stopPropagation()}
            className="text-blue-500 hover:underline shrink-0"
          >
            {entry.traceId.slice(0, 8)}
          </a>
        )}
      </div>
 
      {expanded && Object.keys(entry.metadata).length > 0 && (
        <div className="mt-2 ml-20 rounded bg-gray-100 p-2 text-gray-700">
          <pre>{JSON.stringify(entry.metadata, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

Real-Time Log Tailing

Log tailing streams new entries as they arrive, keeping the view at the bottom of the log — the way tail -f works in a terminal.

function useLogTailing(
  query: LogQuery,
  enabled: boolean,
  onNewEntries: (entries: LogEntry[]) => void
) {
  const { subscribe } = useWebSocket();
 
  useEffect(() => {
    if (!enabled) return;
 
    return subscribe("logs.stream", (data: unknown) => {
      const entry = data as LogEntry;
 
      // Apply client-side filters
      if (query.services?.length && !query.services.includes(entry.service)) return;
      if (query.levels?.length && !query.levels.includes(entry.level)) return;
      if (query.search && !entry.message.includes(query.search)) return;
 
      onNewEntries([entry]);
    });
  }, [enabled, query, subscribe, onNewEntries]);
}

Conclusion

A log aggregation viewer is one of the most critical tools in an operations dashboard. It is the first place engineers look during incidents and the primary interface for understanding system behavior. The Web Ops Console's log viewer combines structured log parsing, multi-dimensional filtering, real-time tailing, and expandable metadata into an interface that makes debugging fast and intuitive. When an incident occurs, the operations team can filter to the relevant service, search for error patterns, and trace individual requests across services — all from a single view.

Related Articles

business

Build vs Buy for Internal Operations Tools

A framework for deciding whether to build or buy internal operations tools, covering total cost of ownership, customization needs, and the strategic value of purpose-built tooling.

6 min read
technical

Data Visualization Patterns for Ops Dashboards

How to choose and implement effective data visualizations for operations dashboards, covering chart selection, color systems, responsive layouts, and accessibility.

5 min read