import { StarSessionToggle } from "@/src/components/star-toggle"; import { DataTable } from "@/src/components/table/data-table"; import { DataTableToolbar } from "@/src/components/table/data-table-toolbar"; import { DataTableControlsProvider, DataTableControls, } from "@/src/components/table/data-table-controls"; import { ResizableFilterLayout } from "@/src/components/table/resizable-filter-layout"; import TableLink from "@/src/components/table/table-link"; import { type LangfuseColumnDef } from "@/src/components/table/types"; import { TokenUsageBadge } from "@/src/components/token-usage-badge"; import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility"; import { useSidebarFilterState } from "@/src/features/filters/hooks/useSidebarFilterState"; import { sessionFilterConfig, SESSION_COLUMN_TO_BACKEND_KEY, } from "@/src/features/filters/config/sessions-config"; import { transformFiltersForBackend } from "@/src/features/filters/lib/filter-transform"; import { type FilterState, BatchExportTableName, TableViewPresetTableName, AnnotationQueueObjectType, BatchActionType, ActionId, type TimeFilter, } from "@langfuse/shared"; import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context"; import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState"; import { api } from "@/src/utils/api"; import { formatIntervalSeconds } from "@/src/utils/dates"; import { numberFormatter, usdFormatter } from "@/src/utils/numbers"; import { type RouterOutput } from "@/src/utils/types"; import type Decimal from "decimal.js"; import { useEffect, useState, useMemo, useRef, useCallback } from "react"; import { NumberParam, useQueryParams, withDefault } from "use-query-params"; import { useTableDateRange } from "@/src/hooks/useTableDateRange"; import { toAbsoluteTimeRange } from "@/src/utils/date-range-utils"; import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics"; import { Skeleton } from "@/src/components/ui/skeleton"; import TagList from "@/src/features/tag/components/TagList"; import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch"; import { cn } from "@/src/utils/tailwind"; import useColumnOrder from "@/src/features/column-visibility/hooks/useColumnOrder"; import { LocalIsoDate } from "@/src/components/LocalIsoDate"; import { useTableViewManager } from "@/src/components/table/table-view-presets/hooks/useTableViewManager"; import { Badge } from "@/src/components/ui/badge"; import { type ScoreAggregate } from "@langfuse/shared"; import { useSelectAll } from "@/src/features/table/hooks/useSelectAll"; import { type TableAction } from "@/src/features/table/types"; import { TableActionMenu } from "@/src/features/table/components/TableActionMenu"; import { type RowSelectionState } from "@tanstack/react-table"; import { showSuccessToast } from "@/src/features/notifications/showSuccessToast"; import { TableSelectionManager } from "@/src/features/table/components/TableSelectionManager"; import { useScoreColumns } from "@/src/features/scores/hooks/useScoreColumns"; import { scoreFilters } from "@/src/features/scores/lib/scoreColumns"; import { BatchExportTableButton } from "@/src/components/BatchExportTableButton"; export type SessionTableRow = { id: string; createdAt: Date; bookmarked: boolean; userIds: string[] | undefined; countTraces: number | undefined; sessionDuration: number | null | undefined; inputCost: Decimal | undefined; outputCost: Decimal | undefined; totalCost: Decimal | undefined; inputTokens: number | undefined; outputTokens: number | undefined; totalTokens: number | undefined; traceTags: string[] | undefined; environment?: string; scores?: ScoreAggregate; }; export type SessionTableProps = { projectId: string; userId?: string; omittedFilter?: string[]; }; export default function SessionsTable({ projectId, userId, omittedFilter = [], }: SessionTableProps) { const { setDetailPageList } = useDetailPageLists(); const { timeRange, setTimeRange } = useTableDateRange(projectId); // Convert timeRange to absolute date range for compatibility const dateRange = useMemo(() => { return toAbsoluteTimeRange(timeRange) ?? undefined; }, [timeRange]); const [selectedRows, setSelectedRows] = useState({}); const userIdFilter: FilterState = userId ? [ { column: "User IDs", type: "arrayOptions", operator: "any of", value: [userId], }, ] : []; const dateRangeFilter: FilterState = dateRange ? [ { column: "createdAt", type: "datetime", operator: ">=", value: dateRange.from, }, ...(dateRange.to ? [ { column: "createdAt", type: "datetime", operator: "<=", value: dateRange.to, } as const, ] : []), ] : []; const environmentFilterOptions = api.projects.environmentFilterOptions.useQuery( { projectId, fromTimestamp: dateRange?.from, }, { trpc: { context: { skipBatch: true } }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: Infinity, }, ); const environmentOptions = useMemo( () => environmentFilterOptions.data?.map((value) => value.environment) ?? undefined, [environmentFilterOptions.data], ); const { selectAll, setSelectAll } = useSelectAll(projectId, "sessions"); const [paginationState, setPaginationState] = useQueryParams({ pageIndex: withDefault(NumberParam, 0), pageSize: withDefault(NumberParam, 50), }); const [rowHeight, setRowHeight] = useRowHeightLocalStorage("sessions", "s"); const [orderByState, setOrderByState] = useOrderByState({ column: "createdAt", order: "DESC", }); // dateRangeFilter contains only createdAt datetime filters, pass directly to API const filterOptions = api.sessions.filterOptions.useQuery( { projectId, timestampFilter: dateRangeFilter.length > 0 ? (dateRangeFilter as TimeFilter[]) : undefined, }, { trpc: { context: { skipBatch: true, }, }, }, ); const newFilterOptions = useMemo(() => { const scoreCategories = filterOptions.data?.score_categories?.reduce( (acc, score) => { acc[score.label] = score.values; return acc; }, {} as Record, ) ?? undefined; const scoresNumeric = filterOptions.data?.scores_avg ?? undefined; return { bookmarked: ["Bookmarked", "Not bookmarked"], environment: environmentOptions, userIds: filterOptions.data?.userIds.map((u) => ({ value: u.value, count: Number(u.count), })) ?? undefined, tags: filterOptions.data?.tags.map((t) => t.value) ?? undefined, // tags don't have counts sessionDuration: [], countTraces: [], inputTokens: [], outputTokens: [], totalTokens: [], inputCost: [], outputCost: [], totalCost: [], score_categories: scoreCategories, scores_avg: scoresNumeric, }; }, [environmentOptions, filterOptions.data]); const queryFilter = useSidebarFilterState( sessionFilterConfig, newFilterOptions, projectId, filterOptions.isPending || environmentFilterOptions.isPending, ); // Create ref-based wrapper to avoid stale closure when queryFilter updates const queryFilterRef = useRef(queryFilter); queryFilterRef.current = queryFilter; const setFiltersWrapper = useCallback( (filters: FilterState) => queryFilterRef.current?.setFilterState(filters), [], ); const combinedFilterState = queryFilter.filterState.concat( userIdFilter, dateRangeFilter, ); const backendFilterState = transformFiltersForBackend( combinedFilterState, SESSION_COLUMN_TO_BACKEND_KEY, sessionFilterConfig.columnDefinitions, ); const payloadCount = { projectId, filter: backendFilterState, orderBy: null, page: 0, limit: 1, }; const payloadGetAll = { ...payloadCount, orderBy: orderByState, page: paginationState.pageIndex, limit: paginationState.pageSize, }; const sessions = api.sessions.all.useQuery(payloadGetAll, { refetchOnWindowFocus: true, }); const sessionCountQuery = api.sessions.countAll.useQuery(payloadCount, { refetchOnWindowFocus: true, }); const addToQueueMutation = api.annotationQueueItems.createMany.useMutation({ onSuccess: (data) => { showSuccessToast({ title: "Sessions added to queue", description: `Selected sessions will be added to queue "${data.queueName}". This may take a minute.`, link: { href: `/project/${projectId}/annotation-queues/${data.queueId}`, text: `View queue "${data.queueName}"`, }, }); }, }); const { scoreColumns, isLoading: isColumnLoading } = useScoreColumns({ projectId, scoreColumnKey: "scores", fromTimestamp: dateRange?.from, filter: scoreFilters.forSessions(), }); const sessionMetrics = api.sessions.metrics.useQuery( { projectId, sessionIds: sessions.data?.sessions.map((s) => s.id) ?? [], }, { enabled: sessions.data !== undefined, refetchOnWindowFocus: true, }, ); type SessionCoreOutput = RouterOutput["sessions"]["all"]["sessions"][number]; type SessionMetricOutput = RouterOutput["sessions"]["metrics"][number]; const sessionRowData = joinTableCoreAndMetrics< SessionCoreOutput, SessionMetricOutput >(sessions.data?.sessions, sessionMetrics.data); const totalCount = sessionCountQuery.data?.totalCount ?? null; useEffect(() => { if (sessions.isSuccess) { setDetailPageList( "sessions", sessions.data.sessions.map((t) => ({ id: t.id })), ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessions.isSuccess, sessions.data]); const { selectActionColumn } = TableSelectionManager({ projectId, tableName: "sessions", setSelectedRows, }); const handleAddToAnnotationQueue = async ({ projectId, targetId, }: { projectId: string; targetId: string; }) => { const selectedSessionIds = Object.keys(selectedRows).filter((sessionId) => sessions.data?.sessions.map((t) => t.id).includes(sessionId), ); await addToQueueMutation.mutateAsync({ projectId, objectIds: selectedSessionIds, objectType: AnnotationQueueObjectType.SESSION, queueId: targetId, isBatchAction: selectAll, query: { filter: backendFilterState, orderBy: orderByState, }, }); setSelectedRows({}); }; const tableActions: TableAction[] = [ { id: ActionId.SessionAddToAnnotationQueue, type: BatchActionType.Create, label: "Add to Annotation Queue", description: "Add selected sessions to an annotation queue.", targetLabel: "Annotation Queue", execute: handleAddToAnnotationQueue, accessCheck: { scope: "annotationQueues:CUD", }, }, ]; const columns: LangfuseColumnDef[] = [ selectActionColumn, { accessorKey: "bookmarked", id: "bookmarked", isFixedPosition: true, header: undefined, size: 50, cell: ({ row }) => { const bookmarked: SessionTableRow["bookmarked"] = row.getValue("bookmarked"); const sessionId: SessionTableRow["id"] = row.getValue("id"); return typeof sessionId === "string" && typeof bookmarked === "boolean" ? ( ) : undefined; }, enableSorting: false, }, { accessorKey: "id", id: "id", header: "ID", size: 200, isFixedPosition: true, cell: ({ row }) => { const value: SessionTableRow["id"] = row.getValue("id"); return value && typeof value === "string" ? ( ) : undefined; }, enableSorting: true, }, { accessorKey: "createdAt", id: "createdAt", header: "Created At", size: 150, enableHiding: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["createdAt"] = row.getValue("createdAt"); return value ? : undefined; }, }, { accessorKey: "sessionDuration", id: "sessionDuration", header: "Duration", size: 130, enableHiding: true, cell: ({ row }) => { const value: SessionTableRow["sessionDuration"] = row.getValue("sessionDuration"); if (!sessionMetrics.isSuccess) { return ; } return value && typeof value === "number" ? formatIntervalSeconds(value) : undefined; }, enableSorting: true, }, { accessorKey: "environment", header: "Environment", id: "environment", size: 150, enableHiding: true, cell: ({ row }) => { const value: SessionTableRow["environment"] = row.getValue("environment"); return value ? ( {value} ) : null; }, }, { accessorKey: "scores", header: "Scores", id: "scores", enableHiding: true, defaultHidden: true, cell: () => { return isColumnLoading ? : null; }, columns: scoreColumns, }, { accessorKey: "userIds", enableColumnFilter: !omittedFilter.find((f) => f === "userIds"), id: "userIds", header: "User IDs", size: 200, enableHiding: true, cell: ({ row }) => { const value: SessionTableRow["userIds"] = row.getValue("userIds"); if (!sessionMetrics.isSuccess) { return ; } return value && Array.isArray(value) ? (
{(value as string[]).map((user) => ( ))}
) : undefined; }, }, { accessorKey: "countTraces", id: "countTraces", header: "Traces", size: 100, headerTooltip: { description: "The number of traces in the session.", }, enableHiding: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["countTraces"] = row.getValue("countTraces"); if (!sessionMetrics.isSuccess) { return ; } return value ? {numberFormatter(value, 0)} : undefined; }, }, { accessorKey: "inputCost", id: "inputCost", header: "Input Cost", size: 110, enableHiding: true, defaultHidden: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["inputCost"] = row.getValue("inputCost"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {usdFormatter(value.toNumber())} ) : undefined; }, }, { accessorKey: "outputCost", id: "outputCost", header: "Output Cost", size: 110, enableHiding: true, enableSorting: true, defaultHidden: true, cell: ({ row }) => { const value: SessionTableRow["outputCost"] = row.getValue("outputCost"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {usdFormatter(value.toNumber())} ) : undefined; }, }, { accessorKey: "totalCost", id: "totalCost", header: "Total Cost", size: 110, enableHiding: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["totalCost"] = row.getValue("totalCost"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {usdFormatter(value.toNumber())} ) : undefined; }, }, { accessorKey: "inputTokens", id: "inputTokens", header: "Input Tokens", size: 110, enableHiding: true, defaultHidden: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["inputTokens"] = row.getValue("inputTokens"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {numberFormatter(Number(value), 0)} ) : undefined; }, }, { accessorKey: "outputTokens", id: "outputTokens", header: "Output Tokens", size: 110, enableHiding: true, defaultHidden: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["outputTokens"] = row.getValue("outputTokens"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {numberFormatter(Number(value), 0)} ) : undefined; }, }, { accessorKey: "totalTokens", id: "totalTokens", header: "Total Tokens", size: 110, enableHiding: true, defaultHidden: true, enableSorting: true, cell: ({ row }) => { const value: SessionTableRow["totalTokens"] = row.getValue("totalTokens"); if (!sessionMetrics.isSuccess) { return ; } return value ? ( {numberFormatter(Number(value), 0)} ) : undefined; }, }, { accessorKey: "usage", id: "usage", header: "Usage", size: 220, enableHiding: true, enableSorting: true, cell: ({ row }) => { const promptTokens: SessionTableRow["inputTokens"] = row.getValue("inputTokens"); const completionTokens: SessionTableRow["outputTokens"] = row.getValue("outputTokens"); const totalTokens: SessionTableRow["totalTokens"] = row.getValue("totalTokens"); if (!sessionMetrics.isSuccess) { return ; } return ( ); }, }, { accessorKey: "traceTags", id: "traceTags", header: "Trace Tags", size: 250, enableHiding: true, defaultHidden: true, cell: ({ row }) => { const value: SessionTableRow["traceTags"] = row.getValue("traceTags"); if (!sessionMetrics.isSuccess) { return ; } return ( value && (
) ); }, }, ]; const [columnVisibility, setColumnVisibility] = useColumnVisibility("sessionsColumnVisibility", columns); const [columnOrder, setColumnOrder] = useColumnOrder( "sessionsColumnOrder", columns, ); const { isLoading: isViewLoading, ...viewControllers } = useTableViewManager({ tableName: TableViewPresetTableName.Sessions, projectId, stateUpdaters: { setOrderBy: setOrderByState, setFilters: setFiltersWrapper, setColumnOrder: setColumnOrder, setColumnVisibility: setColumnVisibility, }, validationContext: { columns, filterColumnDefinition: sessionFilterConfig.columnDefinitions, }, currentFilterState: queryFilter.filterState, }); return (
{/* Toolbar spanning full width */} sessions.data?.sessions.map((s) => s.id).includes(sessionId), ).length > 0 ? ( ) : null, , ]} columns={columns} columnVisibility={columnVisibility} setColumnVisibility={setColumnVisibility} columnOrder={columnOrder} setColumnOrder={setColumnOrder} viewConfig={{ tableName: TableViewPresetTableName.Sessions, projectId, controllers: viewControllers, }} timeRange={timeRange} setTimeRange={setTimeRange} columnsWithCustomSelect={["userIds"]} rowHeight={rowHeight} setRowHeight={setRowHeight} multiSelect={{ selectAll, setSelectAll, selectedRowIds: Object.keys(selectedRows).filter((sessionId) => sessions.data?.sessions.map((s) => s.id).includes(sessionId), ), setRowSelection: setSelectedRows, totalCount, ...paginationState, }} /> {/* Content area with sidebar and table */}
( (session) => { return { id: session.id, createdAt: session.createdAt, bookmarked: session.bookmarked, userIds: session.userIds, countTraces: session.countTraces, sessionDuration: session.sessionDuration, inputCost: session.inputCost, outputCost: session.outputCost, totalCost: session.totalCost, inputTokens: session.promptTokens, outputTokens: session.completionTokens, totalTokens: session.totalTokens, traceTags: session.traceTags, environment: session.environment, scores: session.scores, }; }, ), } } pagination={{ totalCount, onChange: setPaginationState, state: paginationState, }} setOrderBy={setOrderByState} orderBy={orderByState} columnVisibility={columnVisibility} onColumnVisibilityChange={setColumnVisibility} columnOrder={columnOrder} onColumnOrderChange={setColumnOrder} rowSelection={selectedRows} setRowSelection={setSelectedRows} help={{ description: "A session is a collection of related traces, such as a conversation or thread. To begin, add a sessionId to the trace.", href: "https://langfuse.com/docs/observability/features/sessions", }} rowHeight={rowHeight} />
); }