Observability
Message Log
Webhook events over the last hour as a dot timeline you can brush, a filterable log below, and each attempt with its response body.
1 variantMIT
Preview
WebhooksMessage log
/
Last 60 minutes1 dot = 20 events10:53–10:58
Last 60 minutes: 4,321 delivered, 0 failed.
EventStatusAttemptsLatencyTime
202 OK
160 ms
10:55:47
200 OK
380 ms
10:54:31
2 events between 10:53–10:58
Installation
Copies the component into components/ui and installs its dependencies and theme tokens.
pnpm dlx shadcn@latest add https://tuffui.com/r/message-log.jsonUsage
Pass your own data and wire the callbacks to your agent runtime.
import { MessageLog } from "@/components/ui/message-log";
export function WebhookLog({ deliveries, perMinute, today }: WebhookData) {
return (
<MessageLog
section="Webhooks"
title="Message log"
buckets={perMinute.map((m) => ({
at: m.minute, // start of the minute
delivered: m.ok,
failed: m.failed,
}))}
events={deliveries.map((d) => ({
id: d.messageId, // "msg_2Fk8pZ"
type: d.eventType, // "order.refunded"
endpoint: d.endpoint.name,
at: d.createdAt,
attempts: d.attempts.map((a) => ({
status: a.nextAt ? "scheduled" : a.ok ? "delivered" : "failed",
code: a.httpStatus, // 503
label: a.reason, // "Service unavailable"
at: a.nextAt ?? a.sentAt,
ms: a.durationMs,
body: a.responseBody,
})),
}))}
defaultWindow={[minutesAgo(5), now()]} // touches the live edge, so it follows it
total={today.count}
totalLabel="today"
onReplay={(event) => replayMessage(event.id)}
onCopyCurl={(event) => curlFor(event.id)}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| events* | MessageEvent[] | — | Events in any order; the log lists them newest first. The last attempt decides the status: delivered reads as `200 OK`, failed as its code, scheduled or sending as Retrying. |
| buckets* | MessageBucket[] | — | Delivered and failed counts per evenly spaced bucket, oldest first, drawn as dot columns. Narrow cards show the most recent 45, 30 or 15 so dots never merge. |
| perDot | number | round number for ≤ 8 dots | Events per dot. Any failure in a bucket draws at least one red dot, so a small error rate is never rounded away. |
| window | [number, number] | null | — | Controlled brush window in epoch ms, end exclusive. Only events whose `at` falls inside are listed. `null` lists everything. |
| defaultWindow | [Time, Time] | null | null | Starting brush when uncontrolled. A window that ends at the newest bucket follows it as new buckets arrive. |
| onWindowChange | (window: [number, number] | null) => void | — | Called while the brush is dragged, resized or moved with the keyboard, and with `null` when it is cleared. Drag on the chart to draw one; a plain click clears it. |
| filter | "all" | "failed" | "retrying" | — | Controlled All / Failed / Retrying tab. |
| defaultFilter | "all" | "failed" | "retrying" | "all" | Starting tab when uncontrolled. |
| onFilterChange | (filter: MessageFilter) => void | — | Called when the tab changes. |
| query | string | — | Controlled search text. Matches event type, message id, endpoint and HTTP codes. Press / anywhere on the page to focus it. |
| defaultQuery | string | "" | Starting search text when uncontrolled. |
| onQueryChange | (query: string) => void | — | Called as the search text changes. |
| selectedId | string | null | — | Controlled expanded row. The row opens into its attempts and the response body of the latest finished try; click another try to see its body. |
| defaultSelectedId | string | null | null | Row expanded on mount when uncontrolled. |
| onSelect | (event: MessageEvent | null) => void | — | Called when a row is opened, or with `null` when it is closed. |
| onReplay | (event: MessageEvent) => void | — | Shows a Replay button in the expanded row. Add a `sending` attempt to the event and the button shows a spinner until you swap it for the result. |
| onCopyCurl | (event: MessageEvent) => string | void | — | Shows a Copy cURL button. Return the command and it is written to the clipboard for you. |
| maxAttempts | number | 8 | Dots in the Attempts column: one per try, then faint empty slots. |
| annotation | { at: Time; label: string; detail?: string } | null | start of the current failure run | Flag on the timeline. Left out, it marks where the failing buckets that reach now began, with the most common endpoint and code, e.g. "billing-svc · 503". `null` hides it. |
| section | string | "Webhooks" | First part of the breadcrumb title. Hidden on narrow cards. |
| title | string | "Message log" | Header title and the accessible name of the log. |
| total | number | events.length | Every event in the period, for the "4 of 12,480" footer count. |
| totalLabel | string | — | Words after the footer count, e.g. "today". |
| formatTime | (date: Date, precision: "minute" | "second") => string | "11:04" / "11:04:09" in UTC | Formats the axis, the window, row times and attempt times. |
| className | string | — | Extra classes for the root element. |
type Time = Date | string | number;
type MessageEvent = {
id: string;
type: string; // "order.created"
endpoint: string; // "billing-svc"
at: Time; // when it was sent
attempts: MessageAttempt[];
latencyMs?: number; // defaults to the last finished try's ms
body?: string; // fallback response body
bodyLanguage?: CodeLanguage; // "json" by default
};
type MessageAttempt = {
status: "delivered" | "failed" | "scheduled" | "sending";
code?: number; // 503
label?: string; // "Service unavailable"
at: Time; // ran at, or due at for "scheduled"
ms?: number;
body?: string;
};
type MessageBucket = { at: Time; delivered: number; failed: number };
// Brush keys: ← → move the window, Shift + ← → resize it,
// Home / End jump to the edges, Esc clears it.