Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Log tail

‹ 12 of 22 ›

Log tail

People need to inspect individual events as they arrive.

Updated September 10, 2026

Problem

People investigating a live problem need individual events as they arrive, beyond what aggregate charts can show.

Solution

Display incoming log events in time order with readable timestamps and severity labels. Distinguish new arrivals without making the whole view flash.

Let people pause scrolling to read, then return to the latest events. Provide filters and optional grouping of repeated messages, making any hidden duplicates clear.

Link relevant events to their request, trace, or other detail. Show connection and data freshness so a quiet stream can be distinguished from a broken one.

Use when

People are investigating something happening now and need the specific events behind a broader signal.

Don’t use when

The question is about frequency or trend. Watching a tail to judge how often something happens is a way to be badly wrong with high confidence; count it instead. And never as a monitoring surface, because a stream nobody is watching is not monitoring.

Trade-offs

Fast streams become difficult to read, and pausing leaves people behind the newest events. Retention may limit how far back they can inspect. A prominent error may be a symptom rather than the cause of the problem.

Checklist

  • Is new content visually distinct from what was already on screen?
  • What happens above a few hundred lines a second, and does the UI say it is dropping?
  • Can the viewer pause without losing the buffer, and resume without losing their place?
  • Is there level colouring, and does it come from a real field rather than a regex on the text?
  • Is deduplication available, and does the viewer know it is on?
  • Can a line lead to the trace, the host, and the deploy?
  • Is the timestamp shown in a stated timezone, and does it match the charts nearby?
  • Can the viewer filter without leaving the stream and losing the tail?
  • How far back does the tail buffer go before it becomes a search instead?
  • Does a stalled stream look different from a quiet one?

Compare

Grafana Explore treats the tail as one signal among several and invests in the crossings: contrasting backgrounds for new lines, level colouring from a label, four deduplication modes, and label continuity when switching to metrics or traces. Honeycomb rejects the line-oriented framing, treating a log as a wide structured event so that what would be tailing becomes querying, which is better for questions and worse for the case where you genuinely do not yet know what to ask. Sentry never shows a raw stream at all; events are grouped into issues on arrival, which is the right default for errors and hides exactly the low-level noise a tail exists to surface. Netdata keeps a systemd journal view per node, so the tail is scoped to a machine you already suspect rather than to the estate.

Trace waterfall follows a request behind a log event. Filter bar narrows the stream. Search across panels helps locate relevant dashboard content.

Freshness indicator shows whether events are arriving. Error and stale state marks a stalled or failed connection.

Log tail anatomy A live stream of raw lines with level colouring down the left gutter, four hundred identical retry lines collapsed into one row with a count, newly arrived lines marked at the bottom, and a trace ID on each line that leads out of the wall of text. No aggregation, no delay, plus what makes it survivable live tail dedup: exact 4a91c2 7fe310 b20d84 ×412 b20d84 c81f05 d40a71 e15b93 1 2 3 4 1 LEVEL IN THE GUTTER Seven levels read off a label, which turns a wall of monospace into something scannable. 2 DEDUPLICATION A retry loop emitting the same line four hundred times flushes every other event off the screen in seconds. 3 NEW ARRIVES MARKED New lines land at the bottom on a contrasting ground, so the eye can track what just came in. 4 A WAY OUT A tail with no route to the trace ID is a wall of text you have to leave in order to use.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Dense monospace text that has to stay scannable while it moves. Severity is the only colour, timestamps are fixed width, and the scroll detaches the moment a reader touches it.

shadcn
npx shadcn@latest add button
Tokens
--card--card-foreground--muted-foreground--border--status-critical--status-warn--status-nominal--status-unknown--state-live
dedup: exactUTC
  • 09:00:10.880GET /checkout/summary 200 41ms
  • 09:00:11.020cart total recomputed: shipping rule 7 changed after quote
  • 09:00:11.390upstream connect error: payments-v2 refused connection
  • 09:00:11.400retrying payments-v2 in 5ms×412
  • 09:00:13.480GET /checkout/summary 200 38ms
  • 09:00:13.860POST /checkout/confirm 201 212ms
  • 09:00:13.978GET /orders/8813 200 30ms

LogTail.tsxLevel in the gutter from a real field, dedup that folds a retry loop into one row with a count, new arrivals on a contrasting ground, and a trace ID that leads out.

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

/** The seven levels Grafana reads off a `level` label. Colour comes from the
 *  field, never from a regex over the text. */
export type Level = "critical" | "error" | "warning" | "info" | "debug" | "trace" | "unknown";

export type LogLine = { ts: Date; level: Level; message: string; trace: string };

/** Grafana's four. Exact matches whole lines with the date excluded. */
export type Dedup = "none" | "exact" | "numbers" | "signature";

type Props = {
  lines: LogLine[];
  dedup: Dedup;
  live: boolean;
  onToggleLive: () => void;
  /** Lines that arrived after this are drawn on a contrasting ground, so the
   *  eye can find what is new against what was already there. */
  readThrough: Date;
  /** The way out. A line is evidence, and evidence attaches to a request. */
  onOpenTrace: (trace: string) => void;
};

const GUTTER: Record<Level, string> = {
  critical: "border-status-critical", error: "border-status-critical", warning: "border-status-warn",
  info: "border-status-nominal", debug: "border-muted-foreground", trace: "border-muted-foreground", unknown: "border-status-unknown",
};

