Skip to content
TuffTasks, docs and chat for teams and AI agents

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
Delivered Failed Scheduled2 of 11,717 · today

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.json

Usage

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

PropTypeDefaultDescription
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.
perDotnumberround number for ≤ 8 dotsEvents 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] | nullnullStarting 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.
querystring—Controlled search text. Matches event type, message id, endpoint and HTTP codes. Press / anywhere on the page to focus it.
defaultQuerystring""Starting search text when uncontrolled.
onQueryChange(query: string) => void—Called as the search text changes.
selectedIdstring | 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.
defaultSelectedIdstring | nullnullRow 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.
maxAttemptsnumber8Dots in the Attempts column: one per try, then faint empty slots.
annotation{ at: Time; label: string; detail?: string } | nullstart of the current failure runFlag 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.
sectionstring"Webhooks"First part of the breadcrumb title. Hidden on narrow cards.
titlestring"Message log"Header title and the accessible name of the log.
totalnumberevents.lengthEvery event in the period, for the "4 of 12,480" footer count.
totalLabelstring—Words after the footer count, e.g. "today".
formatTime(date: Date, precision: "minute" | "second") => string"11:04" / "11:04:09" in UTCFormats the axis, the window, row times and attempt times.
classNamestring—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.