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:
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user