tutorial
Build an observability log explorer: a million rows, live tail
Last updated 5 September 2026
A log explorer is the tool you reach for when something is on fire: it holds far more rows than fit on a screen, it keeps arriving while you read it, you narrow it fast to the thing you care about, and when you find the cause you send someone a link that lands them exactly where you are. This tutorial builds that: a million rows, a live tail, quick and set filters with facets, an expand pane, and a saved view that travels in the URL, with no backend to run.
Open the finished explorer in the sandbox
The problem: scale you can move through
A million log lines is not a data structure problem so much as a rendering one: paint them all and the page dies, so the grid paints only the rows in view and swaps them as you scroll. On top of that, three things make an explorer usable rather than merely large. It keeps up with a feed without stuttering. It narrows in one gesture, whether you know the exact field or just a word to grep for. And the state you arrived at is something you can hand to someone else. All three are built in, so the explorer below is a configuration, not a rendering engine.
Set up the page
Load the grid from the CDN with one script tag. No build step and no import:
the grid is on the global LatticeGrid. On localhost it is free
to use; a deployed site is licensed per domain, which the sandbox already
carries for you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.js"></script>
Lay out a filter bar and an element for the grid.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #0e1420; color: #d8dee9; }
.bar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
.bar input { font: inherit; font-size: 13px; padding: 8px 12px; width: 260px; border: 1px solid #2a3852; border-radius: 8px; background: #131b2a; color: #e6ecf5; }
.bar button { font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border: 1px solid #2a3852; border-radius: 8px; background: #131b2a; color: #cfd6e4; cursor: pointer; }
.bar button:hover { border-color: #2d6bff; color: #fff; }
.bar .count { color: #8c97a6; font-size: 12px; margin-left: auto; }
#grid { height: 560px; background: #131b2a; border: 1px solid #232f45; border-radius: 10px; overflow: hidden; }
</style>
<div class="bar">
<input id="q" type="search" placeholder="Filter across every column...">
<button id="pause">Pause tail</button>
<button id="share">Copy link to this view</button>
<span class="count" id="count"></span>
</div>
<div id="grid"></div>
Load a million rows
A seeded generator stands in for a log store. The million rows are generated once, up front; the grid virtualizes them, so however far you scroll, only the visible slice is ever in the page. Strings come from small pools, so a million rows stays light in memory.
// A seeded generator standing in for a log store. A million rows is generated
// once, up front; the grid virtualises them, so only the visible slice is ever
// in the DOM. Strings come from small pools, so a million rows stays light.
function rng(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var LEVELS = ['INFO', 'WARN', 'ERROR', 'DEBUG'];
var SERVICES = ['checkout', 'search', 'payments', 'inventory', 'auth', 'gateway'];
var REGIONS = ['eu-west-2', 'us-east-1', 'ap-south-1'];
var MESSAGES = ['request completed', 'cache miss', 'retry scheduled', 'upstream timeout', 'token refreshed', 'rate limit hit', 'slow query', 'connection reset'];
var STATUSES = [200, 201, 204, 301, 400, 401, 404, 429, 500, 503];
function makeLog(rand, seq, now) {
var pick = function (list) { return list[Math.floor(rand() * list.length)]; };
var status = pick(STATUSES);
var level = status >= 500 ? 'ERROR' : status >= 400 ? 'WARN' : pick(LEVELS);
return {
id: seq,
ts: new Date(now - Math.floor(rand() * 3600_000)).toISOString(),
level: level,
service: pick(SERVICES),
status: status,
latencyMs: 5 + Math.floor(rand() * 1500),
region: pick(REGIONS),
host: 'host-' + (1 + Math.floor(rand() * 40)),
traceId: (Math.floor(rand() * 0xffffffff) >>> 0).toString(16).padStart(8, '0'),
message: pick(MESSAGES),
};
}
function generateLogs(count) {
var rand = rng(13);
var now = Date.now();
var rows = new Array(count);
for (var i = 0; i < count; i++) rows[i] = makeLog(rand, count - i, now);
return rows;
}
Columns and the expand pane
Type each column so it sorts, filters and formats correctly. The expand pane is where the detail lives: opening a row builds a small breakdown of its fields on demand, so nothing is stored for the rows that stay closed, which matters when there are a million of them.
var columns = [
{ field: 'ts', title: 'Time', layout: { width: 210, pin: 'start' } },
{ field: 'level', title: 'Level', filter: { type: 'set' }, layout: { width: 90 } },
{ field: 'service', title: 'Service', filter: { type: 'set' }, layout: { width: 120 } },
{ field: 'status', title: 'Status', type: 'number', filter: { type: 'set' }, layout: { width: 90 } },
{ field: 'latencyMs', title: 'Latency', type: 'number', format: { suffix: ' ms' }, filter: { type: 'number' }, layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' }, layout: { width: 130 } },
{ field: 'host', title: 'Host', layout: { width: 110 } },
{ field: 'message', title: 'Message', layout: { flex: 1, min: 200 } },
];
var grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
theme: 'dark',
rows: generateLogs(1_000_000),
columns: columns,
selection: 'single',
// Header histograms that double as a filter: each column shows the shape of
// its values, and clicking a bucket narrows to it.
facets: { enabled: true, collapsed: false, height: 44 },
// The expand pane. rows builds a small key and value breakdown on demand when a
// row opens, so nothing is stored per row for the million that stay closed.
detail: {
height: 200,
rows: function (row) {
var d = row.data;
return [
{ field: 'trace id', value: d.traceId },
{ field: 'host', value: d.host },
{ field: 'status', value: String(d.status) },
{ field: 'latency', value: d.latencyMs + ' ms' },
{ field: 'message', value: d.message },
];
},
config: {
rowKey: 'field',
columns: [
{ field: 'field', title: 'Field', layout: { width: 140 } },
{ field: 'value', title: 'Value', layout: { flex: 1 } },
],
},
},
// A flash on each row as the tail appends it.
highlightOnChange: { duration: 500 },
// The rail carries the quick filter, the column filters, the saved views and
// the export.
toolPanel: {
side: 'left',
panels: ['quick', 'filters', 'columns', 'views'],
actions: ['excel', 'restore', 'maximise'],
exportName: 'logs',
},
});
// Report the matching count as filters change, so the scale is on the screen.
function showCount() {
document.getElementById('count').textContent = grid.rows.count().toLocaleString() + ' rows';
}
grid.on('rows:changed', showCount);
grid.on('filter:changed', showCount);
showCount();
Quick filter, set filters and facets
Three ways to narrow, for three ways of knowing what you want. The quick filter matches a word across every column at once, for when you only have a trace id or a host to grep for. A set filter on a column picks exact values, for when you know you want only errors. And a facet on a column shows the shape of its values as a small histogram in the header that doubles as a filter, so you can see the distribution and click into it. The facets and the set filters are configured on the grid above; the quick filter is one input.
// The quick filter matches across every column at once. One input, one call.
document.getElementById('q').addEventListener('input', function (e) {
grid.filters.quick(e.target.value);
});
Live tail
Logs keep arriving. A new line is inserted at the top on a timer, and the grid keeps the reader's scroll position and selection as it lands, so tailing does not yank the view around. The flash marks each arrival. Pause stops the timer without dropping anything already on screen, which is what you want the moment you spot the line you were after.
// The live tail. A new log arrives on a timer and is inserted at the top; the
// grid keeps the reader's scroll and selection, and the flash shows the arrival.
// Pause stops the timer without dropping anything already on screen.
var seq = 1_000_001;
var tailing = null;
function tick() {
var log = makeLog(rng(seq), seq++, Date.now());
grid.rows.apply({ add: [log], at: 0 });
}
function startTail() { if (!tailing) tailing = setInterval(tick, 700); }
function stopTail() { if (tailing) { clearInterval(tailing); tailing = null; } }
document.getElementById('pause').addEventListener('click', function () {
if (tailing) { stopTail(); this.textContent = 'Resume tail'; }
else { startTail(); this.textContent = 'Pause tail'; }
});
startTail();
Save and share a view
The view you arrived at, the filters, the sort, the column layout and the scroll, is worth sending to someone. It encodes into a compact, URL-safe string, diffed against the defaults so an untouched grid is a few characters, and reads straight back. Put it in the URL and the link carries the whole investigation; open a link that has one and the explorer restores it on load.
// Save and share a view. serialiseState writes a compact, URL-safe encoding of
// the sort, filters, column order and widths, scroll and selection, diffed
// against the defaults so an untouched grid is a handful of characters.
// restoreState reads it straight back. Put it in the URL and the link carries
// the whole investigation.
function applyFromUrl() {
var hash = location.hash.replace(/^#/, '');
if (hash) LatticeGrid.restoreState(grid, hash);
}
document.getElementById('share').addEventListener('click', function () {
location.hash = LatticeGrid.serialiseState(grid);
navigator.clipboard && navigator.clipboard.writeText(location.href);
});
applyFromUrl();
That is the whole explorer: a million rows, a live tail, three kinds of filter and a shareable view. Run it in the sandbox and share a link to a filtered view.
Sparklines and a control chart
Logs are half the story; the metrics beside them are the other half. A compact service rollup shows each service's latency as an in-cell sparkline, so a trend reads at a glance across the rows, and a latency control chart draws its limits from a stable baseline, so a later drift shows up as a rule violation rather than quietly widening the band. Both come from the same grid engine and load the charts module alongside it.
var series = { sort: { enabled: false }, filter: { enabled: false } };
var services = LatticeGrid.createGrid(document.getElementById('services'), {
rowKey: 'service', theme: 'light',
columns: [
{ field: 'service', title: 'Service', layout: { width: 140, pin: 'start' } },
{ field: 'p95', title: 'p95', type: 'number', format: { suffix: ' ms' } },
{ field: 'errorRate', title: 'Errors', type: 'number', format: { style: 'percent', decimals: 2 } },
// Sparkline columns: the field holds an array, and the renderer draws it in
// the cell. A shared scale keeps the rows comparable.
Object.assign({ field: 'trend', title: 'Latency, line', cell: { render: 'line', props: { min: 60, max: 320 } }, layout: { width: 200 } }, series),
Object.assign({ field: 'trend', id: 'trend-area', title: 'Latency, area', cell: { render: 'area', props: { min: 60, max: 320 } }, layout: { width: 200 } }, series),
],
rows: serviceRows(),
});
// A control chart of the latency readings, with the limits fixed on the first
// forty, so a later drift shows as a rule violation rather than absorbing it.
var readingsGrid = LatticeGrid.createHeadlessGrid({
rowKey: 'n',
columns: [{ field: 'n', type: 'number' }, { field: 'latencyMs', type: 'number' }],
rows: readings(),
});
var control = LatticeGrid.createChart({ grid: readingsGrid, container: '#control', type: 'control', y: 'latencyMs', baseline: 40, rules: 'nelson', title: 'Latency control chart' });
Run the metrics example in the sandbox
What you built
One dataset of a million rows became an explorer you can actually work in: virtualized so it stays fast, tailing so it stays current, filtered three ways so you find things, and shareable so a link lands a colleague where you are, with sparklines and a control chart for the metrics beside the logs.
Next, see the demo catalogue for the streaming, facet and sparkline features on their own, or read the grid overview for how a million rows stay this quick.