/** Fixed width, stated timezone. 09:00:12.041 lines up under 09:00:12.978. */
const stamp = (d: Date) =>
  `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}.${String(d.getUTCMilliseconds()).padStart(3, "0")}`;

const key = (line: LogLine, dedup: Dedup) => {
  if (dedup === "none") return null;
  const text = `${line.level} ${line.message}`;
  if (dedup === "exact") return text;
  if (dedup === "numbers") return text.replace(/\d+/g, "#");
  return text.replace(/\S*\d\S*/g, "#");
};

/** Consecutive lines with the same key collapse into the first, with a count. */
const collapse = (lines: LogLine[], dedup: Dedup) => {
  const rows: { line: LogLine; count: number }[] = [];
  for (const line of lines) {
    const last = rows[rows.length - 1];
    const k = key(line, dedup);
    if (last && k !== null && k === key(last.line, dedup)) last.count++;
    else rows.push({ line, count: 1 });
  }
  return rows;
};

export function LogTail({ lines, dedup, live, onToggleLive, readThrough, onOpenTrace }: Props) {
  const rows = collapse(lines, dedup);
  return (
    <div className="w-full max-w-[480px] overflow-hidden rounded-lg border bg-card">
      <div className="flex items-center gap-2 border-b p-3 text-[10px]">
        <Button
          variant="ghost" size="sm" onClick={onToggleLive} aria-pressed={live}
          className={cn("h-5 rounded-full px-2 text-[10px]", live ? "bg-state-live/20 text-state-live" : "border text-muted-foreground")}
        >
          {live ? "live tail" : "paused"}
        </Button>
        <span className="rounded-md border px-2 py-0.5 text-muted-foreground">dedup: {dedup}</span>
        <span className="ml-auto text-muted-foreground">UTC</span>
      </div>
      <ul className="m-0 list-none p-0 font-mono text-[10px]" aria-live={live ? "polite" : "off"}>
        {rows.map(({ line, count }, i) => {
          const fresh = line.ts > readThrough;
          const first = fresh && (i === 0 || rows[i - 1].line.ts <= readThrough);
          return (
            <li
              key={`${line.ts.getTime()}-${i}`}
              className={cn("flex items-center gap-3 border-l-4 py-1.5 pl-3 pr-3", GUTTER[line.level], fresh && "bg-state-live/10", first && "shadow-[0_-1px_0_hsl(var(--border))]")}
            >
              <span className="shrink-0 tabular-nums text-muted-foreground">{stamp(line.ts)}</span>
              <span className="min-w-0 flex-1 truncate text-card-foreground">{line.message}</span>
              {count > 1 && <span className="shrink-0 rounded bg-status-warn/20 px-1.5 py-0.5 text-status-warn">×{count}</span>}
              <button type="button" onClick={() => onOpenTrace(line.trace)} className="ml-auto shrink-0 text-muted-foreground underline-offset-2 hover:underline" aria-label={`open trace ${line.trace}`}>
                {line.trace}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

demo.tsxHow it is called: 418 lines, 412 of them one retry, exact dedup on, two lines newer than the viewer's last look.

import { useState } from "react";
import { LogTail, type LogLine } from "./LogTail";

/**
 * Ninety seconds after a deploy. One request fails against the upstream and
 * retries 412 times; exact dedup folds that into one row. The last two lines
 * arrived after the viewer last looked, so they sit on the live ground.
 */
const NOW = new Date("2026-09-15T09:00:14.000Z");
const at = (msAgo: number) => new Date(NOW.getTime() - msAgo);

const LINES: LogLine[] = [
  { ts: at(3_120), level: "info", message: "GET /checkout/summary 200 41ms", trace: "4a91c2" },
  { ts: at(2_980), level: "warning", message: "cart total recomputed: shipping rule 7 changed after quote", trace: "7fe310" },
  { ts: at(2_610), level: "error", message: "upstream connect error: payments-v2 refused connection", trace: "b20d84" },
  ...Array.from({ length: 412 }, (_, i) => ({
    ts: at(2_600 - i * 5), level: "error" as const, message: "retrying payments-v2 in 5ms", trace: "b20d84",
  })),
  { ts: at(520), level: "info", message: "GET /checkout/summary 200 38ms", trace: "c81f05" },
  { ts: at(140), level: "info", message: "POST /checkout/confirm 201 212ms", trace: "d40a71" },
  { ts: at(22), level: "info", message: "GET /orders/8813 200 30ms", trace: "e15b93" },
];

export default function Demo() {
  const [live, setLive] = useState(true);
  return (
    <LogTail
      lines={LINES}
      dedup="exact"
      live={live}
      onToggleLive={() => setLive(!live)}
      readThrough={at(500)}
      onOpenTrace={() => {}}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Logs Panel September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
A stacked volume chart sits above the individual log events, linking the overall pattern to specific lines. Each row shows a local timestamp and a UTC timestamp, but their timezones are not clearly labeled. That can make the same instant look like two different times. Expanded event details include a trace ID, providing a route to the request behind the message.
  • Stacked composition Stacked log counts show volume by level over time.
  • Log tail Log rows are truncated and contain timestamps in two timezones.
  • Drill-down The trace ID connects the log event to its request.