feat(monitor): serve the Logs tab's site events from the site's OpenTelemetry
Problem
The Monitor's Logs tab (#8332, which was UI only) calls GET /api/:org/monitor/:site/site/events. That route was never added, so the router answers 404 and the tab shows "Couldn't load the site's logs / Not Found" for every Blocks v8 site.
Change
A new route next to /site/data in monitor.ts. It uses the same requireOwnedSite guard (ownership first, then the plan), the same { available: false } answer when the warehouse isn't configured, and returns 502 when a query fails.
monitor-site-events.ts reads otel_logs and otel_traces from the same warehouse, with the same SITE_SCOPE as the aggregated views (the collector's stamp on the site, production only). Every value is bound as a parameter. It returns the shape in apps/web/src/components/monitor/site-events-contract.ts:
- events: logs and spans, newest first, 100 per page, with a keyset cursor on
(ts, id). - levels: a log's level comes from its OTel severity number (17+ error, 13+ warn, 9+ info, 1ā8 debug, unset ā info). A span is
errorwhen its status is Error, otherwiseok. - filters:
kinds,levels,services(comma lists, validated) andq(case-insensitive text in the message, or an exact trace id). - counts: the first page also returns
histogram(one bucket per window step, under every filter except level),facets.levels,facets.services(under every filter except service) andtotal. Pages after a cursor skip these counts.
SITE_SCOPE, inWindow, LOGS_TABLE and the new TRACES_TABLE are now exported from monitor-site.ts.
What sites send today, from @decocms/blocks: error logs at the error sample rate, and spans only when the site raises its trace sample rate (0 by default). So most sites will list error logs only.
Verification
monitor-site-events.test.tscovers:- parsing filters, including unknown, duplicate and oversized input;
- cursor round-trips and rejecting invalid cursors;
- tenant scope and parameter binding on all three queries, with a hostile site slug;
- which filter each count ignores;
- row ā wire event mapping;
- histogram buckets, including empty ones;
- paging,
nextCursor, totals under a level filter, and no counts after a cursor.
bun test apps/api/src/api/routes/monitor*: 39 pass.tsc --noEmit, oxlint, biome and knip are clean.- Not verified against the production warehouse. The query assumes the collector's ClickHouse exporter writes
otel_traceswith the standard schema (Timestamp,TraceId,SpanId,SpanName,Duration,StatusCode,SpanAttributes). If that table is missing, the route answers 502 instead of 404. The spans branch would then need to be gated or dropped, and that should be checked right after deploy.
š¤ Generated with Claude Code
Summary by cubic
Implements the route behind the Monitor's Logs tab, which until now returned 404 and showed "Couldn't load the site's logs" for every Blocks v8 site.
Adds GET /api/:org/monitor/:site/site/events, serving the site's own logs and spans from the OpenTelemetry warehouse (otel_logs and otel_traces) with the same tenant scope and ownership guard as /site/data. It returns pages of 100 newest-first events with a keyset cursor; the first page also carries the histogram and level and service counts. New filters: kinds, levels, services, and q (message text or exact trace id). Most sites send only error logs today, since trace sampling defaults to 0, so listings will usually be error logs only.
Not verified against the production warehouse ā the spans branch assumes the collector writes otel_traces with the standard schema, and answers 502 if that table is missing. Worth checking right after deploy.