diff --git a/.gitignore b/.gitignore index 999f3acbc..625db2834 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ target/ .claude/ .agent-browser/ -.agent-team/TODO.md +.agent-team/ diff --git a/app-server/src/main.rs b/app-server/src/main.rs index ed3b897df..e6dd812f7 100644 --- a/app-server/src/main.rs +++ b/app-server/src/main.rs @@ -1616,7 +1616,8 @@ fn main() -> anyhow::Result<()> { .service(routes::spans::search_spans) .service(routes::rollouts::run) .service(routes::rollouts::update_status) - .service(routes::signals::submit_signal_job), + .service(routes::signals::submit_signal_job) + .service(routes::spans::get_skeleton_hashes), ) .service(routes::probes::check_health) .service(routes::probes::check_ready) diff --git a/app-server/src/routes/spans.rs b/app-server/src/routes/spans.rs index 776727490..a148d58f4 100644 --- a/app-server/src/routes/spans.rs +++ b/app-server/src/routes/spans.rs @@ -13,6 +13,7 @@ use crate::{ quickwit::client::QuickwitClient, routes::ResponseResult, search::snippets::SearchSpanHit, + signals::utils::structural_skeleton_hash, traces::{OBSERVATIONS_EXCHANGE, OBSERVATIONS_ROUTING_KEY, spans::SpanAttributes}, }; @@ -149,3 +150,26 @@ pub async fn search_spans( Ok(HttpResponse::Ok().json(results)) } + +#[derive(Deserialize)] +pub struct SkeletonHashRequest { + pub texts: Vec, +} + +#[post("skeleton-hashes")] +pub async fn get_skeleton_hashes( + _project_id: web::Path, + request: web::Json, +) -> ResponseResult { + let texts = &request.texts; + + if texts.is_empty() || texts.len() > 200 { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": "texts must contain between 1 and 200 items" + }))); + } + + let hashes: Vec = texts.iter().map(|t| structural_skeleton_hash(t)).collect(); + + Ok(HttpResponse::Ok().json(hashes)) +} diff --git a/frontend/app/api/projects/[projectId]/traces/io/route.ts b/frontend/app/api/projects/[projectId]/traces/io/route.ts new file mode 100644 index 000000000..e6e41a158 --- /dev/null +++ b/frontend/app/api/projects/[projectId]/traces/io/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest } from "next/server"; +import { prettifyError, ZodError } from "zod/v4"; + +import { getMainAgentIOBatch } from "@/lib/actions/sessions/trace-io"; + +export async function POST(req: NextRequest, props: { params: Promise<{ projectId: string }> }): Promise { + const { projectId } = await props.params; + try { + const body = await req.json(); + const result = await getMainAgentIOBatch({ ...body, projectId }); + return Response.json(result); + } catch (error) { + if (error instanceof ZodError) { + return Response.json({ error: prettifyError(error) }, { status: 400 }); + } + return Response.json({ error: error instanceof Error ? error.message : "Internal server error" }, { status: 500 }); + } +} diff --git a/frontend/components/common/advanced-search/index.tsx b/frontend/components/common/advanced-search/index.tsx index 2c63e86ca..1b69ddf9c 100644 --- a/frontend/components/common/advanced-search/index.tsx +++ b/frontend/components/common/advanced-search/index.tsx @@ -21,7 +21,7 @@ import { interface AdvancedSearchInnerProps { filters: ColumnFilter[]; - resource: "traces" | "spans"; + resource?: "traces" | "spans"; placeholder?: string; className?: string; disabled?: boolean; @@ -105,7 +105,7 @@ const AdvancedSearchInner = ({ }, [urlTags, setTags, updateLastSubmitted, mode]); useSWR<{ suggestions: AutocompleteSuggestion[] }>( - suggestions ? null : `/api/projects/${projectId}/${resource}/autocomplete`, + suggestions || !resource ? null : `/api/projects/${projectId}/${resource}/autocomplete`, swrFetcher, { onSuccess: (data) => { @@ -146,7 +146,7 @@ AdvancedSearchInner.displayName = "AdvancedSearchInner"; interface AdvancedSearchProps { filters: ColumnFilter[]; - resource: "traces" | "spans"; + resource?: "traces" | "spans"; placeholder?: string; className?: string; disabled?: boolean; diff --git a/frontend/components/common/advanced-search/store/index.tsx b/frontend/components/common/advanced-search/store/index.tsx index 26ed5a33d..52666eb71 100644 --- a/frontend/components/common/advanced-search/store/index.tsx +++ b/frontend/components/common/advanced-search/store/index.tsx @@ -247,7 +247,9 @@ function createCoreSlice( submit: (router, pathname, searchParams) => { const { tags, inputValue, onSubmit, mode } = get(); - const filterObjects = tags.map(createFilterFromTag); + // Skip incomplete tags (empty value) so we don't submit invalid filters + const completeTags = tags.filter((t) => (Array.isArray(t.value) ? t.value.length > 0 : t.value !== "")); + const filterObjects = completeTags.map(createFilterFromTag); const searchValue = inputValue.trim(); set({ isOpen: false, activeIndex: -1, activeRecentIndex: -1 }); diff --git a/frontend/components/traces/sessions-table/columns.tsx b/frontend/components/traces/sessions-table/columns.tsx index d70c31e82..000e3d8d6 100644 --- a/frontend/components/traces/sessions-table/columns.tsx +++ b/frontend/components/traces/sessions-table/columns.tsx @@ -1,20 +1,4 @@ -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { type ColumnDef } from "@tanstack/react-table"; -import { ChevronRightIcon } from "lucide-react"; - -import ClientTimestampFormatter from "@/components/client-timestamp-formatter"; -import TagsCell from "@/components/tags/tags-cell"; import { type ColumnFilter } from "@/components/ui/infinite-datatable/ui/datatable-filter/utils"; -import Mono from "@/components/ui/mono"; -import { type SessionRow } from "@/lib/traces/types"; -import { getDurationString } from "@/lib/utils"; - -const format = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 5, - minimumFractionDigits: 1, -}); export const filters: ColumnFilter[] = [ { @@ -22,11 +6,6 @@ export const filters: ColumnFilter[] = [ name: "Session ID", dataType: "string", }, - { - key: "user_id", - name: "User ID", - dataType: "string", - }, { key: "trace_count", name: "Trace Count", @@ -42,157 +21,9 @@ export const filters: ColumnFilter[] = [ name: "Total Tokens", dataType: "number", }, - { - key: "input_tokens", - name: "Input Tokens", - dataType: "number", - }, - { - key: "output_tokens", - name: "Output Tokens", - dataType: "number", - }, { key: "total_cost", name: "Total Cost", dataType: "number", }, - { - key: "input_cost", - name: "Input Cost", - dataType: "number", - }, - { - key: "output_cost", - name: "Output Cost", - dataType: "number", - }, - { - key: "tags", - name: "Span tags", - dataType: "string", - }, -]; - -export const columns: ColumnDef[] = [ - { - header: "Type", - cell: ({ row }) => - row.original?.subRows ? ( -
- Session - {row.getIsExpanded() ? ( - - ) : ( - - )} -
- ) : ( -
- Trace -
- ), - id: "type", - size: 120, - }, - { - accessorFn: (row) => row.id || row.sessionId, - header: "ID", - id: "id", - cell: (row) => {row.getValue()}, - }, - { - accessorFn: (row) => row.startTime, - header: "Start time", - cell: (row) => , - id: "start_time", - size: 150, - }, - { - accessorFn: (row) => { - if (!row?.subRows) { - return getDurationString(row.startTime, row.endTime); - } - - return row.duration.toFixed(2) + "s"; - }, - header: "Duration", - id: "duration", - size: 100, - }, - { - accessorFn: (row) => format.format(row.inputCost), - header: "Input cost", - id: "input_cost", - size: 120, - }, - { - accessorFn: (row) => format.format(row.outputCost), - header: "Output cost", - id: "output_cost", - size: 120, - }, - { - accessorFn: (row) => format.format(row.totalCost), - header: "Total cost", - id: "total_cost", - size: 120, - }, - { - accessorFn: (row) => row.inputTokens, - header: "Input tokens", - id: "input_tokens", - size: 120, - }, - { - accessorFn: (row) => row.outputTokens, - header: "Output tokens", - id: "output_tokens", - size: 120, - }, - { - accessorFn: (row) => row.totalTokens, - header: "Total tokens", - id: "total_tokens", - size: 120, - }, - { - accessorFn: (row) => (row?.subRows ? row.traceCount || 0 : "-"), - header: "Trace Count", - id: "trace_count", - size: 120, - }, - { - accessorFn: (row) => (row?.subRows ? "-" : row.userId), - header: "User ID", - id: "user_id", - cell: (row) => {row.getValue() || "-"}, - }, - { - accessorFn: (row) => ("spanTags" in row ? row.spanTags : "-"), - cell: (row) => { - const tags = row.getValue() as string[]; - if (Array.isArray(tags) && tags?.length > 0) return ; - return "-"; - }, - header: "Span tags", - accessorKey: "spanTags", - id: "span_tags", - }, -]; - -export const defaultSessionsColumnOrder = [ - "type", - "id", - "start_time", - "duration", - "input_cost", - "output_cost", - "total_cost", - "input_tokens", - "output_tokens", - "total_tokens", - "trace_count", - "user_id", - "span_tags", ]; diff --git a/frontend/components/traces/sessions-table/index.tsx b/frontend/components/traces/sessions-table/index.tsx index 93a5ddcfc..733e45d5a 100644 --- a/frontend/components/traces/sessions-table/index.tsx +++ b/frontend/components/traces/sessions-table/index.tsx @@ -1,34 +1,32 @@ "use client"; -import { type Row } from "@tanstack/react-table"; -import { get } from "lodash"; import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect } from "react"; +import { shallow } from "zustand/shallow"; -import SearchInput from "@/components/common/search-input"; -import { columns, defaultSessionsColumnOrder, filters } from "@/components/traces/sessions-table/columns"; -import { useTraceViewNavigation } from "@/components/traces/trace-view/navigation-context"; +import AdvancedSearch from "@/components/common/advanced-search"; +import { filters } from "@/components/traces/sessions-table/columns"; +import { SessionsStoreProvider, useSessionsStoreContext } from "@/components/traces/sessions-table/sessions-store"; import { useTracesStoreContext } from "@/components/traces/traces-store"; import DateRangeFilter from "@/components/ui/date-range-filter"; -import { InfiniteDataTable } from "@/components/ui/infinite-datatable"; import { useInfiniteScroll } from "@/components/ui/infinite-datatable/hooks"; import { DataTableStateProvider } from "@/components/ui/infinite-datatable/model/datatable-store"; -import ColumnsMenu from "@/components/ui/infinite-datatable/ui/columns-menu.tsx"; -import DataTableFilter, { DataTableFilterList } from "@/components/ui/infinite-datatable/ui/datatable-filter"; +import DataTableFilter from "@/components/ui/infinite-datatable/ui/datatable-filter"; import RefreshButton from "@/components/ui/infinite-datatable/ui/refresh-button.tsx"; import { useToast } from "@/lib/hooks/use-toast"; import { type SessionRow, type TraceRow } from "@/lib/traces/types"; +import { type SessionSortColumn, type SortDirection } from "./session-table-header"; +import SessionsVirtualList from "./sessions-virtual-list"; + const FETCH_SIZE = 50; export default function SessionsTable() { return ( - - + + + + ); } @@ -39,9 +37,8 @@ function SessionsTableContent() { const router = useRouter(); const { projectId } = useParams(); const { toast } = useToast(); - const { setTraceId, traceId } = useTracesStoreContext((state) => ({ + const { setTraceId } = useTracesStoreContext((state) => ({ setTraceId: state.setTraceId, - traceId: state.traceId, })); const filter = searchParams.getAll("filter"); @@ -50,18 +47,59 @@ function SessionsTableContent() { const pastHours = searchParams.get("pastHours"); const textSearchFilter = searchParams.get("search"); - const { setNavigationRefList } = useTraceViewNavigation(); + const sortColumnParam = searchParams.get("sortColumn") as SessionSortColumn | null; + const sortDirectionParam = searchParams.get("sortDir") as SortDirection | null; + const sortColumn = sortColumnParam ?? undefined; + const sortDirection = sortDirectionParam ?? undefined; + + const { expandedSessions, loadingSessions, sessionTraces } = useSessionsStoreContext( + (state) => ({ + expandedSessions: state.expandedSessions, + loadingSessions: state.loadingSessions, + sessionTraces: state.sessionTraces, + }), + shallow + ); + + const { toggleSession, collapseSession, setLoadingSession, setSessionTraces, resetExpandState } = + useSessionsStoreContext( + (state) => ({ + toggleSession: state.toggleSession, + collapseSession: state.collapseSession, + setLoadingSession: state.setLoadingSession, + setSessionTraces: state.setSessionTraces, + resetExpandState: state.resetExpandState, + }), + shallow + ); + + // Serialize filter array for stable dependency comparison + const filterKey = JSON.stringify(filter); - // Initialize with default time range if needed - do this BEFORE useInfiniteScroll + // Reset expanded/trace/timeline state when query params change + useEffect(() => { + resetExpandState(); + }, [ + endDate, + filterKey, + pastHours, + projectId, + sortColumn, + sortDirection, + startDate, + textSearchFilter, + resetExpandState, + ]); + + // Initialize with default time range if needed useEffect(() => { if (!pastHours && !startDate && !endDate) { const sp = new URLSearchParams(searchParams.toString()); - sp.set("pastHours", "24"); + sp.set("pastHours", "72"); router.replace(`${pathName}?${sp.toString()}`); } }, [pastHours, startDate, endDate, searchParams, pathName, router]); - // Only enable fetching when we have valid time params const shouldFetch = !!(pastHours || startDate || endDate); const fetchSessions = useCallback( @@ -75,20 +113,17 @@ function SessionsTableContent() { if (startDate != null) urlParams.set("startDate", startDate); if (endDate != null) urlParams.set("endDate", endDate); - filter.forEach((filter) => urlParams.append("filter", filter)); + filter.forEach((f) => urlParams.append("filter", f)); if (typeof textSearchFilter === "string" && textSearchFilter.length > 0) { urlParams.set("search", textSearchFilter); } - const url = `/api/projects/${projectId}/sessions?${urlParams.toString()}`; + if (sortColumn) urlParams.set("sortColumn", sortColumn); + if (sortDirection) urlParams.set("sortDirection", sortDirection); - const res = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); + const url = `/api/projects/${projectId}/sessions?${urlParams.toString()}`; + const res = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" } }); if (!res.ok) { const text = (await res.json()) as { error: string }; @@ -105,7 +140,7 @@ function SessionsTableContent() { throw error; } }, - [endDate, filter, pastHours, projectId, startDate, textSearchFilter, toast] + [endDate, filter, pastHours, projectId, sortColumn, sortDirection, startDate, textSearchFilter, toast] ); const { @@ -113,68 +148,36 @@ function SessionsTableContent() { hasMore, isFetching, isLoading, + error, fetchNextPage, refetch, - updateData, - error, } = useInfiniteScroll({ fetchFn: fetchSessions, enabled: shouldFetch, - deps: [endDate, filter, pastHours, projectId, startDate, textSearchFilter], + deps: [endDate, filter, pastHours, projectId, sortColumn, sortDirection, startDate, textSearchFilter], }); - useEffect(() => { - setNavigationRefList((sessions || [])?.flatMap((s) => s?.subRows)?.map((t) => t?.id)); - }, [setNavigationRefList, sessions]); - - const handleRowClick = useCallback( - async (row: Row) => { - // If clicking on a trace row (not a session row with subRows) - if (!row.original.subRows) { - const params = new URLSearchParams(searchParams); - setTraceId(row.original.id); - params.set("traceId", row.original.id); - router.push(`${pathName}?${params.toString()}`); - return; - } + const handleToggleSession = useCallback( + async (sessionId: string) => { + const result = toggleSession(sessionId); + if (result.action === "collapsed") return; - const isCurrentlyExpanded = row.getIsExpanded(); - row.toggleExpanded(); - - // If collapsing, clear the subRows - if (isCurrentlyExpanded) { - updateData((sessions) => - sessions?.map((s) => { - if (s.sessionId === row.original.sessionId) { - return { - ...s, - subRows: [], - }; - } - return s; - }) - ); - return; - } - - // If expanding, fetch traces for this session - const filter = { - column: "session_id", - value: row.original.sessionId, - operator: "eq", - }; + const controller = result.controller; try { const urlParams = new URLSearchParams(); urlParams.set("pageNumber", "0"); urlParams.set("pageSize", "50"); - urlParams.set("filter", JSON.stringify(filter)); + urlParams.set("filter", JSON.stringify({ column: "session_id", value: sessionId, operator: "eq" })); + urlParams.set("sortDirection", "ASC"); if (pastHours != null) urlParams.set("pastHours", pastHours); if (startDate != null) urlParams.set("startDate", startDate); if (endDate != null) urlParams.set("endDate", endDate); - const res = await fetch(`/api/projects/${projectId}/traces?${urlParams.toString()}`); + const res = await fetch(`/api/projects/${projectId}/traces?${urlParams.toString()}`, { + signal: controller.signal, + }); if (!res.ok) { throw new Error(`Failed to fetch traces: ${res.status} ${res.statusText}`); @@ -182,59 +185,102 @@ function SessionsTableContent() { const traces = (await res.json()) as { items: TraceRow[] }; - // Update the session with its subRows (traces) - updateData((sessions) => - sessions?.map((s) => { - if (s.sessionId === row.original.sessionId) { - return { - ...s, - subRows: traces.items.toReversed(), - }; - } - return s; - }) - ); + if (controller.signal.aborted) return; + + setSessionTraces(sessionId, traces.items); + setLoadingSession(sessionId, false); } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; toast({ - title: "Failed to load traces. Please try again.", + title: "Error", + description: error instanceof Error ? error.message : "Failed to load traces. Please try again.", variant: "destructive", }); - // Collapse the row again since we failed to fetch - row.toggleExpanded(); + collapseSession(sessionId); + setLoadingSession(sessionId, false); } }, - [setTraceId, pathName, projectId, router, searchParams, pastHours, startDate, endDate, toast, updateData] + [ + pastHours, + startDate, + endDate, + projectId, + toast, + toggleSession, + setLoadingSession, + setSessionTraces, + collapseSession, + ] ); + const handleTraceClick = useCallback( + (traceId: string) => { + const params = new URLSearchParams(searchParams.toString()); + setTraceId(traceId); + params.set("traceId", traceId); + router.push(`${pathName}?${params.toString()}`); + }, + [setTraceId, pathName, router, searchParams] + ); + + const handleSort = useCallback( + (column: SessionSortColumn, direction: SortDirection) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("sortColumn", column); + params.set("sortDir", direction); + router.push(`${pathName}?${params.toString()}`); + }, + [pathName, router, searchParams] + ); + + const handleClearSort = useCallback(() => { + const params = new URLSearchParams(searchParams.toString()); + params.delete("sortColumn"); + params.delete("sortDir"); + router.push(`${pathName}?${params.toString()}`); + }, [pathName, router, searchParams]); + return ( -
- - className="w-full" - columns={columns} - data={sessions} - getRowId={(session) => get(session, ["id"], session.sessionId)} - onRowClick={handleRowClick} - focusedRowId={traceId || searchParams.get("traceId")} +
+
+
+ + + { + resetExpandState(); + refetch(); + }} + variant="outline" + /> +
+
+ +
+
+ -
- - ({ - id: column.id!, - label: typeof column.header === "string" ? column.header : column.id!, - }))} - /> - - - -
- - + onRetry={refetch} + sortColumn={sortColumn} + sortDirection={sortDirection} + onSort={handleSort} + onClearSort={handleClearSort} + />
); } diff --git a/frontend/components/traces/sessions-table/session-row.tsx b/frontend/components/traces/sessions-table/session-row.tsx new file mode 100644 index 000000000..e7f4b2d01 --- /dev/null +++ b/frontend/components/traces/sessions-table/session-row.tsx @@ -0,0 +1,87 @@ +import { ChevronRightIcon } from "lucide-react"; + +import CopyTooltip from "@/components/ui/copy-tooltip"; +import { type SessionRow as SessionRowType } from "@/lib/traces/types"; +import { formatDuration } from "@/lib/traces/utils"; +import { cn } from "@/lib/utils"; + +import { + CHEVRON_COLUMN_WIDTH_CLASSNAME, + COST_COLUMN_WIDTH_CLASSNAME, + COUNT_COLUMN_WIDTH_CLASSNAME, + DURATION_COLUMN_WIDTH_CLASSNAME, + SESSION_ID_COLUMN_WIDTH_CLASSNAME, + TIME_RANGE_COLUMN_WIDTH_CLASSNAME, + TOKENS_COLUMN_WIDTH_CLASSNAME, +} from "./session-table-header"; +import SessionTimeRange from "./session-time-range"; + +const compactNumberFormat = new Intl.NumberFormat("en-US", { + notation: "compact", +}); + +interface SessionRowProps { + session: SessionRowType; + isExpanded: boolean; + isLast?: boolean; + onToggle: () => void; +} + +export default function SessionRow({ session, isExpanded, isLast, onToggle }: SessionRowProps) { + return ( +
+ + +
+ +
+ +
e.stopPropagation()} + > + + + {session.sessionId} + + +
+ +
+ {formatDuration(session.duration ?? 0)} +
+ +
+ {compactNumberFormat.format(session.totalTokens ?? 0)} +
+ +
+ ${(session.totalCost ?? 0).toFixed(2)} +
+ +
+ {session.traceCount ?? 0} +
+ +
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/session-table-header/index.tsx b/frontend/components/traces/sessions-table/session-table-header/index.tsx new file mode 100644 index 000000000..348779dd1 --- /dev/null +++ b/frontend/components/traces/sessions-table/session-table-header/index.tsx @@ -0,0 +1,81 @@ +import SortableHeaderCell from "./sortable-header-cell"; + +export const CHEVRON_COLUMN_WIDTH_CLASSNAME = "w-10"; +export const TIME_RANGE_COLUMN_WIDTH_CLASSNAME = "w-54"; +export const SESSION_ID_COLUMN_WIDTH_CLASSNAME = "w-84"; +export const DURATION_COLUMN_WIDTH_CLASSNAME = "w-32"; +export const TOKENS_COLUMN_WIDTH_CLASSNAME = "w-32"; +export const COST_COLUMN_WIDTH_CLASSNAME = "w-32"; +export const COUNT_COLUMN_WIDTH_CLASSNAME = "w-28"; + +export type SessionSortColumn = "start_time" | "duration" | "total_tokens" | "total_cost" | "trace_count"; +export type SortDirection = "ASC" | "DESC"; + +interface SessionTableHeaderProps { + sortColumn?: SessionSortColumn; + sortDirection?: SortDirection; + onSort: (column: SessionSortColumn, direction: SortDirection) => void; + onClearSort: () => void; +} + +export default function SessionTableHeader({ + sortColumn, + sortDirection, + onSort, + onClearSort, +}: SessionTableHeaderProps) { + return ( +
+
+ +
+ ID +
+ + + + +
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/session-table-header/sortable-header-cell.tsx b/frontend/components/traces/sessions-table/session-table-header/sortable-header-cell.tsx new file mode 100644 index 000000000..01407625a --- /dev/null +++ b/frontend/components/traces/sessions-table/session-table-header/sortable-header-cell.tsx @@ -0,0 +1,97 @@ +import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +import { type SessionSortColumn, type SortDirection } from "./index"; + +interface SortableHeaderCellProps { + label: string; + column: SessionSortColumn; + sortColumn?: SessionSortColumn; + sortDirection?: SortDirection; + onSort: (column: SessionSortColumn, direction: SortDirection) => void; + onClearSort: () => void; + className: string; +} + +export default function SortableHeaderCell({ + label, + column, + sortColumn, + sortDirection, + onSort, + onClearSort, + className, +}: SortableHeaderCellProps) { + const isActive = sortColumn === column; + const isAsc = isActive && sortDirection === "ASC"; + const isDesc = isActive && sortDirection === "DESC"; + + return ( +
+ {label} +
+ + + + + + { + e.stopPropagation(); + if (isAsc) { + onClearSort(); + } else { + onSort(column, "ASC"); + } + }} + > + {isAsc ? : } + Sort ascending + + { + e.stopPropagation(); + if (isDesc) { + onClearSort(); + } else { + onSort(column, "DESC"); + } + }} + > + {isDesc ? : } + Sort descending + + + +
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/session-time-range.tsx b/frontend/components/traces/sessions-table/session-time-range.tsx new file mode 100644 index 000000000..e61a8fe3e --- /dev/null +++ b/frontend/components/traces/sessions-table/session-time-range.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { TooltipContent } from "@/components/ui/tooltip"; +import { formatTimeRange } from "@/lib/utils"; + +const timeFormat: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}; + +const dateFormat: Intl.DateTimeFormatOptions = { + weekday: "short", + month: "short", + day: "numeric", +}; + +interface SessionTimeRangeProps { + startTime: string; + endTime: string; +} + +export default function SessionTimeRange({ startTime, endTime }: SessionTimeRangeProps) { + const start = new Date(startTime); + const end = new Date(endTime); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return {startTime}; + } + + return {formatTimeRange(start, end)}; +} + +export function TraceTimeTooltip({ startTime, endTime }: { startTime: string; endTime: string }) { + const start = new Date(startTime); + const end = new Date(endTime); + const sameDay = start.toDateString() === end.toDateString(); + + if (sameDay) { + return ( + +
+ {start.toLocaleDateString([], dateFormat)} + + {start.toLocaleTimeString([], timeFormat)} + {" – "} + {end.toLocaleTimeString([], timeFormat)} + +
+
+ ); + } + + return ( + +
+
+ {start.toLocaleDateString([], dateFormat)} + {start.toLocaleTimeString([], timeFormat)} +
+
+ {end.toLocaleDateString([], dateFormat)} + {end.toLocaleTimeString([], timeFormat)} +
+
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/session-trace-card.tsx b/frontend/components/traces/sessions-table/session-trace-card.tsx new file mode 100644 index 000000000..7356534f1 --- /dev/null +++ b/frontend/components/traces/sessions-table/session-trace-card.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { TooltipPortal } from "@radix-ui/react-tooltip"; +import { ChevronDown, ChevronUp, CircleDollarSign, Clock3, Coins } from "lucide-react"; +import { useState } from "react"; + +import Markdown from "@/components/traces/trace-view/list/markdown"; +import CopyTooltip from "@/components/ui/copy-tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { type TraceRow } from "@/lib/traces/types"; +import { cn, getDurationString } from "@/lib/utils"; + +import { TraceTimeTooltip } from "./session-time-range"; +import { type TraceIOEntry } from "./use-batched-trace-io"; + +const compactNumberFormat = new Intl.NumberFormat("en-US", { + notation: "compact", +}); + +interface SessionTraceCardProps { + trace: TraceRow; + isLast: boolean; + onClick?: () => void; + traceIO?: TraceIOEntry; + isIOLoading: boolean; +} + +export default function SessionTraceCard({ trace, isLast, onClick, traceIO, isIOLoading }: SessionTraceCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+
+
+
+ + + + + {new Date(trace.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} + {" – "} + {new Date(trace.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + })} + + + + + + + +
e.stopPropagation()}> + {trace?.topSpanName && ( + + + {trace.topSpanName} + + + )} +
+
+
+
+ + + {getDurationString(trace.startTime, trace.endTime)} + +
+
+ + + {compactNumberFormat.format(trace.totalTokens)} + +
+
+ + + {(trace.totalCost ?? 0).toFixed(2)} + +
+
+
+ + {/* Input column */} + { + setIsExpanded((prev) => !prev); + e.stopPropagation(); + }} + /> + + {/* Output column */} + { + setIsExpanded((prev) => !prev); + e.stopPropagation(); + }} + /> +
+
+ ); +} + +function TraceIOContent({ + text, + isLoading, + fallback, + isExpanded, + onExpand, +}: { + text: string | null | undefined; + isLoading: boolean; + fallback: string; + isExpanded: boolean; + onExpand: (e: React.MouseEvent) => void; +}) { + return ( +
+
+ {isLoading && !text ? ( +
+ + + +
+ ) : !text ? ( +

{fallback}

+ ) : ( + + )} +
+ +
+ ); +} diff --git a/frontend/components/traces/sessions-table/sessions-store.tsx b/frontend/components/traces/sessions-table/sessions-store.tsx new file mode 100644 index 000000000..4b2bc06ed --- /dev/null +++ b/frontend/components/traces/sessions-table/sessions-store.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { createContext, type PropsWithChildren, useContext, useState } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { createStore } from "zustand/vanilla"; + +import { type TraceRow } from "@/lib/traces/types"; + +export type SessionsState = { + expandedSessions: Set; + loadingSessions: Set; + sessionTraces: Record; +}; + +export type SessionsActions = { + expandSession: (sessionId: string) => void; + collapseSession: (sessionId: string) => void; + toggleSession: (sessionId: string) => { action: "expanded"; controller: AbortController } | { action: "collapsed" }; + setLoadingSession: (sessionId: string, loading: boolean) => void; + setSessionTraces: (sessionId: string, traces: TraceRow[]) => void; + resetExpandState: () => void; + getController: (sessionId: string) => AbortController; +}; + +export type SessionsStore = SessionsState & SessionsActions; + +export type SessionsStoreApi = ReturnType; + +const DEFAULT_STATE: SessionsState = { + expandedSessions: new Set(), + loadingSessions: new Set(), + sessionTraces: {}, +}; + +export const createSessionsStore = () => { + const sessionControllers = new Map(); + + return createStore()((set, get) => ({ + ...DEFAULT_STATE, + + expandSession: (sessionId) => + set((state) => { + const next = new Set(state.expandedSessions); + next.add(sessionId); + return { expandedSessions: next }; + }), + + collapseSession: (sessionId) => { + sessionControllers.get(sessionId)?.abort(); + sessionControllers.delete(sessionId); + set((state) => { + const nextExpanded = new Set(state.expandedSessions); + nextExpanded.delete(sessionId); + const nextLoading = new Set(state.loadingSessions); + nextLoading.delete(sessionId); + return { expandedSessions: nextExpanded, loadingSessions: nextLoading }; + }); + }, + + toggleSession: (sessionId) => { + const isExpanded = get().expandedSessions.has(sessionId); + if (isExpanded) { + get().collapseSession(sessionId); + return { action: "collapsed" }; + } + const controller = get().getController(sessionId); + get().expandSession(sessionId); + get().setLoadingSession(sessionId, true); + return { action: "expanded", controller }; + }, + + setLoadingSession: (sessionId, loading) => + set((state) => { + const next = new Set(state.loadingSessions); + if (loading) { + next.add(sessionId); + } else { + next.delete(sessionId); + } + return { loadingSessions: next }; + }), + + setSessionTraces: (sessionId, traces) => + set((state) => ({ + sessionTraces: { ...state.sessionTraces, [sessionId]: traces }, + })), + + resetExpandState: () => { + for (const c of sessionControllers.values()) c.abort(); + sessionControllers.clear(); + set({ + expandedSessions: new Set(), + loadingSessions: new Set(), + sessionTraces: {}, + }); + }, + + getController: (sessionId) => { + sessionControllers.get(sessionId)?.abort(); + const controller = new AbortController(); + sessionControllers.set(sessionId, controller); + return controller; + }, + })); +}; + +export const SessionsContext = createContext(null); + +export const useSessionsStoreContext = ( + selector: (state: SessionsStore) => T, + equalityFn?: (a: T, b: T) => boolean +): T => { + const store = useContext(SessionsContext); + if (!store) throw new Error("Missing SessionsContext.Provider in the tree"); + return useStoreWithEqualityFn(store, selector, equalityFn); +}; + +export const SessionsStoreProvider = ({ children }: PropsWithChildren) => { + const [storeState] = useState(() => createSessionsStore()); + + return {children}; +}; diff --git a/frontend/components/traces/sessions-table/sessions-virtual-list.tsx b/frontend/components/traces/sessions-table/sessions-virtual-list.tsx new file mode 100644 index 000000000..6ded09d49 --- /dev/null +++ b/frontend/components/traces/sessions-table/sessions-virtual-list.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { defaultRangeExtractor, type Range, useVirtualizer } from "@tanstack/react-virtual"; +import { motion } from "framer-motion"; +import { Loader2 } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import { type SessionRow as SessionRowType, type TraceRow } from "@/lib/traces/types"; +import { cn } from "@/lib/utils"; + +import SessionRowComponent from "./session-row"; +import SessionTableHeader, { type SessionSortColumn, type SortDirection } from "./session-table-header"; +import SessionTraceCard from "./session-trace-card"; +import TraceSectionHeader from "./trace-section-header"; +import { type TraceIOEntry, useBatchedTraceIO } from "./use-batched-trace-io"; + +const SESSION_HEADER_HEIGHT = 36; + +const itemTransition = { type: "spring", stiffness: 400, damping: 50 } as const; + +type VirtualListItem = + | { type: "session-row"; session: SessionRowType; isLast: boolean } + | { type: "trace-section-header"; sessionId: string } + | { type: "trace-card"; trace: TraceRow; sessionId: string; isFirst: boolean; isLast: boolean } + | { type: "trace-loading"; sessionId: string; isLast: boolean } + | { type: "trace-empty"; sessionId: string; isLast: boolean }; + +interface SessionsVirtualListProps { + sessions: SessionRowType[]; + expandedSessions: Set; + loadingSessions: Set; + sessionTraces: Record; + onToggleSession: (sessionId: string) => void; + onTraceClick: (traceId: string) => void; + hasMore: boolean; + isFetching: boolean; + isLoading: boolean; + fetchNextPage: () => void; + error?: Error | null; + onRetry?: () => void; + sortColumn?: SessionSortColumn; + sortDirection?: SortDirection; + onSort: (column: SessionSortColumn, direction: SortDirection) => void; + onClearSort: () => void; +} + +function estimateSize(item: VirtualListItem): number { + if (item.type === "session-row") return 42; + if (item.type === "trace-section-header") return 52; + if (item.type === "trace-loading" || item.type === "trace-empty") return 60; + // trace-card: 140px card + padding (8px top for first, 24px bottom for last, 8px otherwise) + if (item.type === "trace-card") { + const topPad = item.isFirst ? 8 : 0; + const bottomPad = item.isLast ? 24 : 8; + return 140 + topPad + bottomPad; + } + return 36; +} + +export default function SessionsVirtualList({ + sessions, + expandedSessions, + loadingSessions, + sessionTraces, + onToggleSession, + onTraceClick, + hasMore, + isFetching, + isLoading, + fetchNextPage, + error, + onRetry, + sortColumn, + sortDirection, + onSort, + onClearSort, +}: SessionsVirtualListProps) { + const { projectId } = useParams<{ projectId: string }>(); + const scrollContainerRef = useRef(null); + const sentinelRef = useRef(null); + + const flatList = useMemo(() => { + const items: VirtualListItem[] = []; + for (let i = 0; i < sessions.length; i++) { + const session = sessions[i]; + const isLastSession = i === sessions.length - 1; + const isExpanded = expandedSessions.has(session.sessionId); + + items.push({ + type: "session-row", + session, + isLast: isLastSession && !isExpanded, + }); + + if (isExpanded) { + const isLoading = loadingSessions.has(session.sessionId); + const traces = sessionTraces[session.sessionId] ?? []; + + if (isLoading) { + items.push({ type: "trace-loading", sessionId: session.sessionId, isLast: isLastSession }); + } else if (traces.length === 0) { + items.push({ type: "trace-empty", sessionId: session.sessionId, isLast: isLastSession }); + } else { + items.push({ type: "trace-section-header", sessionId: session.sessionId }); + traces.forEach((trace, idx) => { + items.push({ + type: "trace-card", + trace, + sessionId: session.sessionId, + isFirst: idx === 0, + isLast: idx === traces.length - 1, + }); + }); + } + } + } + return items; + }, [sessions, expandedSessions, loadingSessions, sessionTraces]); + + const getItemKey = useCallback( + (index: number) => { + const item = flatList[index]; + switch (item.type) { + case "session-row": + return `session-${item.session.sessionId}`; + case "trace-section-header": + return `header-${item.sessionId}`; + case "trace-card": + return `card-${item.trace.id}`; + case "trace-loading": + return `loading-${item.sessionId}`; + case "trace-empty": + return `empty-${item.sessionId}`; + } + }, + [flatList] + ); + + const stickyIndexes = useMemo( + () => + flatList.reduce((acc, item, index) => { + if (item.type === "session-row" && expandedSessions.has(item.session.sessionId)) { + acc.push(index); + } + return acc; + }, []), + [flatList, expandedSessions] + ); + + const activeStickyIndexRef = useRef(null); + + const isActiveSticky = useCallback((index: number) => activeStickyIndexRef.current === index, []); + + const rangeExtractor = useCallback( + (range: Range) => { + if (stickyIndexes.length === 0) return defaultRangeExtractor(range); + + activeStickyIndexRef.current = [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? null; + + const next = new Set([ + ...(activeStickyIndexRef.current !== null ? [activeStickyIndexRef.current] : []), + ...defaultRangeExtractor(range), + ]); + + return [...next].sort((a, b) => a - b); + }, + [stickyIndexes] + ); + + const virtualizer = useVirtualizer({ + count: flatList.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index) => estimateSize(flatList[index]), + getItemKey, + overscan: 20, + rangeExtractor, + }); + + const virtualItems = virtualizer.getVirtualItems(); + const rangeStart = virtualItems[0]?.index ?? 0; + const rangeEnd = virtualItems[virtualItems.length - 1]?.index ?? 0; + + const visibleTraceIds = useMemo(() => { + const ids: string[] = []; + for (let i = rangeStart; i <= rangeEnd; i++) { + const item = flatList[i]; + if (item?.type === "trace-card" && item.trace.totalTokens > 0) { + ids.push(item.trace.id); + } + } + return ids; + }, [rangeStart, rangeEnd, flatList]); + + const { previews: traceIOPreviews } = useBatchedTraceIO(projectId, visibleTraceIds); + + // IntersectionObserver for infinite scroll + useEffect(() => { + const sentinel = sentinelRef.current; + const scrollContainer = scrollContainerRef.current; + if (!sentinel || !scrollContainer) return; + if (!hasMore || isFetching || isLoading) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !isFetching) { + fetchNextPage(); + } + }, + { root: scrollContainer, rootMargin: "420px", threshold: 0 } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [fetchNextPage, hasMore, isFetching, isLoading]); + + const renderItem = useCallback( + (item: VirtualListItem) => { + switch (item.type) { + case "session-row": + return ( + onToggleSession(item.session.sessionId)} + /> + ); + case "trace-section-header": + return ( + + + + ); + case "trace-card": { + const io: TraceIOEntry | null | undefined = traceIOPreviews[item.trace.id]; + const ioLoading = item.trace.totalTokens > 0 && io === undefined; + return ( + + onTraceClick(item.trace.id)} + traceIO={io ?? undefined} + isIOLoading={ioLoading} + /> + + ); + } + case "trace-loading": + return ( + +
+ + Loading traces... +
+
+ ); + case "trace-empty": + return ( + +
+ No traces in this session +
+
+ ); + } + }, + [expandedSessions, onToggleSession, onTraceClick, traceIOPreviews] + ); + + const showList = !isLoading && sessions.length > 0; + + return ( +
+
+ + {isLoading && ( +
+ + Loading sessions... +
+ )} + {!isLoading && error && sessions.length === 0 && ( +
+ Failed to load sessions + {error.message} + {onRetry && ( + + )} +
+ )} + {!isLoading && !error && sessions.length === 0 && ( +
+ No sessions found +
+ )} + {showList && ( + <> +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = flatList[virtualItem.index]; + const activeSticky = isActiveSticky(virtualItem.index); + return ( +
+ {renderItem(item)} +
+ ); + })} +
+
+ {isFetching && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/trace-section-header.tsx b/frontend/components/traces/sessions-table/trace-section-header.tsx new file mode 100644 index 000000000..be8949f33 --- /dev/null +++ b/frontend/components/traces/sessions-table/trace-section-header.tsx @@ -0,0 +1,15 @@ +export default function TraceSectionHeader() { + return ( +
+
+ Details +
+
+ Input +
+
+ Output +
+
+ ); +} diff --git a/frontend/components/traces/sessions-table/use-batched-trace-io.ts b/frontend/components/traces/sessions-table/use-batched-trace-io.ts new file mode 100644 index 000000000..4e7b8b2eb --- /dev/null +++ b/frontend/components/traces/sessions-table/use-batched-trace-io.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useToast } from "@/lib/hooks/use-toast"; +import { SimpleLRU } from "@/lib/simple-lru"; + +export type TraceIOEntry = { input: string | null; output: string | null }; + +interface UseBatchedTraceIOOptions { + debounceMs?: number; + maxEntries?: number; +} + +export function useBatchedTraceIO( + projectId: string | undefined, + visibleTraceIds: string[], + options: UseBatchedTraceIOOptions = {} +) { + const { debounceMs = 200, maxEntries = 200 } = options; + const { toast } = useToast(); + const cache = useRef(new SimpleLRU(maxEntries)); + const fetching = useRef(new Set()); + const pendingFetch = useRef(new Set()); + const timer = useRef(null); + const lastIdsRef = useRef(""); + const [previews, setPreviews] = useState>({}); + + const fetchBatch = useCallback( + async (traceIds: string[]) => { + if (traceIds.length === 0 || !projectId) return; + + try { + const response = await fetch(`/api/projects/${projectId}/traces/io`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ traceIds }), + }); + + if (!response.ok) { + const errData = await response + .json() + .then((d: { error?: string }) => d?.error) + .catch(() => null); + throw new Error(errData ?? "Failed to fetch trace previews"); + } + + const data = (await response.json()) as Record; + + for (const id of traceIds) { + cache.current.set(id, data[id] ?? null); + fetching.current.delete(id); + } + + setPreviews((prev) => { + const next = { ...prev }; + for (const id of traceIds) { + next[id] = cache.current.get(id) ?? null; + } + return next; + }); + } catch (error) { + toast({ + variant: "destructive", + title: error instanceof Error ? error.message : "Failed to fetch trace previews", + }); + + for (const id of traceIds) { + fetching.current.delete(id); + } + } + }, + [projectId, toast] + ); + + const scheduleFetch = useCallback(async () => { + if (pendingFetch.current.size === 0) return; + + const toFetch = Array.from(pendingFetch.current); + pendingFetch.current.clear(); + + for (const id of toFetch) fetching.current.add(id); + + // Server enforces max 100 IDs per request + for (let i = 0; i < toFetch.length; i += 100) { + await fetchBatch(toFetch.slice(i, i + 100)); + } + }, [fetchBatch]); + + useEffect(() => { + const currentIdsKey = visibleTraceIds.join(","); + + if (currentIdsKey === lastIdsRef.current) return; + lastIdsRef.current = currentIdsKey; + + // All sessions collapsed / reset — drop cached state so we don't hold stale memory + if (visibleTraceIds.length === 0) { + cache.current.clear(); + fetching.current.clear(); + pendingFetch.current.clear(); + if (timer.current) clearTimeout(timer.current); + setPreviews({}); + return; + } + + const newIds = visibleTraceIds.filter( + (id) => !cache.current.has(id) && !fetching.current.has(id) && !pendingFetch.current.has(id) + ); + + if (newIds.length > 0) { + for (const id of newIds) pendingFetch.current.add(id); + + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(scheduleFetch, debounceMs); + } + }, [visibleTraceIds, scheduleFetch, debounceMs]); + + return { previews }; +} diff --git a/frontend/components/traces/trace-view/header/resizeable-signal-card.tsx b/frontend/components/traces/trace-view/header/resizeable-signal-card.tsx index c78a17f4b..1085aba35 100644 --- a/frontend/components/traces/trace-view/header/resizeable-signal-card.tsx +++ b/frontend/components/traces/trace-view/header/resizeable-signal-card.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useCallback,useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; diff --git a/frontend/components/traces/trace-view/header/trace-dropdown.tsx b/frontend/components/traces/trace-view/header/trace-dropdown.tsx index f947f4ca7..b626437b6 100644 --- a/frontend/components/traces/trace-view/header/trace-dropdown.tsx +++ b/frontend/components/traces/trace-view/header/trace-dropdown.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Copy, Database, Loader } from "lucide-react"; +import { ChevronDown, Copy, Database, Layers, Loader } from "lucide-react"; import { useParams } from "next/navigation"; import { useCallback } from "react"; @@ -34,6 +34,22 @@ export default function TraceDropdown({ traceId }: TraceDropdownProps) { } }, [trace?.id, toast]); + const sessionId = trace?.sessionId; + const hasSession = sessionId && sessionId !== "" && sessionId !== ""; + + const handleOpenSession = useCallback(() => { + if (!hasSession || !trace) return; + const filter = JSON.stringify({ column: "session_id", value: sessionId, operator: "eq" }); + const startDate = new Date(new Date(trace.startTime).getTime() - 3600_000).toISOString(); + const endDate = new Date(new Date(trace.endTime).getTime() + 3600_000).toISOString(); + const params = new URLSearchParams(); + params.set("view", "sessions"); + params.set("filter", filter); + params.set("startDate", startDate); + params.set("endDate", endDate); + window.open(`/project/${projectId}/traces?${params.toString()}`, "_blank"); + }, [hasSession, trace, sessionId, projectId]); + return ( @@ -50,6 +66,12 @@ export default function TraceDropdown({ traceId }: TraceDropdownProps) { {isSqlLoading ? : } Open in SQL editor + {hasSession && ( + + + Open session + + )} ); diff --git a/frontend/components/traces/trace-view/store/base.ts b/frontend/components/traces/trace-view/store/base.ts index 53ea43b31..6246b9f1a 100644 --- a/frontend/components/traces/trace-view/store/base.ts +++ b/frontend/components/traces/trace-view/store/base.ts @@ -92,6 +92,7 @@ export type TraceViewTrace = { traceType: string; visibility: "public" | "private"; hasBrowserSession: boolean; + sessionId?: string; }; export type TraceSignal = { diff --git a/frontend/components/ui/copy-tooltip.tsx b/frontend/components/ui/copy-tooltip.tsx index e15ba9acb..c5917a6e1 100644 --- a/frontend/components/ui/copy-tooltip.tsx +++ b/frontend/components/ui/copy-tooltip.tsx @@ -1,5 +1,6 @@ "use client"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; import { type PropsWithChildren, useCallback, useEffect, useRef, useState } from "react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -71,9 +72,11 @@ export default function CopyTooltip({ {children} - -

{copied ? copiedText : text}

-
+ + +

{copied ? copiedText : text}

+
+
); diff --git a/frontend/lib/actions/common/types.ts b/frontend/lib/actions/common/types.ts index 26aee61d1..5720b42b1 100644 --- a/frontend/lib/actions/common/types.ts +++ b/frontend/lib/actions/common/types.ts @@ -4,6 +4,18 @@ import { FilterSchema } from "./filters"; export { FilterSchema }; +const hasEmptyFilterValue = (value: unknown): boolean => { + if (typeof value === "string") { + return value.trim() === ""; + } + + if (Array.isArray(value)) { + return value.length === 0 || value.every((item) => typeof item === "string" && item.trim() === ""); + } + + return false; +}; + export const FiltersSchema = z.object({ filter: z .array(z.string()) @@ -13,6 +25,9 @@ export const FiltersSchema = z.object({ .map((filter) => { try { const parsed = JSON.parse(filter); + if (hasEmptyFilterValue((parsed as { value?: unknown })?.value)) { + return undefined; + } return FilterSchema.parse(parsed); } catch (error) { ctx.issues.push({ diff --git a/frontend/lib/actions/sessions/extract-input.ts b/frontend/lib/actions/sessions/extract-input.ts new file mode 100644 index 000000000..db07b8e81 --- /dev/null +++ b/frontend/lib/actions/sessions/extract-input.ts @@ -0,0 +1,212 @@ +import { observe } from "@lmnr-ai/lmnr"; + +import { cache } from "@/lib/cache"; + +import { type ParsedInput, type TextPart } from "./parse-input"; +import { applyRegex, generateExtractionRegex } from "./prompts"; + +const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; +const REGEX_CACHE_PREFIX = "trace_input_regex:"; +const BATCH_SIZE = 10; + +const SCAFFOLDING_PATTERN = /^\s*<([a-z][a-z0-9_-]*)[\s>][\s\S]*<\/\1>\s*$/i; + +function looksLikeScaffolding(text: string): boolean { + return SCAFFOLDING_PATTERN.test(text); +} + +export function joinUserParts(parts: TextPart[]): string | null { + if (parts.length === 0) return null; + const text = parts + .map((p) => p.text) + .join("\n") + .trim(); + return text.length > 0 ? text : null; +} + +interface TraceForExtraction { + traceId: string; + output: string | null; + parsed: ParsedInput | null; +} + +/** + * For a group of traces sharing the same system prompt, generate or + * retrieve a cached regex, then apply it to extract each trace's input. + */ +export async function extractInputsForGroup( + systemHash: string, + projectId: string, + traces: TraceForExtraction[], + results: Record +): Promise { + const cacheKey = `${REGEX_CACHE_PREFIX}${projectId}:${systemHash}`; + + try { + const cachedRegex = await cache.get(cacheKey); + if (cachedRegex) { + let allMatched = true; + for (const trace of traces) { + const joinedText = joinUserParts(trace.parsed?.userParts ?? []); + if (!joinedText) { + results[trace.traceId] = { input: null, output: trace.output }; + continue; + } + const extracted = applyRegex(cachedRegex, joinedText); + if (extracted) { + results[trace.traceId] = { input: extracted, output: trace.output }; + } else { + allMatched = false; + break; + } + } + if (allMatched) { + await cache.expire(cacheKey, SEVEN_DAYS_SECONDS).catch(() => {}); + return; + } + await cache.remove(cacheKey).catch(() => {}); + } + } catch { + // Redis unavailable + } + + const samples = traces.slice(0, BATCH_SIZE).filter((t) => t.parsed && t.parsed.userParts.length > 0); + if (samples.length === 0) { + for (const trace of traces) { + results[trace.traceId] = { input: joinUserParts(trace.parsed?.userParts ?? []), output: trace.output }; + } + return; + } + + await observe({ name: "extract_trace_inputs" }, async () => { + const llmInput = buildDeduplicatedLLMInput(samples.map((s) => s.parsed!.userParts)); + const regex = await generateExtractionRegex(llmInput); + + if (!regex) { + for (const trace of traces) { + if (!(trace.traceId in results)) { + results[trace.traceId] = { input: joinUserParts(trace.parsed?.userParts ?? []), output: trace.output }; + } + } + return; + } + + let anyMatch = false; + for (const trace of traces) { + const joinedText = joinUserParts(trace.parsed?.userParts ?? []); + if (!joinedText) { + results[trace.traceId] = { input: null, output: trace.output }; + continue; + } + const extracted = observe( + { name: "apply_trace_input_extraction_regex", input: { pattern: regex, text: joinedText } }, + () => applyRegex(regex, joinedText) + ); + if (extracted) { + results[trace.traceId] = { input: extracted, output: trace.output }; + anyMatch = true; + } else { + results[trace.traceId] = { input: joinedText, output: trace.output }; + } + } + + if (anyMatch) { + await cache.set(cacheKey, regex, { expireAfterSeconds: SEVEN_DAYS_SECONDS }).catch(() => {}); + } + }); +} + +/** + * Build the LLM prompt from multiple traces' user message parts, + * deduplicating parts that are identical across samples by index + * and detecting scaffolding structurally for single-sample cases. + */ +function buildDeduplicatedLLMInput(allParts: TextPart[][]): string { + if (allParts.length === 1) { + return buildSingleSampleInput(allParts[0]); + } + + return buildMultiSampleInput(allParts); +} + +function buildSingleSampleInput(parts: TextPart[]): string { + if (parts.length === 1) return parts[0].text; + + const scaffoldingIndices = new Set(); + for (let i = 0; i < parts.length; i++) { + if (looksLikeScaffolding(parts[i].text)) { + scaffoldingIndices.add(i); + } + } + + if (scaffoldingIndices.size === 0) { + return parts.map((p, i) => `[Part ${i + 1}]\n${p.text}`).join("\n\n"); + } + + if (scaffoldingIndices.size === parts.length) { + return parts.map((p, i) => `[Part ${i + 1}]\n${p.text}`).join("\n\n"); + } + + const sections: string[] = []; + + sections.push("== SCAFFOLDING PARTS (system-injected context, skip these) =="); + for (const idx of scaffoldingIndices) { + sections.push(`[Part ${idx + 1} — scaffolding]\n${parts[idx].text}`); + } + + sections.push("== USER REQUEST (the actual user input to capture) =="); + for (let i = 0; i < parts.length; i++) { + if (!scaffoldingIndices.has(i)) { + sections.push(`[Part ${i + 1}]\n${parts[i].text}`); + } + } + + return sections.join("\n\n"); +} + +function buildMultiSampleInput(allParts: TextPart[][]): string { + const maxParts = Math.max(...allParts.map((p) => p.length)); + const sections: string[] = []; + + const sharedAtIndex = new Map(); + const scaffoldingAtIndex = new Set(); + + for (let idx = 0; idx < maxParts; idx++) { + const textsAtIdx = allParts.filter((parts) => idx < parts.length).map((parts) => parts[idx].text); + + if (textsAtIdx.length === allParts.length && textsAtIdx.every((t) => t === textsAtIdx[0])) { + sharedAtIndex.set(idx, textsAtIdx[0]); + } else if (textsAtIdx.every((t) => looksLikeScaffolding(t))) { + scaffoldingAtIndex.add(idx); + } + } + + if (sharedAtIndex.size > 0 || scaffoldingAtIndex.size > 0) { + sections.push("== SHARED / SCAFFOLDING PARTS (system-injected context, skip these) =="); + for (const [idx, text] of sharedAtIndex) { + sections.push(`[Part ${idx + 1} — shared]\n${text}`); + } + for (const idx of scaffoldingAtIndex) { + const sample = allParts.find((parts) => idx < parts.length); + if (sample) { + sections.push(`[Part ${idx + 1} — scaffolding (varies per trace but is system context)]\n${sample[idx].text}`); + } + } + } + + const skipIndices = new Set([...sharedAtIndex.keys(), ...scaffoldingAtIndex]); + + for (let sampleIdx = 0; sampleIdx < allParts.length; sampleIdx++) { + const parts = allParts[sampleIdx]; + const uniqueParts = parts.map((p, idx) => ({ part: p, idx })).filter(({ idx }) => !skipIndices.has(idx)); + + if (uniqueParts.length === 0) continue; + + sections.push(`== SAMPLE ${sampleIdx + 1} (unique parts — this is the actual user request) ==`); + for (const { part, idx } of uniqueParts) { + sections.push(`[Part ${idx + 1}]\n${part.text}`); + } + } + + return sections.join("\n\n"); +} diff --git a/frontend/lib/actions/sessions/index.ts b/frontend/lib/actions/sessions/index.ts index d8ca031b5..468819d44 100644 --- a/frontend/lib/actions/sessions/index.ts +++ b/frontend/lib/actions/sessions/index.ts @@ -16,6 +16,8 @@ export const GetSessionsSchema = PaginationFiltersSchema.extend({ projectId: z.guid(), search: z.string().nullable().optional(), searchIn: z.array(z.string()).default([]), + sortColumn: z.enum(["start_time", "duration", "total_tokens", "total_cost", "trace_count"]).nullable().optional(), + sortDirection: z.enum(["ASC", "DESC"]).nullable().optional(), }); export const DeleteSessionsSchema = z.object({ @@ -34,6 +36,8 @@ export async function getSessions(input: z.infer): Pro search, searchIn, filter: inputFilters, + sortColumn, + sortDirection, } = input; const filters: Filter[] = compact(inputFilters); @@ -62,6 +66,8 @@ export async function getSessions(input: z.infer): Pro startTime, endTime, pastHours, + sortColumn: sortColumn ?? undefined, + sortDirection: sortDirection ?? undefined, }); const items = await executeQuery>({ @@ -70,9 +76,9 @@ export async function getSessions(input: z.infer): Pro projectId, }); - return { - items: items.map((item) => ({ ...item, subRows: [] })), - }; + const sessionItems = items.map((item) => ({ ...item, subRows: [] })); + + return { items: sessionItems }; } const searchTraceIds = async ({ diff --git a/frontend/lib/actions/sessions/parse-input.ts b/frontend/lib/actions/sessions/parse-input.ts new file mode 100644 index 000000000..3d81db957 --- /dev/null +++ b/frontend/lib/actions/sessions/parse-input.ts @@ -0,0 +1,135 @@ +import { type z } from "zod/v4"; + +import { tryParseJson } from "@/lib/actions/common/utils"; +import { type AnthropicContentBlockSchema, AnthropicMessagesSchema } from "@/lib/spans/types/anthropic"; +import { GeminiContentsSchema, type GeminiTextPartSchema } from "@/lib/spans/types/gemini"; +import { OpenAIMessagesSchema, type OpenAITextPartSchema } from "@/lib/spans/types/openai"; + +export type TextPart = { text: string }; + +export interface ParsedInput { + systemText: string | null; + userParts: TextPart[]; +} + +/** + * Build a synthetic messages array from the first and last elements + * extracted by ClickHouse, then parse into typed system + user parts. + */ +export function parseExtractedMessages(firstMessage: string, lastMessage: string): ParsedInput | null { + const parts: string[] = []; + if (firstMessage) parts.push(firstMessage); + if (lastMessage) parts.push(lastMessage); + if (parts.length === 0) return null; + + const syntheticJson = `[${parts.join(",")}]`; + return parseMessagesArray(syntheticJson); +} + +/** + * Try OpenAI, Anthropic, and Gemini schemas in order. + */ +function parseMessagesArray(json: string): ParsedInput | null { + const parsed = tryParseJson(json); + if (!parsed) return null; + + const arr = Array.isArray(parsed) ? parsed : [parsed]; + + const openai = OpenAIMessagesSchema.safeParse(arr); + if (openai.success) return extractFromOpenAI(openai.data); + + const anthropic = AnthropicMessagesSchema.safeParse(arr); + if (anthropic.success) return extractFromAnthropic(anthropic.data); + + const gemini = GeminiContentsSchema.safeParse(arr); + if (gemini.success) return extractFromGemini(gemini.data); + + return null; +} + +function extractFromOpenAI(messages: z.infer): ParsedInput { + let systemText: string | null = null; + const systemMsg = messages.find((m) => m.role === "system"); + if (systemMsg) { + if (typeof systemMsg.content === "string") { + systemText = systemMsg.content; + } else { + const textParts = systemMsg.content + .filter((p): p is z.infer => p.type === "text") + .map((p) => p.text); + if (textParts.length > 0) systemText = textParts.join("\n"); + } + } + + return { systemText, userParts: extractLastUserMessageOpenAI(messages) }; +} + +function extractLastUserMessageOpenAI(messages: z.infer): TextPart[] { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "user") continue; + + if (typeof msg.content === "string") return [{ text: msg.content }]; + + return msg.content + .filter((p): p is z.infer => p.type === "text") + .map((p) => ({ text: p.text })); + } + return []; +} + +function extractFromAnthropic(messages: z.infer): ParsedInput { + let systemText: string | null = null; + const systemMsg = messages.find((m) => m.role === "system"); + if (systemMsg) { + if (typeof systemMsg.content === "string") { + systemText = systemMsg.content; + } else { + const textBlocks = (systemMsg.content as z.infer[]).filter( + (b): b is { type: "text"; text: string } => b.type === "text" + ); + if (textBlocks.length > 0) systemText = textBlocks.map((b) => b.text).join("\n"); + } + } + + return { systemText, userParts: extractLastUserMessageAnthropic(messages) }; +} + +function extractLastUserMessageAnthropic(messages: z.infer): TextPart[] { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "user") continue; + + if (typeof msg.content === "string") return [{ text: msg.content }]; + + return (msg.content as z.infer[]) + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => ({ text: b.text })); + } + return []; +} + +function extractFromGemini(contents: z.infer): ParsedInput { + let systemText: string | null = null; + const systemContent = contents.find((c) => c.role === "system"); + if (systemContent) { + const textParts = systemContent.parts + .filter((p): p is z.infer => "text" in p) + .map((p) => p.text); + if (textParts.length > 0) systemText = textParts.join("\n"); + } + + return { systemText, userParts: extractLastUserMessageGemini(contents) }; +} + +function extractLastUserMessageGemini(contents: z.infer): TextPart[] { + for (let i = contents.length - 1; i >= 0; i--) { + const content = contents[i]; + if (content.role !== "user" && (content.role || i === 0)) continue; + + return content.parts + .filter((p): p is z.infer => "text" in p) + .map((p) => ({ text: p.text })); + } + return []; +} diff --git a/frontend/lib/actions/sessions/prompts.ts b/frontend/lib/actions/sessions/prompts.ts new file mode 100644 index 000000000..446921b2a --- /dev/null +++ b/frontend/lib/actions/sessions/prompts.ts @@ -0,0 +1,79 @@ +import { getTracer } from "@lmnr-ai/lmnr"; +import { generateText } from "ai"; + +import { getLanguageModel } from "@/lib/ai/model"; + +const SYSTEM_PROMPT = `You write re2 regexes to extract the user's actual request from AI agent conversation messages. + +The text contains scaffolding blocks wrapped in XML tags mixed with the actual user request. + +YOUR TASK: find where the user's actual request is and write a regex that captures ONLY that text. + +There are two common patterns — pick the one that matches: + +PATTERN A – The user request is INSIDE a dedicated tag (e.g. , , ). +Use this when you see a tag whose content is clearly the user's own words, surrounded by system/scaffolding tags. +Template: (?s)\\s*(.*?)\\s* +Examples: +- (?s)\\s*(.*?)\\s* +- (?s)\\s*(.*?)\\s* + +PATTERN B – The user request is AFTER all scaffolding tags (the plain text at the end). +Use this when scaffolding tags wrap system context and the user request follows the last closing tag. +Template: (?s).*\\s*(.*) +Examples: +- (?s).*\\s*(.*) +- (?s).*\\s*(.*) + +RULES: +- Exactly one capture group that gets the user's actual request. +- re2 only: no lookaheads, lookbehinds, backreferences. +- Always prefix with (?s) so . matches newlines. +- If there is no scaffolding (no XML tag blocks), return: (?s)(.*) +- Return ONLY the regex, nothing else.`; + +export async function generateExtractionRegex(userMessage: string): Promise { + try { + const { text } = await generateText({ + model: getLanguageModel("lite"), + system: SYSTEM_PROMPT, + prompt: userMessage, + maxRetries: 0, + temperature: 0, + abortSignal: AbortSignal.timeout(5000), + experimental_telemetry: { + isEnabled: true, + tracer: getTracer(), + }, + }); + + const pattern = text + .trim() + .replace(/^[`"']+|[`"']+$/g, "") + .trim(); + + return pattern || null; + } catch { + return null; + } +} + +export function applyRegex(pattern: string, text: string): string | null { + try { + let flags = ""; + let cleanPattern = pattern; + if (cleanPattern.startsWith("(?s)")) { + flags = "s"; + cleanPattern = cleanPattern.slice(4); + } + const regex = new RegExp(cleanPattern, flags); + const match = regex.exec(text); + if (match && match[1] != null) { + const extracted = match[1].trim(); + if (extracted.length > 0) return extracted; + } + } catch { + // Invalid regex pattern + } + return null; +} diff --git a/frontend/lib/actions/sessions/trace-io.ts b/frontend/lib/actions/sessions/trace-io.ts new file mode 100644 index 000000000..9c2f264f6 --- /dev/null +++ b/frontend/lib/actions/sessions/trace-io.ts @@ -0,0 +1,196 @@ +import { z } from "zod/v4"; + +import { processSpanPreviews } from "@/lib/actions/spans/previews"; +import { executeQuery } from "@/lib/actions/sql"; +import { fetcherJSON } from "@/lib/utils"; + +import { extractInputsForGroup, joinUserParts } from "./extract-input"; +import { type ParsedInput, parseExtractedMessages } from "./parse-input"; + +const bodySchema = z.object({ + traceIds: z.array(z.guid()).min(1).max(100), +}); + +const TOP_PATH_QUERY = ` + SELECT path + FROM ( + SELECT path, total_tokens, start_time + FROM spans + WHERE trace_id = {traceId: UUID} + AND span_type = 'LLM' + ORDER BY start_time ASC + LIMIT 20 + ) + GROUP BY path + ORDER BY min(start_time) ASC, sum(total_tokens) DESC + LIMIT 1 +`; + +const INPUT_QUERY = ` + SELECT + arr[1] AS first_message, + if(length(arr) > 1, arr[length(arr)], '') AS last_message + FROM ( + SELECT JSONExtractArrayRaw(input) AS arr + FROM spans + WHERE trace_id = {traceId: UUID} + AND span_type = 'LLM' + AND path = {path: String} + ORDER BY start_time ASC + LIMIT 1 + ) +`; + +const OUTPUT_QUERY = ` + SELECT span_id AS spanId, output AS data, name + FROM spans + WHERE trace_id = {traceId: UUID} + AND span_type = 'LLM' + AND path = {path: String} + ORDER BY start_time DESC + LIMIT 1 +`; + +interface InputQueryRow { + first_message: string; + last_message: string; +} + +interface TraceIOResult { + input: string | null; + output: string | null; +} + +interface TraceWithParsedInput { + traceId: string; + output: string | null; + parsed: ParsedInput | null; +} + +export async function getMainAgentIOBatch({ + traceIds, + projectId, +}: { + traceIds: string[]; + projectId: string; +}): Promise> { + const parsed = bodySchema.parse({ traceIds }); + + const traceData = await Promise.all(parsed.traceIds.map((traceId) => fetchTraceData(traceId, projectId))); + + const textsToHash: string[] = []; + const traceIndexByTextIndex: number[] = []; + for (let i = 0; i < traceData.length; i++) { + const systemText = traceData[i].parsed?.systemText; + if (systemText) { + traceIndexByTextIndex.push(i); + textsToHash.push(systemText); + } + } + + let hashes: string[] = []; + if (textsToHash.length > 0) { + hashes = await fetchSkeletonHashes(textsToHash, projectId); + } + + const bySystemHash = new Map(); + const noSystemTraces: TraceWithParsedInput[] = []; + + const traceHashMap = new Map(); + for (let j = 0; j < traceIndexByTextIndex.length; j++) { + if (hashes[j]) { + traceHashMap.set(traceIndexByTextIndex[j], hashes[j]); + } + } + + for (let i = 0; i < traceData.length; i++) { + const trace = traceData[i]; + const hash = traceHashMap.get(i); + if (!hash) { + noSystemTraces.push(trace); + continue; + } + const group = bySystemHash.get(hash) ?? []; + group.push(trace); + bySystemHash.set(hash, group); + } + + const results: Record = {}; + + for (const trace of noSystemTraces) { + results[trace.traceId] = { + input: joinUserParts(trace.parsed?.userParts ?? []), + output: trace.output, + }; + } + + await Promise.all( + Array.from(bySystemHash.entries()).map(([hash, traces]) => extractInputsForGroup(hash, projectId, traces, results)) + ); + + for (const traceId of traceIds) { + if (!(traceId in results)) { + results[traceId] = { input: null, output: null }; + } + } + + return results; +} + +async function fetchSkeletonHashes(texts: string[], projectId: string): Promise { + try { + return await fetcherJSON(`/projects/${projectId}/skeleton-hashes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ texts }), + }); + } catch { + return []; + } +} + +async function fetchTraceData(traceId: string, projectId: string): Promise { + const pathRows = await executeQuery<{ path: string }>({ + query: TOP_PATH_QUERY, + parameters: { traceId }, + projectId, + }); + + if (pathRows.length === 0) { + return { traceId, output: null, parsed: null }; + } + + const topPath = pathRows[0].path; + + const [inputRows, outputRows] = await Promise.all([ + executeQuery({ + query: INPUT_QUERY, + parameters: { traceId, path: topPath }, + projectId, + }), + executeQuery<{ spanId: string; data: string; name: string }>({ + query: OUTPUT_QUERY, + parameters: { traceId, path: topPath }, + projectId, + }), + ]); + + const outputText = await resolveOutput(outputRows, projectId); + + if (inputRows.length === 0) { + return { traceId, output: outputText, parsed: null }; + } + + const parsed = parseExtractedMessages(inputRows[0].first_message, inputRows[0].last_message); + return { traceId, output: outputText, parsed }; +} + +async function resolveOutput( + rows: { spanId: string; data: string; name: string }[], + projectId: string +): Promise { + if (rows.length === 0) return null; + const { spanId } = rows[0]; + const previews = await processSpanPreviews(rows, projectId, [spanId], { [spanId]: "LLM" }); + return previews[spanId] || null; +} diff --git a/frontend/lib/actions/sessions/utils.ts b/frontend/lib/actions/sessions/utils.ts index 810f539cf..5f37ba01a 100644 --- a/frontend/lib/actions/sessions/utils.ts +++ b/frontend/lib/actions/sessions/utils.ts @@ -141,6 +141,8 @@ const sessionsSelectColumns = [ "any(user_id) as userId", ]; +export type SessionSortColumn = "start_time" | "duration" | "total_tokens" | "total_cost" | "trace_count"; + export interface BuildSessionsQueryOptions { columns?: string[]; traceIds?: string[]; @@ -150,10 +152,31 @@ export interface BuildSessionsQueryOptions { startTime?: string; endTime?: string; pastHours?: string; + sortColumn?: SessionSortColumn; + sortDirection?: "ASC" | "DESC"; } +const SORT_COLUMN_MAP: Record = { + start_time: "MIN(start_time)", + duration: "SUM(end_time - start_time)", + total_tokens: "SUM(total_tokens)", + total_cost: "SUM(total_cost)", + trace_count: "COUNT(*)", +}; + export const buildSessionsQueryWithParams = (options: BuildSessionsQueryOptions): QueryResult => { - const { traceIds = [], filters, limit, offset, startTime, endTime, pastHours, columns } = options; + const { + traceIds = [], + filters, + limit, + offset, + startTime, + endTime, + pastHours, + columns, + sortColumn, + sortDirection, + } = options; const whereFilters: Filter[] = []; const havingFilters: Filter[] = []; @@ -213,8 +236,8 @@ export const buildSessionsQueryWithParams = (options: BuildSessionsQueryOptions) groupBy: ["session_id"], orderBy: [ { - column: "MIN(start_time)", - direction: "DESC", + column: (sortColumn && SORT_COLUMN_MAP[sortColumn]) || "MIN(start_time)", + direction: sortDirection === "ASC" ? "ASC" : "DESC", }, ], ...(!isNil(limit) && @@ -228,94 +251,3 @@ export const buildSessionsQueryWithParams = (options: BuildSessionsQueryOptions) return buildSelectQuery(queryOptions); }; - -export const buildSessionsCountQueryWithParams = ( - options: Omit -): QueryResult => { - const { traceIds = [], filters, startTime, endTime, pastHours } = options; - - const whereFilters: Filter[] = []; - const havingFilters: Filter[] = []; - - const aggregateColumns = new Set([ - "trace_count", - "input_tokens", - "output_tokens", - "total_tokens", - "input_cost", - "output_cost", - "total_cost", - "duration", - ]); - - filters.forEach((filter) => { - if (aggregateColumns.has(filter.column)) { - havingFilters.push(filter); - } else { - whereFilters.push(filter); - } - }); - - const customConditions: Array<{ - condition: string; - params: QueryParams; - }> = []; - - if (traceIds?.length > 0) { - customConditions.push({ - condition: `id IN ({traceIds:Array(UUID)})`, - params: { traceIds }, - }); - } - - customConditions.push({ - condition: `session_id != '' AND session_id != ''`, - params: {}, - }); - - if (havingFilters.length > 0) { - const subqueryOptions: SelectQueryOptions = { - select: { - columns: ["session_id"], - table: "traces", - }, - timeRange: { - startTime, - endTime, - pastHours, - timeColumn: "start_time", - }, - filters: whereFilters, - columnFilterConfig: sessionsWhereColumnFilterConfig, - havingFilters, - havingColumnFilterConfig: sessionsHavingColumnFilterConfig, - customConditions, - groupBy: ["session_id"], - }; - - const subquery = buildSelectQuery(subqueryOptions); - - return { - query: `SELECT COUNT(*) as count FROM (${subquery.query}) as sessions_with_filters`, - parameters: subquery.parameters, - }; - } - - const queryOptions: SelectQueryOptions = { - select: { - columns: ["COUNT(DISTINCT session_id) as count"], - table: "traces", - }, - timeRange: { - startTime, - endTime, - pastHours, - timeColumn: "start_time", - }, - filters: whereFilters, - columnFilterConfig: sessionsWhereColumnFilterConfig, - customConditions, - }; - - return buildSelectQuery(queryOptions); -}; diff --git a/frontend/lib/actions/spans/previews/index.ts b/frontend/lib/actions/spans/previews/index.ts index 4a8c8deac..dd8540c00 100644 --- a/frontend/lib/actions/spans/previews/index.ts +++ b/frontend/lib/actions/spans/previews/index.ts @@ -335,14 +335,27 @@ const saveRenderingKeys = async ( } }; -export async function getSpanPreviews( - input: z.infer, +export interface RawSpanData { + spanId: string; + data: string; + name: string; +} + +/** + * Process already-fetched raw span data through the full preview pipeline + * (classify → cached DB keys → provider matching → LLM generation). + * + * Use this when you already have the span output data and want to avoid + * a redundant ClickHouse fetch. + */ +export async function processSpanPreviews( + rawSpans: RawSpanData[], + projectId: string, + spanIds: string[], + spanTypes: Record, options: GetSpanPreviewsOptions = {} ): Promise { const { skipGeneration = false } = options; - const { projectId, traceId, spanIds, spanTypes, startDate, endDate } = GetSpanPreviewsSchema.parse(input); - - const rawSpans = await fetchSpanData(projectId, traceId, spanIds, spanTypes, startDate, endDate); const { resolved: classifiedPreviews, needsProcessing } = classifyRawSpans(rawSpans, spanTypes); @@ -363,7 +376,6 @@ export async function getSpanPreviews( const providerResult = { ...cachedResult, ...providerPreviews }; if (skipGeneration || needsLlm.length === 0) { - // Shared traces: resolve remaining spans with JSON fallback, no LLM calls for (const span of needsLlm) { providerResult[span.spanId] = toJsonPreview(span.parsedData); } @@ -376,3 +388,14 @@ export async function getSpanPreviews( return fillMissing({ ...providerResult, ...llmPreviews }, spanIds); } + +export async function getSpanPreviews( + input: z.infer, + options: GetSpanPreviewsOptions = {} +): Promise { + const { projectId, traceId, spanIds, spanTypes, startDate, endDate } = GetSpanPreviewsSchema.parse(input); + + const rawSpans = await fetchSpanData(projectId, traceId, spanIds, spanTypes, startDate, endDate); + + return processSpanPreviews(rawSpans, projectId, spanIds, spanTypes, options); +} diff --git a/frontend/lib/actions/trace/index.ts b/frontend/lib/actions/trace/index.ts index 91aa12d53..ae48a46b5 100644 --- a/frontend/lib/actions/trace/index.ts +++ b/frontend/lib/actions/trace/index.ts @@ -189,7 +189,8 @@ export async function getTrace(input: z.infer): Promise { + if (this.useRedis) { + const client = await this.getRedisClient(); + try { + const result = await client.expire(key, seconds); + return result === 1; + } catch (e) { + console.error("Error setting expiry in cache", e); + return false; + } + } else { + const entry = this.memoryCache.get(key); + if (!entry) return false; + entry.expiresAt = Date.now() + seconds * 1000; + return true; + } + } + async zrange(key: string, start: number, stop: number): Promise { if (this.useRedis) { const client = await this.getRedisClient(); diff --git a/frontend/lib/traces/utils.ts b/frontend/lib/traces/utils.ts index 5672e50f7..a70e6f626 100644 --- a/frontend/lib/traces/utils.ts +++ b/frontend/lib/traces/utils.ts @@ -12,6 +12,13 @@ export const SPAN_TYPE_TO_COLOR = { [SpanType.CACHED]: "hsl(var(--llm))", }; +export function formatDuration(durationSec: number): string { + if (durationSec < 0.01) return "0s"; + if (durationSec < 100) return `${durationSec.toFixed(2)}s`; + if (durationSec < 1000) return `${durationSec.toFixed(1)}s`; + return `${Math.round(durationSec)}s`; +} + // If the span hadn't arrived in one hour, it's probably not going to arrive. const MILLISECONDS_DATE_THRESHOLD = 1000 * 60 * 60; // 1 hour diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index e517fd6ca..92a7d0b03 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -336,6 +336,38 @@ export const inferImageType = (base64: string): `image/${string}` | null => { } return null; }; +export function formatTimeRange(start: Date, end: Date): string { + const sameDay = start.toDateString() === end.toDateString(); + + const startHours = start.getHours(); + const startMinutes = String(start.getMinutes()).padStart(2, "0"); + const startAmPm = startHours >= 12 ? "PM" : "AM"; + const startH = startHours % 12 || 12; + const startTimeStr = `${startH}:${startMinutes} ${startAmPm}`; + + const endHours = end.getHours(); + const endMinutes = String(end.getMinutes()).padStart(2, "0"); + const endAmPm = endHours >= 12 ? "PM" : "AM"; + const endH = endHours % 12 || 12; + const endTimeStr = `${endH}:${endMinutes} ${endAmPm}`; + + const isToday = start.toDateString() === new Date().toDateString(); + + if (isToday && sameDay) { + return `${startTimeStr} – ${endTimeStr}`; + } + + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const startDateStr = `${months[start.getMonth()]} ${start.getDate()}`; + + if (sameDay) { + return `${startDateStr}, ${startTimeStr} – ${endTimeStr}`; + } + + const endDateStr = `${months[end.getMonth()]} ${end.getDate()}`; + return `${startDateStr}, ${startTimeStr} – ${endDateStr}, ${endTimeStr}`; +} + export const getDurationString = (startTime: string, endTime: string) => { const start = new Date(startTime); const end = new Date(endTime);