From eefbeac7f89d235f49877f61bba8d559e2d4b4bc Mon Sep 17 00:00:00 2001 From: zias Date: Mon, 30 Mar 2026 12:08:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20logs=20UI=20=E2=80=94=20tRPC=20ro?= =?UTF-8?q?uter,=20page,=20and=20sidebar=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logRouter with list (infinite query, cursor-based) and severityCounts procedures - /logs route page: severity filter chips with counts, search input, expandable log rows - Log detail expansion shows attributes, resource, trace context, device/location - Sidebar "Logs" link with ScrollText icon between Events and Sessions Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/sidebar-project-menu.tsx | 2 + .../_app.$organizationId.$projectId.logs.tsx | 340 ++++++++++++++++++ apps/start/src/utils/title.ts | 1 + packages/trpc/index.ts | 1 + packages/trpc/src/root.ts | 2 + packages/trpc/src/routers/log.ts | 212 +++++++++++ 6 files changed, 558 insertions(+) create mode 100644 apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx create mode 100644 packages/trpc/src/routers/log.ts diff --git a/apps/start/src/components/sidebar-project-menu.tsx b/apps/start/src/components/sidebar-project-menu.tsx index 1f381605..d4df5a51 100644 --- a/apps/start/src/components/sidebar-project-menu.tsx +++ b/apps/start/src/components/sidebar-project-menu.tsx @@ -15,6 +15,7 @@ import { LayoutDashboardIcon, LayoutPanelTopIcon, PlusIcon, + ScrollTextIcon, SearchIcon, SparklesIcon, TrendingUpDownIcon, @@ -61,6 +62,7 @@ export default function SidebarProjectMenu({ + diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx new file mode 100644 index 00000000..548f0daa --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.logs.tsx @@ -0,0 +1,340 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { + AlertCircleIcon, + AlertTriangleIcon, + BugIcon, + ChevronDownIcon, + ChevronRightIcon, + InfoIcon, + SearchIcon, + XCircleIcon, +} from 'lucide-react'; +import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; +import { useState } from 'react'; +import { PageContainer } from '@/components/page-container'; +import { PageHeader } from '@/components/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; +import { createProjectTitle, PAGE_TITLES } from '@/utils/title'; +import type { IServiceLog } from '@openpanel/trpc'; + +export const Route = createFileRoute( + '/_app/$organizationId/$projectId/logs', +)({ + component: Component, + head: () => ({ + meta: [{ title: createProjectTitle(PAGE_TITLES.LOGS) }], + }), +}); + +const SEVERITY_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const; + +type SeverityLevel = (typeof SEVERITY_LEVELS)[number]; + +function getSeverityVariant( + severity: string, +): 'default' | 'secondary' | 'info' | 'warning' | 'destructive' | 'outline' { + switch (severity.toLowerCase()) { + case 'fatal': + case 'critical': + case 'error': + return 'destructive'; + case 'warn': + case 'warning': + return 'warning'; + case 'info': + return 'info'; + default: + return 'outline'; + } +} + +function SeverityIcon({ severity }: { severity: string }) { + const s = severity.toLowerCase(); + const cls = 'size-3.5 shrink-0'; + if (s === 'fatal' || s === 'critical') return ; + if (s === 'error') return ; + if (s === 'warn' || s === 'warning') return ; + if (s === 'debug' || s === 'trace') return ; + return ; +} + +function LogRow({ log }: { log: IServiceLog }) { + const [expanded, setExpanded] = useState(false); + const hasDetails = + (log.attributes && Object.keys(log.attributes).length > 0) || + (log.resource && Object.keys(log.resource).length > 0) || + log.traceId || + log.loggerName; + + return ( + <> + + + {expanded && ( +
+
+ {log.loggerName && ( + + )} + {log.traceId && ( + + )} + {log.spanId && ( + + )} + {log.deviceId && ( + + )} + {log.os && ( + + )} + {log.country && ( + + )} + + {Object.keys(log.attributes).length > 0 && ( +
+

Attributes

+
+ {Object.entries(log.attributes).map(([k, v]) => ( +
+ {k} + = + {v} +
+ ))} +
+
+ )} + + {Object.keys(log.resource).length > 0 && ( +
+

Resource

+
+ {Object.entries(log.resource).map(([k, v]) => ( +
+ {k} + = + {v} +
+ ))} +
+
+ )} +
+
+ )} + + ); +} + +function MetaRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Component() { + const { projectId } = Route.useParams(); + const trpc = useTRPC(); + + const [search, setSearch] = useQueryState('search', parseAsString.withDefault('')); + const [severities, setSeverities] = useQueryState( + 'severity', + parseAsArrayOf(parseAsString).withDefault([]), + ); + + const countsQuery = useQuery( + trpc.log.severityCounts.queryOptions({ projectId }), + ); + + const logsQuery = useInfiniteQuery( + trpc.log.list.infiniteQueryOptions( + { + projectId, + search: search || undefined, + severity: severities.length > 0 ? (severities as SeverityLevel[]) : undefined, + take: 50, + }, + { + getNextPageParam: (lastPage) => lastPage.meta.next, + }, + ), + ); + + const logs = logsQuery.data?.pages.flatMap((p) => p.data) ?? []; + const counts = countsQuery.data ?? {}; + + const toggleSeverity = (s: string) => { + setSeverities((prev) => + prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s], + ); + }; + + return ( + + + + {/* Severity filter chips */} +
+ {SEVERITY_LEVELS.map((s) => { + const active = severities.includes(s); + const count = counts[s] ?? 0; + return ( + + ); + })} + + {severities.length > 0 && ( + + )} +
+ + {/* Search */} +
+ + setSearch(e.target.value || null)} + /> +
+ + {/* Log table */} +
+ {/* Header */} +
+ + Time + Level + Message + Source +
+ + {logsQuery.isPending && ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ )} + + {!logsQuery.isPending && logs.length === 0 && ( +
+ +

No logs found

+

+ {search || severities.length > 0 + ? 'Try adjusting your filters' + : 'Logs will appear here once your app starts sending them'} +

+
+ )} + + {logs.map((log) => ( + + ))} + + {logsQuery.hasNextPage && ( +
+ +
+ )} +
+ + ); +} + +function ScrollTextIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/start/src/utils/title.ts b/apps/start/src/utils/title.ts index 1d310735..634a6a8a 100644 --- a/apps/start/src/utils/title.ts +++ b/apps/start/src/utils/title.ts @@ -88,6 +88,7 @@ export const PAGE_TITLES = { MEMBERS: 'Members', BILLING: 'Billing', CHAT: 'AI Assistant', + LOGS: 'Logs', REALTIME: 'Realtime', REFERENCES: 'References', INSIGHTS: 'Insights', diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts index 2c70a820..8fa5b8fe 100644 --- a/packages/trpc/index.ts +++ b/packages/trpc/index.ts @@ -1,3 +1,4 @@ export * from './src/root'; export * from './src/trpc'; export { getProjectAccess } from './src/access'; +export type { IServiceLog } from './src/routers/log'; diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 808de8c6..2927ddae 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -8,6 +8,7 @@ import { eventRouter } from './routers/event'; import { groupRouter } from './routers/group'; import { gscRouter } from './routers/gsc'; import { importRouter } from './routers/import'; +import { logRouter } from './routers/log'; import { insightRouter } from './routers/insight'; import { integrationRouter } from './routers/integration'; import { notificationRouter } from './routers/notification'; @@ -57,6 +58,7 @@ export const appRouter = createTRPCRouter({ email: emailRouter, gsc: gscRouter, group: groupRouter, + log: logRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/log.ts b/packages/trpc/src/routers/log.ts new file mode 100644 index 00000000..4664aeb0 --- /dev/null +++ b/packages/trpc/src/routers/log.ts @@ -0,0 +1,212 @@ +import { chQuery, convertClickhouseDateToJs } from '@openpanel/db'; +import { zSeverityText } from '@openpanel/validation'; +import sqlstring from 'sqlstring'; +import { z } from 'zod'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export interface IServiceLog { + id: string; + projectId: string; + deviceId: string; + profileId: string; + sessionId: string; + timestamp: Date; + severityNumber: number; + severityText: string; + body: string; + traceId: string; + spanId: string; + traceFlags: number; + loggerName: string; + attributes: Record; + resource: Record; + sdkName: string; + sdkVersion: string; + country: string; + city: string; + region: string; + os: string; + osVersion: string; + browser: string; + browserVersion: string; + device: string; + brand: string; + model: string; +} + +interface IClickhouseLog { + id: string; + project_id: string; + device_id: string; + profile_id: string; + session_id: string; + timestamp: string; + severity_number: number; + severity_text: string; + body: string; + trace_id: string; + span_id: string; + trace_flags: number; + logger_name: string; + attributes: Record; + resource: Record; + sdk_name: string; + sdk_version: string; + country: string; + city: string; + region: string; + os: string; + os_version: string; + browser: string; + browser_version: string; + device: string; + brand: string; + model: string; +} + +function toServiceLog(row: IClickhouseLog): IServiceLog { + return { + id: row.id, + projectId: row.project_id, + deviceId: row.device_id, + profileId: row.profile_id, + sessionId: row.session_id, + timestamp: convertClickhouseDateToJs(row.timestamp), + severityNumber: row.severity_number, + severityText: row.severity_text, + body: row.body, + traceId: row.trace_id, + spanId: row.span_id, + traceFlags: row.trace_flags, + loggerName: row.logger_name, + attributes: row.attributes, + resource: row.resource, + sdkName: row.sdk_name, + sdkVersion: row.sdk_version, + country: row.country, + city: row.city, + region: row.region, + os: row.os, + osVersion: row.os_version, + browser: row.browser, + browserVersion: row.browser_version, + device: row.device, + brand: row.brand, + model: row.model, + }; +} + +export const logRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + projectId: z.string(), + cursor: z.string().nullish(), + severity: z.array(zSeverityText).optional(), + search: z.string().optional(), + loggerName: z.string().optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + take: z.number().default(50), + }), + ) + .query(async ({ input }) => { + const { projectId, cursor, severity, search, loggerName, startDate, endDate, take } = input; + + const conditions: string[] = [ + `project_id = ${sqlstring.escape(projectId)}`, + ]; + + if (cursor) { + conditions.push(`timestamp < ${sqlstring.escape(cursor)}`); + } + + if (severity && severity.length > 0) { + const escaped = severity.map((s) => sqlstring.escape(s)).join(', '); + conditions.push(`severity_text IN (${escaped})`); + } + + if (search) { + conditions.push(`body ILIKE ${sqlstring.escape(`%${search}%`)}`); + } + + if (loggerName) { + conditions.push(`logger_name = ${sqlstring.escape(loggerName)}`); + } + + if (startDate) { + conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`); + } + + if (endDate) { + conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`); + } + + const where = conditions.join(' AND '); + + const rows = await chQuery( + `SELECT + id, project_id, device_id, profile_id, session_id, + timestamp, severity_number, severity_text, body, + trace_id, span_id, trace_flags, logger_name, + attributes, resource, + sdk_name, sdk_version, + country, city, region, os, os_version, + browser, browser_version, device, brand, model + FROM logs + WHERE ${where} + ORDER BY timestamp DESC + LIMIT ${take + 1}`, + ); + + const hasMore = rows.length > take; + const data = rows.slice(0, take).map(toServiceLog); + const lastItem = data[data.length - 1]; + + return { + data, + meta: { + next: hasMore && lastItem ? lastItem.timestamp.toISOString() : null, + }, + }; + }), + + severityCounts: protectedProcedure + .input( + z.object({ + projectId: z.string(), + startDate: z.date().optional(), + endDate: z.date().optional(), + }), + ) + .query(async ({ input }) => { + const { projectId, startDate, endDate } = input; + + const conditions: string[] = [ + `project_id = ${sqlstring.escape(projectId)}`, + ]; + + if (startDate) { + conditions.push(`timestamp >= ${sqlstring.escape(startDate.toISOString())}`); + } + + if (endDate) { + conditions.push(`timestamp <= ${sqlstring.escape(endDate.toISOString())}`); + } + + const where = conditions.join(' AND '); + + const rows = await chQuery<{ severity_text: string; count: number }>( + `SELECT severity_text, count() AS count + FROM logs + WHERE ${where} + GROUP BY severity_text + ORDER BY count DESC`, + ); + + return rows.reduce>((acc, row) => { + acc[row.severity_text] = row.count; + return acc; + }, {}); + }), +});