import { CodeSurface } from "@agent-native/core/blocks"; import { agentNativePath } from "@agent-native/core/client/api-path"; import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useBuilderConnectFlow, useBuilderStatus, } from "@agent-native/core/client/settings"; import { IconCheck, IconChevronDown, IconCloud, IconCode, IconExternalLink, IconFilter, IconLoader2, IconPlayerPlay, IconRefresh, IconSearch, IconServer, IconSettings, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useReplayStorageStatus } from "@/hooks/use-replay-storage-status"; import { cn } from "@/lib/utils"; type ReplayRange = "24h" | "7d" | "30d" | "90d" | "all"; const SESSION_REPLAY_DOCS_URL = "https://www.agent-native.com/docs/tracking#session-replay"; const S3_STORAGE_FIELDS = [ { key: "S3_ENDPOINT", labelKey: "settings.s3EndpointLabel", placeholder: "https://s3.us-east-1.amazonaws.com", required: true, }, { key: "S3_BUCKET", labelKey: "settings.s3BucketLabel", placeholder: "my-replays-bucket", required: true, }, { key: "S3_ACCESS_KEY_ID", labelKey: "settings.s3AccessKeyLabel", placeholder: "AKIA...", required: true, }, { key: "S3_SECRET_ACCESS_KEY", labelKey: "settings.s3SecretAccessKeyLabel", placeholder: "••••••••", required: true, secret: true, }, { key: "S3_REGION", labelKey: "settings.s3RegionLabel", placeholder: "us-east-1", }, { key: "S3_PUBLIC_BASE_URL", labelKey: "settings.s3PublicBaseUrlLabel", placeholder: "https://cdn.example.com", }, ] as const; async function saveS3StorageSettings( values: Record, ): Promise { const vars = S3_STORAGE_FIELDS.map((field) => ({ key: field.key, value: (values[field.key] ?? "").trim(), })).filter((entry) => entry.value.length > 0); for (const { key, value } of vars) { const res = await fetch(agentNativePath("/_agent-native/secrets/adhoc"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: key, value, scope: "workspace", description: "Analytics S3-compatible replay storage", // i18n-ignore -- secret metadata description, not visible UI }), }); if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string; } | null; throw new Error(body?.error ?? `Save failed (${res.status})`); } } } type SessionRecordingSummary = { id: string; clientRecordingId: string; sessionId: string; userId: string | null; anonymousId: string | null; userKey: string | null; startedAt: string; endedAt: string | null; durationMs: number | null; chunkCount: number; eventCount: number; totalBytes: number; pageCount: number; errorCount: number; rageClickCount: number; privacyMode: string; firstUrl: string | null; lastUrl: string | null; path: string | null; hostname: string | null; referrer: string | null; app: string | null; template: string | null; metadata?: Record; status: "active" | "completed"; createdAt: string; updatedAt: string; lastIngestedAt: string | null; }; type SessionRecordingIdentity = Pick< SessionRecordingSummary, "id" | "userId" | "userKey" | "anonymousId" | "sessionId" >; type SessionRecordingDevice = Pick; const RANGE_OPTIONS: ReplayRange[] = ["24h", "7d", "30d", "90d", "all"]; const SESSION_QUERY_DEBOUNCE_MS = 250; /** * Local input state for a URL-backed filter, debounced into the URL. * * `urlValue` only resyncs local state when it changes for a reason other * than this hook's own debounced write (back/forward navigation, an agent * driven URL change, etc). React Router commits `setSearchParams` inside a * transition, so without this guard the echo of our own write can land * after a newer keystroke and clobber it. */ export function useDebouncedUrlFilter( urlValue: string, onCommit: (value: string) => void, ): [string, (value: string) => void] { const [input, setInput] = useState(urlValue); const lastPushedRef = useRef(urlValue); useEffect(() => { if (urlValue === lastPushedRef.current) return; lastPushedRef.current = urlValue; setInput(urlValue); }, [urlValue]); useEffect(() => { if (input === urlValue) return; const timeout = window.setTimeout(() => { lastPushedRef.current = input; onCommit(input); }, SESSION_QUERY_DEBOUNCE_MS); return () => window.clearTimeout(timeout); }, [input, urlValue, onCommit]); return [input, setInput]; } export default function SessionsPage() { const t = useT(); const [searchParams, setSearchParams] = useSearchParams(); const range = readRange(searchParams.get("range")); const app = searchParams.get("app") ?? ""; const query = searchParams.get("q") ?? ""; const from = useMemo(() => rangeToFrom(range), [range]); const updateFilter = useCallback( (key: string, value: string) => { setSearchParams( (current) => { const next = new URLSearchParams(current); const emptyDefault = (key === "range" && value === "30d") || value.trim() === ""; if (emptyDefault) next.delete(key); else next.set(key, value); return next; }, { replace: true }, ); }, [setSearchParams], ); const commitQuery = useCallback( (value: string) => updateFilter("q", value), [updateFilter], ); const [queryInput, setQueryInput] = useDebouncedUrlFilter(query, commitQuery); const commitApp = useCallback( (value: string) => updateFilter("app", value), [updateFilter], ); const [appInput, setAppInput] = useDebouncedUrlFilter(app, commitApp); const { data, isLoading, isFetching, refetch, error } = useActionQuery< SessionRecordingSummary[] >( "list-session-recordings", { from: from ?? undefined, app: app || undefined, query: query || undefined, limit: 100, }, { staleTime: 30_000 }, ); const recordings = data ?? []; return (
{t("sessions.filters")}
setQueryInput(event.target.value)} placeholder={t("sessions.searchPlaceholder")} className="h-9 ps-9" />
{t("sessions.filters")}
{t("sessions.filters")}
{t("sessions.range")}
{t("sessions.userFilters")}
setAppInput(event.target.value)} placeholder={t("sessions.appPlaceholder")} className="h-9" />
{t("sessions.eventFilters")}
{t("sessions.anyActivity")}
{error ? (
{t("sessions.loadFailed", { message: error.message })}
) : isLoading ? ( ) : recordings.length === 0 ? ( ) : (
{t("sessions.sessionPlaylist")}
{t("sessions.showing", { count: String(recordings.length), })}
{recordings.map((recording) => { const href = `/sessions/${encodeURIComponent(recording.id)}`; const lastSeen = recording.endedAt ?? recording.lastIngestedAt ?? recording.startedAt; const deviceLabel = sessionDeviceLabel(recording); return ( {formatSessionDuration(recording.durationMs)} {visitorLabel(recording, t)} {formatDateTime(lastSeen)} ·{" "} {t("sessions.eventCountCompact", { count: formatNumber(recording.eventCount), })} {pathLabel(recording)} {recording.hostname || recording.app || recording.template || shortId(recording.sessionId)} {formatPageCount(recording.pageCount, t)} {recording.errorCount > 0 ? ( {t("sessions.errorCount", { count: String(recording.errorCount), })} ) : null} {recording.rageClickCount > 0 ? ( {t("sessions.rageClicks", { count: String(recording.rageClickCount), })} ) : null} {deviceLabel ? ( {deviceLabel} ) : null} ); })}
)}
); } function EmptySessionsState() { const t = useT(); const storageStatus = useReplayStorageStatus(); const showStorageHint = !storageStatus.isLoading && !storageStatus.data?.configured; return (
{showStorageHint ? : null}

{t("sessions.noSessions")}

{t("sessions.noSessionsDescription")}

{t("sessions.installSnippetTitle")}
); } export function ReplayStorageHint({ embedded = false, }: { embedded?: boolean; }) { const t = useT(); const storageStatus = useReplayStorageStatus(); const builderStatus = useBuilderStatus(); const builderConnect = useBuilderConnectFlow({ popupUrl: builderStatus.status?.cliAuthUrl ?? builderStatus.status?.connectUrl, trackingSource: "analytics_sessions_storage_hint", trackingFlow: "replay_storage", onConnected: async () => { await Promise.all([storageStatus.refetch(), builderStatus.refetch()]); }, }); const builderConnected = Boolean( builderConnect.configured || builderStatus.status?.configured || storageStatus.data?.builderConfigured, ); const builderStatusLoading = storageStatus.isLoading || builderStatus.loading || !builderConnect.hasFetchedStatus; const [s3Expanded, setS3Expanded] = useState(false); const [s3Values, setS3Values] = useState>({}); const [savingStorage, setSavingStorage] = useState(false); async function handleSaveS3Storage() { const missing = S3_STORAGE_FIELDS.filter( (field) => "required" in field && field.required && !(s3Values[field.key] ?? "").trim(), ); if (missing.length > 0) { toast.error(t("settings.storageRequired")); return; } setSavingStorage(true); try { await saveS3StorageSettings(s3Values); setS3Values((current) => ({ ...current, S3_SECRET_ACCESS_KEY: "", })); await storageStatus.refetch(); toast.success(t("settings.storageSaved")); } catch (err) { toast.error( err instanceof Error ? err.message : t("settings.storageSaveFailed"), ); } finally { setSavingStorage(false); } } return (
{!embedded ? (
{t("sessions.storageSetupTitle")}

{t("sessions.storageSetupDescription")}

) : null}

{t("settings.s3OwnBucketDescription")}

{S3_STORAGE_FIELDS.map((field) => (
setS3Values((current) => ({ ...current, [field.key]: event.target.value, })) } placeholder={field.placeholder} autoComplete="off" disabled={savingStorage} />
))}
); } function SessionSkeleton() { return (
{Array.from({ length: 7 }).map((_, index) => ( ))}
); } function readRange(value: string | null): ReplayRange { return RANGE_OPTIONS.includes(value as ReplayRange) ? (value as ReplayRange) : "30d"; } function rangeToFrom(range: ReplayRange): string | null { if (range === "all") return null; const hours = range === "24h" ? 24 : range === "7d" ? 168 : range === "90d" ? 2160 : 720; return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); } function rangeLabel(value: ReplayRange, t: ReturnType): string { if (value === "24h") return t("sessions.last24h"); if (value === "7d") return t("sessions.last7d"); if (value === "30d") return t("sessions.last30d"); if (value === "90d") return t("sessions.last90d"); return t("sessions.allTime"); } function visitorLabel( recording: SessionRecordingIdentity, t: ReturnType, ): string { const email = emailLike(recording.userId) || emailLike(recording.userKey); if (email) return email; return ( recording.userId || recording.userKey || recording.anonymousId || t("sessions.anonymous") ); } function emailLike(value: string | null): string | null { if (!value?.includes("@")) return null; return value; } function shortId(value: string): string { return value.length > 22 ? `${value.slice(0, 10)}...${value.slice(-8)}` : value; } function pathLabel(recording: SessionRecordingSummary): string { if (recording.path) return recording.path; if (recording.lastUrl) return safePathFromUrl(recording.lastUrl); if (recording.firstUrl) return safePathFromUrl(recording.firstUrl); return shortId(recording.sessionId); } function safePathFromUrl(value: string): string { try { const url = new URL(value); return `${url.pathname}${url.search}` || url.hostname; } catch { return value; } } export function sessionDeviceLabel( recording: SessionRecordingDevice, ): string | null { const metadata = recordValue(recording.metadata); const device = recordValue(metadata.device); const browser = recordValue(metadata.browser); const client = recordValue(metadata.client); const os = osLabelFromValue( metadata.os ?? metadata.operatingSystem ?? metadata.operating_system ?? metadata.deviceOs ?? metadata.device_os ?? device.os ?? device.operatingSystem ?? browser.os ?? client.os, ); if (os) return os; const platform = stringValue( metadata.platform ?? device.platform ?? client.platform, ); const platformLabel = inferOsLabel(platform); if (platformLabel) return platformLabel; const userAgent = stringValue( metadata.userAgent ?? metadata.user_agent ?? device.userAgent ?? device.user_agent ?? client.userAgent ?? client.user_agent, ); return inferOsLabel(userAgent); } function osLabelFromValue(value: unknown): string | null { const record = recordValue(value); if (record) { const name = stringValue(record.name ?? record.family ?? record.os); const version = stringValue(record.version ?? record.release); if (name && version) return `${normalizeOsName(name)} ${version}`; if (name) return normalizeOsName(name); } const label = stringValue(value); if (!label) return null; return inferOsLabel(label) ?? label; } function inferOsLabel(value: string | null): string | null { if (!value) return null; if (/cros|chrome os/i.test(value)) return "ChromeOS"; if (/iphone|ipad|ipod|ios/i.test(value)) return "iOS"; if (/mac os x|macintosh|macintel|darwin|macos/i.test(value)) return "macOS"; if (/windows|win32|win64/i.test(value)) return "Windows"; if (/android/i.test(value)) return "Android"; if (/linux/i.test(value)) return "Linux"; return null; } function normalizeOsName(value: string): string { return inferOsLabel(value) ?? value; } function recordValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function stringValue(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } function formatDateTime(value: string): string { const date = new Date(value); if (!Number.isFinite(date.getTime())) return value; return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }).format(date); } export function formatSessionDuration(ms: number | null): string { if (!ms || !Number.isFinite(ms) || ms <= 0) return "0m"; const seconds = Math.round(ms / 1000); const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m`; } function formatNumber(value: number): string { return new Intl.NumberFormat().format(value); } function formatPageCount(value: number, t: ReturnType): string { const count = Math.max(0, Math.round(value || 0)); if (count === 1) { return t("sessions.pageCountCompactSingular", { count: formatNumber(count), }); } return t("sessions.pageCountCompact", { count: formatNumber(count) }); } const SESSION_REPLAY_SNIPPET = `// Agent Native templates already call configureTracking(). import { configureTracking } from "@agent-native/core/client/observability"; configureTracking({ key: "anpk_...", endpoint: "https://analytics.example.com/api/analytics/track", sessionReplay: { enabled: true, requireSignedInUser: true, sampleRate: 1, }, getDefaultProps: (_event, props) => ({ ...props, app: "my-app", template: "my-template", }), });`;