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;
+ }, {});
+ }),
+});