feat(telemetry): session journey view replaces raw events table

- sessions listed from client_events grouped by session id (7d window)
- timeline sheet merges page views, client events, errors, and server-fn
  activities joined by player within the session window
- vitals excluded from timelines; gap markers over 5 minutes
This commit is contained in:
2026-08-25 22:56:09 -07:00
parent 5d7747839c
commit e4d1ba720a
17 changed files with 605 additions and 400 deletions
@@ -1,157 +0,0 @@
import { useState, useMemo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { Code, Container, Stack } from "@mantine/core";
import { usePlayers } from "@/features/players/queries";
import { useSheet } from "@/hooks/use-sheet";
import { useClientEvents } from "../queries";
import type { ClientEvent, ClientEventSearchParams } from "../types";
import {
DetailSheet,
PaginatedList,
RelativeTime,
SearchFilterBar,
TelemetryListItem,
} from "./shared";
export const defaultClientEventParams: ClientEventSearchParams = {
page: 1,
perPage: 50,
sortBy: "-created",
};
export const ClientEventsTable = () => {
const { t, i18n } = useLingui();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<ClientEvent | null>(null);
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
const { data: players } = usePlayers();
const searchParams: ClientEventSearchParams = useMemo(
() => ({
page,
perPage: 50,
name: search || undefined,
player: playerFilter || undefined,
sortBy: "-created",
}),
[page, search, playerFilter]
);
const { data: result } = useClientEvents(searchParams);
const playerName = (event: ClientEvent) =>
typeof event.player === "object" && event.player
? `${event.player.first_name} ${event.player.last_name}`
: t`Anonymous`;
const handleEventClick = (event: ClientEvent) => {
setSelectedEvent(event);
openDetails();
};
const handleCloseDetails = () => {
setSelectedEvent(null);
closeDetails();
};
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<SearchFilterBar
searchValue={search}
onSearchChange={(value) => {
setSearch(value);
setPage(1);
}}
searchPlaceholder={t`Event name`}
filters={[
{
placeholder: t`Player`,
value: playerFilter,
onChange: (value) => {
setPlayerFilter(value);
setPage(1);
},
data: players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
})),
searchable: true,
},
]}
summary={
<Plural value={result.totalItems} one="# total event" other="# total events" />
}
/>
<PaginatedList
items={result.items}
keyOf={(event) => event.id}
empty={t`No Events Found`}
page={page}
totalPages={result.totalPages}
onPageChange={setPage}
renderItem={(event) => (
<TelemetryListItem
title={event.name}
meta={[
playerName(event),
event.route_id || event.path || "",
<RelativeTime date={event.created} />,
]}
onClick={() => handleEventClick(event)}
/>
)}
/>
</Stack>
<DetailSheet
title={t`Event Details`}
opened={detailsOpened}
onClose={handleCloseDetails}
fields={
selectedEvent
? [
{ label: <Trans>Name</Trans>, value: selectedEvent.name },
{ label: <Trans>Player</Trans>, value: playerName(selectedEvent) },
...(selectedEvent.path
? [{ label: <Trans>Path</Trans>, value: selectedEvent.path }]
: []),
...(selectedEvent.route_id
? [{ label: <Trans>Route</Trans>, value: selectedEvent.route_id }]
: []),
...(selectedEvent.session_id
? [{ label: <Trans>Session</Trans>, value: selectedEvent.session_id }]
: []),
...(selectedEvent.value !== undefined && selectedEvent.value !== null
? [{ label: <Trans>Value</Trans>, value: selectedEvent.value }]
: []),
...(selectedEvent.props
? [
{
label: <Trans>Props</Trans>,
value: (
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(selectedEvent.props, null, 2)}
</Code>
),
},
]
: []),
...(selectedEvent.user_agent
? [{ label: <Trans>User Agent</Trans>, value: selectedEvent.user_agent }]
: []),
{
label: <Trans>Created</Trans>,
value: new Date(selectedEvent.created).toLocaleString(i18n.locale),
},
]
: []
}
/>
</Container>
);
};
@@ -0,0 +1,99 @@
import { Fragment } from "react";
import { Box, Group, Stack, Text } from "@mantine/core";
import { Plural, useLingui } from "@lingui/react/macro";
import { useSessionTimeline } from "../../queries";
import type { TimelineEntry } from "../../types";
const GAP_MARKER_MS = 5 * 60 * 1000;
const parseTs = (ts: string) => Date.parse(ts.replace(" ", "T"));
const dotColor = (entry: TimelineEntry): string => {
if (entry.kind === "error" || entry.success === false) return "var(--mantine-color-red-6)";
if (entry.kind === "server_fn") return "var(--mantine-color-green-6)";
return "var(--mantine-color-dimmed)";
};
const Dot = ({ color }: { color: string }) => (
<Box
w={6}
h={6}
style={{ borderRadius: "50%", backgroundColor: color, flexShrink: 0 }}
/>
);
export const SessionTimelineView = ({ sessionId }: { sessionId: string }) => {
const { i18n } = useLingui();
const { data } = useSessionTimeline(sessionId);
const clock = (ts: string) =>
i18n.date(new Date(parseTs(ts)), { hour: "2-digit", minute: "2-digit", second: "2-digit" });
return (
<Stack gap={0}>
<Group gap="md" pb="sm">
<Text size="xs" c="dimmed">
{i18n.date(new Date(parseTs(data.session.started)), {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
<Text size="xs" c="dimmed">
<Plural value={data.session.pageViews} one="# page" other="# pages" />
</Text>
{data.session.errors > 0 && (
<Text size="xs" c="red">
<Plural value={data.session.errors} one="# error" other="# errors" />
</Text>
)}
</Group>
{data.entries.map((entry, index) => {
const prev = data.entries[index - 1];
const gapMs = prev ? parseTs(entry.ts) - parseTs(prev.ts) : 0;
return (
<Fragment key={`${entry.ts}-${index}`}>
{gapMs > GAP_MARKER_MS && (
<Text size="xs" c="dimmed" py={4} pl={46}>
+{Math.round(gapMs / 60000)}m
</Text>
)}
<Group gap="xs" py={5} wrap="nowrap" align="center">
<Text
size="xs"
c="dimmed"
w={64}
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
>
{clock(entry.ts)}
</Text>
<Dot color={dotColor(entry)} />
<Text
size="sm"
lineClamp={1}
c={entry.kind === "error" ? "red" : undefined}
style={{ minWidth: 0, flex: 1 }}
>
{entry.label}
</Text>
{entry.kind === "server_fn" && entry.success === false && (
<Text size="xs" c="red" style={{ flexShrink: 0 }}>
✗
</Text>
)}
{entry.kind === "server_fn" && typeof entry.duration === "number" && entry.duration > 0 && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
{entry.duration}ms
</Text>
)}
</Group>
</Fragment>
);
})}
</Stack>
);
};
export default SessionTimelineView;
@@ -0,0 +1,81 @@
import { Suspense, useState } from "react";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import { Text } from "@mantine/core";
import Sheet from "@/components/sheet/sheet";
import { ListSkeleton } from "@/components/skeletons";
import { useSessions } from "../../queries";
import type { SessionSummary } from "../../types";
import { PaginatedList, RelativeTime, TelemetryListItem } from "../shared";
import SessionTimelineView from "./session-timeline";
export const defaultSessionParams = { days: 7, page: 1 } as const;
const playerName = (player?: SessionSummary["player"]): string | null => {
if (!player) return null;
return [player.first_name, player.last_name].filter(Boolean).join(" ") || null;
};
const durationLabel = (session: SessionSummary): string => {
const ms =
Date.parse(session.lastSeen.replace(" ", "T")) -
Date.parse(session.started.replace(" ", "T"));
const minutes = Math.round(ms / 60000);
return minutes < 1 ? "<1m" : `${minutes}m`;
};
export const SessionsList = () => {
const { t } = useLingui();
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<SessionSummary | null>(null);
const { data } = useSessions({ days: 7, page });
return (
<>
<PaginatedList
items={data.items}
keyOf={(session) => session.session_id}
empty={t`No sessions yet`}
page={data.page}
totalPages={data.totalPages}
onPageChange={setPage}
renderItem={(session) => (
<TelemetryListItem
title={playerName(session.player) ?? t`Anonymous`}
meta={[
<RelativeTime key="time" date={session.lastSeen} />,
<Plural key="pages" value={session.pageViews} one="# page" other="# pages" />,
durationLabel(session),
]}
error={
session.errors > 0 ? (
<Plural value={session.errors} one="# error" other="# errors" />
) : undefined
}
onClick={() => setSelected(session)}
/>
)}
/>
<Sheet
title={selected ? (playerName(selected.player) ?? t`Anonymous`) : ""}
opened={selected !== null}
onChange={() => setSelected(null)}
>
{selected && (
<Suspense fallback={<ListSkeleton rows={8} />}>
<SessionTimelineView sessionId={selected.session_id} />
</Suspense>
)}
</Sheet>
{data.totalItems > 0 && (
<Text size="xs" c="dimmed" px="md" py="sm">
<Trans>Last 7 days</Trans>
</Text>
)}
</>
);
};
export default SessionsList;
+2 -1
View File
@@ -3,6 +3,7 @@ export * from "./queries";
export * from "./range";
export * from "./components/charts";
export * from "./components/shared";
export { ClientEventsTable, defaultClientEventParams } from "./components/client-events-table";
export { SessionsList, defaultSessionParams } from "./components/sessions/sessions-list";
export { SessionTimelineView } from "./components/sessions/session-timeline";
export { ErrorBrowser } from "./components/error-browser";
export { UsageScreen, USAGE_METRICS } from "./components/usage-screen";
+20
View File
@@ -2,7 +2,9 @@ import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import {
getClientErrorGroups,
getRollupRange,
getSessionTimeline,
getTelemetryRuntimeStatus,
listSessions,
listTelemetryAlerts,
searchClientErrors,
searchClientEvents,
@@ -30,6 +32,10 @@ export const telemetryKeys = {
errorGroups: (params: ErrorGroupParams) => ["telemetry", "errorGroups", params] as const,
rollups: (params: RollupRangeParams) => ["telemetry", "rollups", params] as const,
alerts: (page: number) => ["telemetry", "alerts", page] as const,
sessions: (params: { days?: number; page?: number }) =>
["telemetry", "sessions", params] as const,
sessionTimeline: (sessionId: string) =>
["telemetry", "sessionTimeline", sessionId] as const,
runtimeStatus: ["telemetry", "runtimeStatus"] as const,
};
@@ -54,6 +60,14 @@ export const telemetryQueries = {
queryKey: telemetryKeys.alerts(page),
queryFn: () => listTelemetryAlerts({ data: { page } }),
}),
sessions: (params: { days?: number; page?: number } = {}) => ({
queryKey: telemetryKeys.sessions(params),
queryFn: () => listSessions({ data: params }),
}),
sessionTimeline: (sessionId: string) => ({
queryKey: telemetryKeys.sessionTimeline(sessionId),
queryFn: () => getSessionTimeline({ data: { sessionId } }),
}),
runtimeStatus: () => ({
queryKey: telemetryKeys.runtimeStatus,
queryFn: () => getTelemetryRuntimeStatus(),
@@ -72,5 +86,11 @@ export const useClientErrorGroups = (params: ErrorGroupParams) =>
export const useRollupRange = (params: RollupRangeParams) =>
useServerSuspenseQuery(telemetryQueries.rollups(params));
export const useSessions = (params: { days?: number; page?: number } = {}) =>
useServerSuspenseQuery(telemetryQueries.sessions(params));
export const useSessionTimeline = (sessionId: string) =>
useServerSuspenseQuery(telemetryQueries.sessionTimeline(sessionId));
export const useTelemetryRuntimeStatus = () =>
useServerSuspenseQuery(telemetryQueries.runtimeStatus());
+211
View File
@@ -15,9 +15,12 @@ import type {
ClientErrorGroup,
ClientEvent,
RollupRow,
SessionSummary,
SessionTimeline,
TelemetryAlert,
TelemetryListResult,
TelemetryRuntimeStatus,
TimelineEntry,
} from "./types";
const clientEventSearchSchema = z.object({
@@ -155,6 +158,214 @@ export const listTelemetryAlerts = createServerFn()
)
);
const pbTime = (date: Date): string => date.toISOString().replace("T", " ");
const playerInfoById = async (ids: Set<string>) => {
if (ids.size === 0) return new Map<string, { id: string; first_name?: string; last_name?: string }>();
const players = await pbAdmin.listPlayers();
return new Map(
players
.filter((player) => ids.has(player.id))
.map((player) => [
player.id,
{ id: player.id, first_name: player.first_name, last_name: player.last_name },
])
);
};
export const listSessions = createServerFn()
.validator(
z.object({
days: z.number().min(1).max(90).optional(),
page: z.number().optional(),
perPage: z.number().max(100).optional(),
})
)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<TelemetryListResult<SessionSummary>>(async () => {
const { days = 7, page = 1, perPage = 30 } = data;
const from = pbTime(new Date(Date.now() - days * 86400000));
const sessions = new Map<
string,
{ playerId?: string; started: string; lastSeen: string; pageViews: number; events: number; errors: number; user_agent?: string }
>();
await pbAdmin.pageCollection<{
session_id?: string;
player?: string;
name: string;
user_agent?: string;
created: string;
}>(
"client_events",
{ filter: `created >= '${from}'`, fields: "session_id,player,name,user_agent,created" },
(rows) => {
for (const row of rows) {
if (!row.session_id) continue;
const session = sessions.get(row.session_id) ?? {
playerId: row.player,
started: row.created,
lastSeen: row.created,
pageViews: 0,
events: 0,
errors: 0,
user_agent: row.user_agent,
};
session.playerId ||= row.player;
if (row.created < session.started) session.started = row.created;
if (row.created > session.lastSeen) session.lastSeen = row.created;
if (row.name === "page_view") session.pageViews++;
else if (!row.name.startsWith("vital.")) session.events++;
sessions.set(row.session_id, session);
}
}
);
await pbAdmin.pageCollection<{ session_id?: string; created: string }>(
"client_errors",
{ filter: `created >= '${from}'`, fields: "session_id,created" },
(rows) => {
for (const row of rows) {
if (!row.session_id) continue;
const session = sessions.get(row.session_id);
if (session) session.errors++;
}
}
);
const playerIds = new Set(
[...sessions.values()].map((s) => s.playerId).filter((id): id is string => !!id)
);
const players = await playerInfoById(playerIds);
const all: SessionSummary[] = [...sessions.entries()]
.map(([session_id, s]) => ({
session_id,
player: s.playerId ? players.get(s.playerId) : undefined,
started: s.started,
lastSeen: s.lastSeen,
pageViews: s.pageViews,
events: s.events,
errors: s.errors,
user_agent: s.user_agent,
}))
.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
const start = (page - 1) * perPage;
return {
items: all.slice(start, start + perPage),
page,
perPage,
totalPages: Math.max(1, Math.ceil(all.length / perPage)),
totalItems: all.length,
};
})
);
const SESSION_WINDOW_PAD_MS = 30_000;
const MAX_TIMELINE_ENTRIES = 500;
export const getSessionTimeline = createServerFn()
.validator(z.object({ sessionId: z.string().min(8).max(64) }))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<SessionTimeline>(async () => {
const events = await pbAdmin.searchClientEvents({
sessionId: data.sessionId,
perPage: 200,
sortBy: "created",
});
const errors = await pbAdmin.searchClientErrors({
sessionId: data.sessionId,
perPage: 200,
sortBy: "created",
});
const sessionErrors = errors.items;
if (events.items.length === 0 && sessionErrors.length === 0) {
throw new Error("Session not found");
}
const timestamps = [
...events.items.map((e) => e.created),
...sessionErrors.map((e) => e.created),
].sort();
const started = timestamps[0];
const lastSeen = timestamps[timestamps.length - 1];
const playerId = events.items.find((e) => typeof e.player === "string" && e.player)
?.player as string | undefined;
const entries: TimelineEntry[] = [];
for (const event of events.items) {
if (event.name.startsWith("vital.")) continue;
entries.push({
ts: event.created,
kind: event.name === "page_view" ? "page_view" : "event",
label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name,
detail: event.name === "page_view" ? undefined : event.path,
});
}
for (const error of sessionErrors) {
entries.push({
ts: error.created,
kind: "error",
label: error.message,
detail: error.path,
success: false,
});
}
// Server-fn activities have no session id; join by player inside the
// session's padded time window.
if (playerId) {
const windowFrom = pbTime(new Date(Date.parse(started.replace(" ", "T")) - SESSION_WINDOW_PAD_MS));
const windowTo = pbTime(new Date(Date.parse(lastSeen.replace(" ", "T")) + SESSION_WINDOW_PAD_MS));
const activities = await pbAdmin.searchActivities({
player: playerId,
from: windowFrom,
to: windowTo,
perPage: 200,
sortBy: "created",
});
for (const activity of activities.items) {
entries.push({
ts: activity.created,
kind: "server_fn",
label: activity.name,
detail: activity.error,
success: activity.success,
duration: activity.duration,
});
}
}
entries.sort((a, b) => (a.ts < b.ts ? -1 : 1));
const players = await playerInfoById(new Set(playerId ? [playerId] : []));
return {
session: {
session_id: data.sessionId,
player: playerId ? players.get(playerId) : undefined,
started,
lastSeen,
pageViews: events.items.filter((e) => e.name === "page_view").length,
events: events.items.filter(
(e) => e.name !== "page_view" && !e.name.startsWith("vital.")
).length,
errors: sessionErrors.length,
user_agent: events.items[0]?.user_agent,
},
entries: entries.slice(0, MAX_TIMELINE_ENTRIES),
};
})
);
export const getTelemetryRuntimeStatus = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.handler(async () =>
+27
View File
@@ -22,6 +22,33 @@ export interface ClientErrorGroup {
};
}
export interface SessionSummary {
session_id: string;
player?: { id: string; first_name?: string; last_name?: string };
started: string;
lastSeen: string;
pageViews: number;
events: number;
errors: number;
user_agent?: string;
}
export type TimelineKind = "page_view" | "event" | "error" | "server_fn";
export interface TimelineEntry {
ts: string;
kind: TimelineKind;
label: string;
detail?: string;
success?: boolean;
duration?: number;
}
export interface SessionTimeline {
session: SessionSummary;
entries: TimelineEntry[];
}
export interface TelemetryRuntimeStatus {
health: {
status: "ok" | "degraded";