Skip to content
KONIGI

Dashboards / Visual representation / Log tail

12 of 22

Log tail

Something is happening now and the viewer needs to watch the raw events as they arrive.

Updated September 10, 2026

Problem

The deploy went out ninety seconds ago and something is wrong. Aggregates are five minutes behind and averaged into uselessness. The person watching needs the actual events, now, in order.

Solution

A stream of raw lines, newest arriving live, with enough structure layered on that a human can survive the volume. The ancestor is tail -f and the pattern has never really improved on its core promise: no aggregation, no delay, just what happened.

Everything interesting is in what gets added on top without breaking that promise.

Grafana’s Explore is a good inventory of the necessary additions. Live tail streams new lines, and the detail that makes it usable is that new logs appear at the bottom with a contrasting background so the eye can track what is new against what was already there. Log level colouring recognises seven levels—critical, error, warning, info, debug, trace, unknown—from a level label, which turns a wall of monospace into something scannable. Deduplication offers None, Exact (matching whole lines, dates excluded), Numbers (ignoring numeric values) and Signature, the most aggressive. That last set matters more than it sounds: a retry loop emitting the same line four hundred times will otherwise flush every other event off the screen in seconds.

The other necessary addition is a way out. A log line is evidence, and evidence is only useful attached to a request. Explore correlates logs with metrics, traces and profiles side by side, and carries compatible labels across when you switch data source. A tail without a route to the trace ID is a wall of text you have to leave in order to use.

Use when

Something is happening right now, the aggregate view has already told you roughly where, and you need the specifics. Deploys, incidents, and reproducing a bug against a known request.

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

Live tail is the only pattern here that punishes the viewer for looking away, and past a few hundred lines a minute it is unreadable by construction. Auto-scroll fights with the reader: pause and you fall behind, don’t pause and you cannot read anything long enough to understand it. Retention is expensive, so the window you can tail is usually much shorter than the window you can aggregate. And the tail is deceptively persuasive—a vivid error scrolling past feels like the cause, and is often a downstream symptom that started later than whatever actually broke.

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 is where a promising log line should lead. Filter bar is what makes a high-volume stream survivable. Search across panels is the same need at rest rather than live. Freshness indicator answers whether a quiet stream is quiet or broken. Error and stale state is what a stalled tail should be showing instead of nothing.

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

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / Logs Panel September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The arrangement is the standard one and it is right: a stacked volume chart by log level across the top, then the raw stream underneath, so the shape of the traffic and the individual lines share one page. Two details are worth stopping on. Every line carries two timestamps in two timezones—the gutter reads 2026-09-10 18:36:58.435 in the browser's local time, and the JSON three characters later reads 2026-09-11T01:36:58.434956621Z in UTC. Same instant, seven hours apart, on the same row, and nothing labels either one. The second is the trace ID in the expanded panel at the bottom, which is the thing that makes a log line usable: it is the route from a line of text to the request it came from. Two of the three entries shown carry the identical trace ID, which is also the case deduplication exists for.
  • Stacked composition Volume by level over time. The bottom band sits on a flat baseline; the two above it are approximate.
  • Log tail Raw lines, truncated at the right edge, with two timestamps in two timezones on every row.
  • Drill-down The trace ID: the route out of the wall of text and into the request that produced it.