diff --git a/apps/api/src/api/routes/monitor-site-events.test.ts b/apps/api/src/api/routes/monitor-site-events.test.ts new file mode 100644 index 0000000000..56b7957805 --- /dev/null +++ b/apps/api/src/api/routes/monitor-site-events.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "bun:test"; +import { + resolveSiteWindow, + type RunQuery, + type SiteWindow, +} from "./monitor-site"; +import { + decodeSiteEventsCursor, + type EventRow, + encodeSiteEventsCursor, + eventsHistogramQuery, + eventsPageQuery, + eventsServicesQuery, + parseSiteEventFilters, + querySiteEvents, + SITE_EVENTS_PAGE_SIZE, + type SiteEventFilters, + SiteEventsInputError, + toEvent, + toHistogram, +} from "./monitor-site-events"; + +const NOW = Date.parse("2026-10-19T12:34:56.789Z"); +const W = resolveSiteWindow("1h", NOW) as SiteWindow; +const NONE: SiteEventFilters = { kinds: [], levels: [], services: [], q: "" }; + +describe("parseSiteEventFilters", () => { + it("reads comma lists, trimmed and deduped; absent means every value", () => { + expect(parseSiteEventFilters({})).toEqual(NONE); + expect( + parseSiteEventFilters({ + kinds: "log, span,log", + levels: "error,,warn", + services: " a ,b", + q: " timeout ", + }), + ).toEqual({ + kinds: ["log", "span"], + levels: ["error", "warn"], + services: ["a", "b"], + q: "timeout", + }); + }); + + it("rejects an unknown kind or level and an oversized query", () => { + expect(() => parseSiteEventFilters({ kinds: "metric" })).toThrow( + SiteEventsInputError, + ); + expect(() => parseSiteEventFilters({ levels: "fatal" })).toThrow( + SiteEventsInputError, + ); + expect(() => parseSiteEventFilters({ q: "x".repeat(201) })).toThrow( + SiteEventsInputError, + ); + }); + + it("caps the services list", () => { + const services = Array.from({ length: 50 }, (_, i) => `s${i}`).join(","); + expect(parseSiteEventFilters({ services }).services).toHaveLength(20); + }); +}); + +describe("cursor", () => { + it("round-trips", () => { + const cursor = { t: "1792000000123456789", id: "l9F3A0B" }; + expect(decodeSiteEventsCursor(encodeSiteEventsCursor(cursor))).toEqual( + cursor, + ); + }); + + it("rejects anything this API didn't write", () => { + for (const bad of [ + "nope", + encodeSiteEventsCursor({ t: "1' OR 1=1", id: "l1" }), + encodeSiteEventsCursor({ t: "1", id: "x'--" }), + Buffer.from("null").toString("base64url"), + ]) { + expect(() => decodeSiteEventsCursor(bad)).toThrow(SiteEventsInputError); + } + }); +}); + +describe("tenancy and filters", () => { + const hostile = "acme' OR 1=1 --"; + const filters: SiteEventFilters = { + kinds: ["log"], + levels: ["error"], + services: ["svc"], + q: "boom' --", + }; + const queries = [ + eventsPageQuery(hostile, W, filters, { t: "1", id: "l1" }), + eventsHistogramQuery(hostile, W, filters), + eventsServicesQuery(hostile, W, filters), + ]; + + it("scopes both tables to the site's collector stamp, every value bound as a parameter", () => { + for (const { query, params } of queries) { + for (const table of ["default.otel_logs", "default.otel_traces"]) { + expect(query).toContain(table); + } + expect(query.match(/ServiceName = \{slug:String\}/g)).toHaveLength(2); + expect( + query.match(/ResourceAttributes\['deco.site'\] = \{slug:String\}/g), + ).toHaveLength(2); + expect(query).not.toContain(hostile); + expect(query).not.toContain("boom"); + expect(params.slug).toBe(hostile); + expect(params.q).toBe("boom' --"); + } + }); + + it("counts levels across every level, and services across every service", () => { + const [page, histogram, services] = queries.map((q) => q.query) as [ + string, + string, + string, + ]; + expect(page).toContain("level IN {levels:Array(String)}"); + expect(page).toContain("service IN {services:Array(String)}"); + expect(histogram).not.toContain("level IN"); + expect(histogram).toContain("service IN"); + expect(services).toContain("level IN"); + expect(services).not.toContain("service IN"); + }); + + it("pages after the cursor only when there is one", () => { + expect(eventsPageQuery("s", W, NONE, null).query).not.toContain("cursor_t"); + expect( + eventsPageQuery("s", W, NONE, { t: "5", id: "s1" }).params, + ).toMatchObject({ cursor_t: "5", cursor_id: "s1" }); + }); +}); + +describe("transforms", () => { + const row = (over: Partial = {}): EventRow => ({ + kind: "log", + ts: "1792000000123456789", + id: "lABC", + level: "error", + service: "site", + message: "boom", + duration_ms: null, + trace_id: "", + span_id: "", + attributes: { "http.route": "/p" }, + ...over, + }); + + it("maps a row to the wire event (64-bit ns arrive quoted)", () => { + expect(toEvent(row())).toEqual({ + id: "lABC", + timestamp: new Date(1792000000123).toISOString(), + kind: "log", + level: "error", + service: "site", + message: "boom", + durationMs: null, + traceId: null, + spanId: null, + attributes: { "http.route": "/p" }, + }); + expect( + toEvent( + row({ + kind: "span", + duration_ms: 12.5, + trace_id: "t", + span_id: "s", + attributes: null, + }), + ), + ).toMatchObject({ + durationMs: 12.5, + traceId: "t", + spanId: "s", + attributes: {}, + }); + }); + + it("has one bucket per step over the window, empty ones included", () => { + const first = W.startMs / 1000; + const hist = toHistogram( + [ + { t: String(first), level: "error", count: "2" }, + { t: first, level: "ok", count: 3 }, + { t: first + W.stepS * 2, level: "warn", count: 1 }, + ], + W, + ); + expect(hist).toHaveLength(60); + expect(hist[0]).toEqual({ + t: new Date(W.startMs).toISOString(), + error: 2, + warn: 0, + info: 0, + ok: 3, + debug: 0, + }); + expect(hist[1]?.ok).toBe(0); + expect(hist[2]?.warn).toBe(1); + }); +}); + +describe("querySiteEvents", () => { + const rows = (count: number): EventRow[] => + Array.from({ length: count }, (_, i) => ({ + kind: "log", + ts: String(1792000000000000000n - BigInt(i)), + id: `l${i.toString(16).toUpperCase()}`, + level: "error", + service: "site", + message: `m${i}`, + duration_ms: null, + trace_id: "", + span_id: "", + attributes: {}, + })); + /** A warehouse answering each of the three queries by its shape. */ + const warehouse = + (page: EventRow[], seen: string[] = []): RunQuery => + async (query: string) => { + seen.push(query); + if (query.includes("GROUP BY t, level")) + return [ + { t: W.startMs / 1000, level: "error", count: "4" }, + { t: W.startMs / 1000, level: "ok", count: "6" }, + ] as T[]; + if (query.includes("GROUP BY name")) + return [{ name: "site", count: "10" }] as T[]; + return page as T[]; + }; + + it("returns a page, the cursor of its last event, and the counts", async () => { + const page = await querySiteEvents( + warehouse(rows(SITE_EVENTS_PAGE_SIZE + 1)), + "s", + W, + NONE, + null, + ); + expect(page.events).toHaveLength(SITE_EVENTS_PAGE_SIZE); + const last = page.events.at(-1)!; + expect(decodeSiteEventsCursor(page.nextCursor!)).toEqual({ + t: String(1792000000000000000n - BigInt(SITE_EVENTS_PAGE_SIZE - 1)), + id: last.id, + }); + expect(page.total).toBe(10); + expect(page.facets).toEqual({ + levels: { error: 4, warn: 0, info: 0, ok: 6, debug: 0 }, + services: [{ name: "site", count: 10 }], + }); + }); + + it("has no cursor on the last page", async () => { + const page = await querySiteEvents(warehouse(rows(3)), "s", W, NONE, null); + expect(page.events).toHaveLength(3); + expect(page.nextCursor).toBeNull(); + }); + + it("totals only the levels filtered on", async () => { + const page = await querySiteEvents( + warehouse([]), + "s", + W, + { ...NONE, levels: ["error"] }, + null, + ); + expect(page.total).toBe(4); + expect(page.facets.levels.ok).toBe(6); + }); + + it("skips the counts after a cursor", async () => { + const seen: string[] = []; + const page = await querySiteEvents(warehouse(rows(1), seen), "s", W, NONE, { + t: "1", + id: "l1", + }); + expect(seen).toHaveLength(1); + expect(page.histogram).toEqual([]); + expect(page.total).toBe(0); + }); +}); diff --git a/apps/api/src/api/routes/monitor-site-events.ts b/apps/api/src/api/routes/monitor-site-events.ts new file mode 100644 index 0000000000..65cac18aaf --- /dev/null +++ b/apps/api/src/api/routes/monitor-site-events.ts @@ -0,0 +1,415 @@ +/** + * The Monitor's Logs tab for Blocks v8 sites: the site's own logs and spans, + * one by one, newest first (`GET /api/:org/monitor/:site/site/events`). The + * wire shape is `apps/web/src/components/monitor/site-events-contract.ts`. + * + * Same warehouse, tables and tenant scope as the aggregated views in + * `monitor-site.ts`: `otel_logs` and `otel_traces`, scoped on the collector's + * stamp of the site. What a site sends (from `@decocms/blocks`): error logs at + * the error sample rate, and spans only once the site raises its trace sample + * rate (0 by default), so most sites list error logs only. + * + * Levels: a log's from its OTel severity number (17+ error, 13+ warn, 9+ info, + * 1–8 debug, unset info); a span is `error` when its status is Error, else `ok`. + * + * Counts (`total`, `histogram`, `facets`) come with the first page only; the + * page after a cursor carries the events and the next cursor. + */ + +import { + inWindow, + LOGS_TABLE, + type RunQuery, + SITE_SCOPE, + type SiteWindow, + TRACES_TABLE, +} from "./monitor-site"; + +const SITE_EVENT_KINDS = ["log", "span"] as const; +export type SiteEventKind = (typeof SITE_EVENT_KINDS)[number]; + +const SITE_EVENT_LEVELS = ["error", "warn", "info", "ok", "debug"] as const; +export type SiteEventLevel = (typeof SITE_EVENT_LEVELS)[number]; + +export const SITE_EVENTS_PAGE_SIZE = 100; +const MAX_QUERY_LENGTH = 200; +const MAX_SERVICES = 20; + +export interface SiteEventFilters { + /** Empty: every kind. */ + kinds: SiteEventKind[]; + /** Empty: every level. */ + levels: SiteEventLevel[]; + /** Empty: every service. */ + services: string[]; + /** Case-insensitive text in the message, or an exact trace id. */ + q: string; +} + +/** Where the next page starts: the last event's time (ns) and id. */ +export interface SiteEventsCursor { + t: string; + id: string; +} + +export class SiteEventsInputError extends Error { + constructor(message: string) { + super(message); + this.name = "SiteEventsInputError"; + } +} + +function list(value: string | undefined): string[] { + return [ + ...new Set( + (value ?? "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + ), + ]; +} + +function oneOf( + name: string, + values: string[], + allowed: readonly T[], +): T[] { + for (const v of values) { + if (!(allowed as readonly string[]).includes(v)) { + throw new SiteEventsInputError(`unknown ${name}: ${v}`); + } + } + return values as T[]; +} + +/** The filters from the query string; {@link SiteEventsInputError} on a bad one. */ +export function parseSiteEventFilters(query: { + kinds?: string; + levels?: string; + services?: string; + q?: string; +}): SiteEventFilters { + const q = (query.q ?? "").trim(); + if (q.length > MAX_QUERY_LENGTH) { + throw new SiteEventsInputError("q is too long"); + } + return { + kinds: oneOf("kind", list(query.kinds), SITE_EVENT_KINDS), + levels: oneOf("level", list(query.levels), SITE_EVENT_LEVELS), + services: list(query.services).slice(0, MAX_SERVICES), + q, + }; +} + +export function encodeSiteEventsCursor(cursor: SiteEventsCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf-8").toString("base64url"); +} + +/** The cursor; {@link SiteEventsInputError} when it isn't one this API wrote. */ +export function decodeSiteEventsCursor(value: string): SiteEventsCursor { + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf-8"), + ) as { t?: unknown; id?: unknown } | null; + if ( + typeof parsed?.t === "string" && + /^\d{1,20}$/.test(parsed.t) && + typeof parsed.id === "string" && + /^[ls][0-9A-F]{1,16}$/.test(parsed.id) + ) { + return { t: parsed.t, id: parsed.id }; + } + } catch { + // falls through + } + throw new SiteEventsInputError("invalid cursor"); +} + +// --- SQL ---------------------------------------------------------------------- + +/** + * One row per log and per span in the window, in a single shape. `id` is a + * hash, prefixed by kind, that orders ties on `ts` for the cursor. + */ +const EVENTS = `( + SELECT + 'log' AS kind, + toUnixTimestamp64Nano(Timestamp) AS ts, + concat('l', hex(cityHash64(Timestamp, TraceId, SpanId, SeverityNumber, Body))) AS id, + multiIf( + SeverityNumber >= 17, 'error', + SeverityNumber >= 13, 'warn', + SeverityNumber >= 9 OR SeverityNumber = 0, 'info', + 'debug') AS level, + ServiceName AS service, + Body AS message, + CAST(NULL AS Nullable(Float64)) AS duration_ms, + TraceId AS trace_id, + SpanId AS span_id, + CAST(LogAttributes, 'Map(String, String)') AS attributes + FROM ${LOGS_TABLE} + WHERE ${SITE_SCOPE} + AND ${inWindow("Timestamp", "start")} + UNION ALL + SELECT + 'span' AS kind, + toUnixTimestamp64Nano(Timestamp) AS ts, + concat('s', hex(cityHash64(TraceId, SpanId))) AS id, + if(StatusCode IN ('Error', 'STATUS_CODE_ERROR'), 'error', 'ok') AS level, + ServiceName AS service, + SpanName AS message, + toNullable(toFloat64(Duration) / 1e6) AS duration_ms, + TraceId AS trace_id, + SpanId AS span_id, + CAST(SpanAttributes, 'Map(String, String)') AS attributes + FROM ${TRACES_TABLE} + WHERE ${SITE_SCOPE} + AND ${inWindow("Timestamp", "start")} + )`; + +type Filter = keyof SiteEventFilters; + +/** The WHERE over {@link EVENTS} for every filter but `skip`. */ +function where(f: SiteEventFilters, skip?: Filter): string { + const clauses = ["1"]; + if (skip !== "kinds" && f.kinds.length) + clauses.push("kind IN {kinds:Array(String)}"); + if (skip !== "levels" && f.levels.length) + clauses.push("level IN {levels:Array(String)}"); + if (skip !== "services" && f.services.length) + clauses.push("service IN {services:Array(String)}"); + if (skip !== "q" && f.q) + clauses.push( + "(positionCaseInsensitiveUTF8(message, {q:String}) > 0 OR trace_id = {q:String})", + ); + return clauses.join(" AND "); +} + +function params( + slug: string, + w: SiteWindow, + f: SiteEventFilters, +): Record { + return { + slug, + start: w.startMs, + end: w.endMs, + step: w.stepS, + kinds: f.kinds, + levels: f.levels, + services: f.services, + q: f.q, + }; +} + +export interface SiteEventsQuery { + query: string; + params: Record; +} + +/** One page of events, newest first, after the cursor. One extra row tells if there's more. */ +export function eventsPageQuery( + slug: string, + w: SiteWindow, + f: SiteEventFilters, + cursor: SiteEventsCursor | null, +): SiteEventsQuery { + return { + query: `SELECT kind, ts, id, level, service, message, duration_ms, trace_id, span_id, attributes + FROM ${EVENTS} + WHERE ${where(f)}${cursor ? "\n AND (ts, id) < ({cursor_t:Int64}, {cursor_id:String})" : ""} + ORDER BY ts DESC, id DESC + LIMIT {limit:UInt32}`, + params: { + ...params(slug, w, f), + limit: SITE_EVENTS_PAGE_SIZE + 1, + ...(cursor ? { cursor_t: cursor.t, cursor_id: cursor.id } : {}), + }, + }; +} + +/** Counts per bucket and level, under every filter but the level. */ +export function eventsHistogramQuery( + slug: string, + w: SiteWindow, + f: SiteEventFilters, +): SiteEventsQuery { + return { + query: `SELECT + intDiv(intDiv(ts, 1000000000), {step:UInt32}) * {step:UInt32} AS t, + level, + count() AS count + FROM ${EVENTS} + WHERE ${where(f, "levels")} + GROUP BY t, level + ORDER BY t`, + params: params(slug, w, f), + }; +} + +/** Counts per service, under every filter but the service. */ +export function eventsServicesQuery( + slug: string, + w: SiteWindow, + f: SiteEventFilters, +): SiteEventsQuery { + return { + query: `SELECT service AS name, count() AS count + FROM ${EVENTS} + WHERE ${where(f, "services")} + GROUP BY name + ORDER BY count DESC, name + LIMIT ${MAX_SERVICES}`, + params: params(slug, w, f), + }; +} + +// --- Rows -> page ------------------------------------------------------------- + +/** 64-bit integers arrive quoted from JSONEachRow. */ +type Num = number | string; +const n = (v: Num | null | undefined): number => Number(v ?? 0) || 0; + +export interface EventRow { + kind: SiteEventKind; + ts: Num; + id: string; + level: SiteEventLevel; + service: string; + message: string; + duration_ms: number | null; + trace_id: string; + span_id: string; + attributes: Record | null; +} + +export interface SiteEvent { + id: string; + timestamp: string; + kind: SiteEventKind; + level: SiteEventLevel; + service: string; + message: string; + durationMs: number | null; + traceId: string | null; + spanId: string | null; + attributes: Record; +} + +type LevelCounts = Record; + +export interface SiteEventsPage { + total: number; + histogram: (LevelCounts & { t: string })[]; + facets: { + levels: LevelCounts; + services: { name: string; count: number }[]; + }; + events: SiteEvent[]; + nextCursor: string | null; +} + +const zeroLevels = (): LevelCounts => ({ + error: 0, + warn: 0, + info: 0, + ok: 0, + debug: 0, +}); + +/** Nanoseconds since the epoch, as ISO 8601 (to the millisecond). */ +function isoFromNs(ts: Num): string { + return new Date(Number(BigInt(String(ts)) / 1_000_000n)).toISOString(); +} + +export function toEvent(r: EventRow): SiteEvent { + return { + id: r.id, + timestamp: isoFromNs(r.ts), + kind: r.kind, + level: r.level, + service: r.service, + message: r.message, + durationMs: r.duration_ms === null ? null : n(r.duration_ms), + traceId: r.trace_id || null, + spanId: r.span_id || null, + attributes: r.attributes ?? {}, + }; +} + +/** One bucket per step over the whole window, oldest first, empty ones included. */ +export function toHistogram( + rows: { t: Num; level: SiteEventLevel; count: Num }[], + w: SiteWindow, +): (LevelCounts & { t: string })[] { + const byT = new Map(); + for (const r of rows) { + const t = n(r.t); + const counts = byT.get(t) ?? zeroLevels(); + if (Object.hasOwn(counts, r.level)) counts[r.level] += n(r.count); + byT.set(t, counts); + } + const out: (LevelCounts & { t: string })[] = []; + for (let t = w.startMs / 1000; t < w.endMs / 1000; t += w.stepS) { + out.push({ + t: new Date(t * 1000).toISOString(), + ...(byT.get(t) ?? zeroLevels()), + }); + } + return out; +} + +/** Reads one page for a site the caller already checked the org owns. */ +export async function querySiteEvents( + run: RunQuery, + slug: string, + w: SiteWindow, + f: SiteEventFilters, + cursor: SiteEventsCursor | null, +): Promise { + const page = eventsPageQuery(slug, w, f, cursor); + const counted = !cursor; + const hist = counted ? eventsHistogramQuery(slug, w, f) : null; + const svc = counted ? eventsServicesQuery(slug, w, f) : null; + const [rows, histRows, svcRows] = await Promise.all([ + run(page.query, page.params), + hist + ? run<{ t: Num; level: SiteEventLevel; count: Num }>( + hist.query, + hist.params, + ) + : Promise.resolve([]), + svc + ? run<{ name: string; count: Num }>(svc.query, svc.params) + : Promise.resolve([]), + ]); + + const more = rows.length > SITE_EVENTS_PAGE_SIZE; + const events = rows.slice(0, SITE_EVENTS_PAGE_SIZE).map(toEvent); + const last = rows[SITE_EVENTS_PAGE_SIZE - 1]; + const nextCursor = + more && last + ? encodeSiteEventsCursor({ t: String(last.ts), id: last.id }) + : null; + + const histogram = counted ? toHistogram(histRows, w) : []; + const levels = zeroLevels(); + for (const b of histogram) + for (const l of SITE_EVENT_LEVELS) levels[l] += b[l]; + const totalLevels: readonly SiteEventLevel[] = f.levels.length + ? f.levels + : SITE_EVENT_LEVELS; + let total = 0; + for (const l of totalLevels) total += levels[l]; + return { + total, + histogram, + facets: { + levels, + services: svcRows.map((r) => ({ name: r.name, count: n(r.count) })), + }, + events, + nextCursor, + }; +} diff --git a/apps/api/src/api/routes/monitor-site.ts b/apps/api/src/api/routes/monitor-site.ts index 8f56f44352..75ae098918 100644 --- a/apps/api/src/api/routes/monitor-site.ts +++ b/apps/api/src/api/routes/monitor-site.ts @@ -94,14 +94,15 @@ export function resolveSiteWindow( * collector, in production. A resource without an environment counts as * production (the attribute is optional in the OTel resource). */ -const SITE_SCOPE = `ServiceName = {slug:String} +export const SITE_SCOPE = `ServiceName = {slug:String} AND ResourceAttributes['deco.site'] = {slug:String} AND ResourceAttributes['deployment.environment.name'] IN ('production', '')`; const HIST_TABLE = "default.otel_metrics_histogram"; -const LOGS_TABLE = "default.otel_logs"; +export const LOGS_TABLE = "default.otel_logs"; +export const TRACES_TABLE = "default.otel_traces"; -const inWindow = (col: string, from: string) => +export const inWindow = (col: string, from: string) => `${col} >= fromUnixTimestamp64Milli({${from}:Int64}) AND ${col} < fromUnixTimestamp64Milli({end:Int64})`; /** diff --git a/apps/api/src/api/routes/monitor.ts b/apps/api/src/api/routes/monitor.ts index a3a2718e57..89884b42d6 100644 --- a/apps/api/src/api/routes/monitor.ts +++ b/apps/api/src/api/routes/monitor.ts @@ -52,6 +52,12 @@ import { } from "../../deco-legacy/clickhouse-analytics"; import { toOneDollarHostname } from "../../deco-legacy/onedollarstats"; import { querySiteView, resolveSiteWindow, SITE_VIEWS } from "./monitor-site"; +import { + decodeSiteEventsCursor, + parseSiteEventFilters, + querySiteEvents, + SiteEventsInputError, +} from "./monitor-site-events"; import { ownedAnalyticsSiteId } from "@/analytics/access"; import { isCustomerLakeConfigured } from "@/customer-lake/client"; import { @@ -1066,6 +1072,53 @@ export const createMonitorRoutes = () => { } }); + // GET /api/:org/monitor/:site/site/events?range&kinds&levels&services&q&cursor + // — the Logs tab: the site's own logs and spans, newest first + // (monitor-site-events.ts). Same guard and warehouse as /site/data. + app.get("/:site/site/events", async (c) => { + const guard = await requireOwnedSite(c); + if (guard instanceof Response) return guard; + + if (!isAnalyticsConfigured()) { + return c.json({ available: false, error: "Monitor is not configured" }); + } + + const window = resolveSiteWindow(c.req.query("range") ?? "24h", Date.now()); + if (!window) { + return c.json({ error: `unknown range: ${c.req.query("range")}` }, 400); + } + let filters: ReturnType; + let cursor: ReturnType | null; + try { + filters = parseSiteEventFilters({ + kinds: c.req.query("kinds"), + levels: c.req.query("levels"), + services: c.req.query("services"), + q: c.req.query("q"), + }); + const raw = c.req.query("cursor"); + cursor = raw ? decodeSiteEventsCursor(raw) : null; + } catch (err) { + if (err instanceof SiteEventsInputError) { + return c.json({ error: err.message }, 400); + } + throw err; + } + + const { slug } = guard; + try { + return c.json( + await querySiteEvents(analyticsQuery, slug, window, filters, cursor), + ); + } catch (err) { + console.error( + `[monitor] site events query failed for site="${slug}":`, + err, + ); + return c.json({ error: "Failed to query monitor warehouse" }, 502); + } + }); + // GET /api/:org/monitor/:site/audience/data?view=&range= — the pageview / // visitor half (the native replacement for the old admin's OneDollarStats // "Analytics" tab). Same tenancy guard and shape as the CDN handler.