diff --git a/backend/ee/onyx/db/analytics.py b/backend/ee/onyx/db/analytics.py index b19524f7e96..5e525fa624d 100644 --- a/backend/ee/onyx/db/analytics.py +++ b/backend/ee/onyx/db/analytics.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from uuid import UUID +from sqlalchemy import and_ from sqlalchemy import case from sqlalchemy import cast from sqlalchemy import Date @@ -14,6 +15,9 @@ from onyx.db.models import ChatMessage from onyx.db.models import ChatMessageFeedback from onyx.db.models import ChatSession +from onyx.db.models import Persona +from onyx.db.models import User +from onyx.db.models import UserRole def fetch_query_analytics( @@ -234,3 +238,121 @@ def fetch_persona_unique_users( ) return [tuple(row) for row in db_session.execute(query).all()] + + +def fetch_assistant_message_analytics( + db_session: Session, + assistant_id: int, + start: datetime.datetime, + end: datetime.datetime, +) -> list[tuple[int, datetime.date]]: + """ + Gets the daily message counts for a specific assistant in the given time range. + """ + query = ( + select( + func.count(ChatMessage.id), + cast(ChatMessage.time_sent, Date), + ) + .join( + ChatSession, + ChatMessage.chat_session_id == ChatSession.id, + ) + .where( + or_( + ChatMessage.alternate_assistant_id == assistant_id, + ChatSession.persona_id == assistant_id, + ), + ChatMessage.time_sent >= start, + ChatMessage.time_sent <= end, + ChatMessage.message_type == MessageType.ASSISTANT, + ) + .group_by(cast(ChatMessage.time_sent, Date)) + .order_by(cast(ChatMessage.time_sent, Date)) + ) + + return [tuple(row) for row in db_session.execute(query).all()] + + +def fetch_assistant_unique_users( + db_session: Session, + assistant_id: int, + start: datetime.datetime, + end: datetime.datetime, +) -> list[tuple[int, datetime.date]]: + """ + Gets the daily unique user counts for a specific assistant in the given time range. + """ + query = ( + select( + func.count(func.distinct(ChatSession.user_id)), + cast(ChatMessage.time_sent, Date), + ) + .join( + ChatSession, + ChatMessage.chat_session_id == ChatSession.id, + ) + .where( + or_( + ChatMessage.alternate_assistant_id == assistant_id, + ChatSession.persona_id == assistant_id, + ), + ChatMessage.time_sent >= start, + ChatMessage.time_sent <= end, + ChatMessage.message_type == MessageType.ASSISTANT, + ) + .group_by(cast(ChatMessage.time_sent, Date)) + .order_by(cast(ChatMessage.time_sent, Date)) + ) + + return [tuple(row) for row in db_session.execute(query).all()] + + +def fetch_assistant_unique_users_total( + db_session: Session, + assistant_id: int, + start: datetime.datetime, + end: datetime.datetime, +) -> int: + """ + Gets the total number of distinct users who have sent or received messages from + the specified assistant in the given time range. + """ + query = ( + select(func.count(func.distinct(ChatSession.user_id))) + .select_from(ChatMessage) + .join( + ChatSession, + ChatMessage.chat_session_id == ChatSession.id, + ) + .where( + or_( + ChatMessage.alternate_assistant_id == assistant_id, + ChatSession.persona_id == assistant_id, + ), + ChatMessage.time_sent >= start, + ChatMessage.time_sent <= end, + ChatMessage.message_type == MessageType.ASSISTANT, + ) + ) + + result = db_session.execute(query).scalar() + return result if result else 0 + + +# Users can view assistant stats if they created the persona, +# or if they are an admin +def user_can_view_assistant_stats( + db_session: Session, user: User | None, assistant_id: int +) -> bool: + # If user is None, assume the user is an admin or auth is disabled + if user is None or user.role == UserRole.ADMIN: + return True + + # Check if the user created the persona + stmt = select(Persona).where( + and_(Persona.id == assistant_id, Persona.user_id == user.id) + ) + + persona = db_session.execute(stmt).scalar_one_or_none() + return persona is not None diff --git a/backend/ee/onyx/server/analytics/api.py b/backend/ee/onyx/server/analytics/api.py index 374bcb10dba..46f458c1748 100644 --- a/backend/ee/onyx/server/analytics/api.py +++ b/backend/ee/onyx/server/analytics/api.py @@ -1,17 +1,24 @@ import datetime from collections import defaultdict +from typing import List from fastapi import APIRouter from fastapi import Depends +from fastapi import HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session +from ee.onyx.db.analytics import fetch_assistant_message_analytics +from ee.onyx.db.analytics import fetch_assistant_unique_users +from ee.onyx.db.analytics import fetch_assistant_unique_users_total from ee.onyx.db.analytics import fetch_onyxbot_analytics from ee.onyx.db.analytics import fetch_per_user_query_analytics from ee.onyx.db.analytics import fetch_persona_message_analytics from ee.onyx.db.analytics import fetch_persona_unique_users from ee.onyx.db.analytics import fetch_query_analytics +from ee.onyx.db.analytics import user_can_view_assistant_stats from onyx.auth.users import current_admin_user +from onyx.auth.users import current_user from onyx.db.engine import get_session from onyx.db.models import User @@ -191,3 +198,74 @@ def get_persona_unique_users( ) ) return unique_user_counts + + +class AssistantDailyUsageResponse(BaseModel): + date: datetime.date + total_messages: int + total_unique_users: int + + +class AssistantStatsResponse(BaseModel): + daily_stats: List[AssistantDailyUsageResponse] + total_messages: int + total_unique_users: int + + +@router.get("/assistant/{assistant_id}/stats") +def get_assistant_stats( + assistant_id: int, + start: datetime.datetime | None = None, + end: datetime.datetime | None = None, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> AssistantStatsResponse: + """ + Returns daily message and unique user counts for a user's assistant, + along with the overall total messages and total distinct users. + """ + start = start or ( + datetime.datetime.utcnow() - datetime.timedelta(days=_DEFAULT_LOOKBACK_DAYS) + ) + end = end or datetime.datetime.utcnow() + + if not user_can_view_assistant_stats(db_session, user, assistant_id): + raise HTTPException( + status_code=403, detail="Not allowed to access this assistant's stats." + ) + + # Pull daily usage from the DB calls + messages_data = fetch_assistant_message_analytics( + db_session, assistant_id, start, end + ) + unique_users_data = fetch_assistant_unique_users( + db_session, assistant_id, start, end + ) + + # Map each day => (messages, unique_users). + daily_messages_map = {date: count for count, date in messages_data} + daily_unique_users_map = {date: count for count, date in unique_users_data} + all_dates = set(daily_messages_map.keys()) | set(daily_unique_users_map.keys()) + + # Merge both sets of metrics by date + daily_results: list[AssistantDailyUsageResponse] = [] + for date in sorted(all_dates): + daily_results.append( + AssistantDailyUsageResponse( + date=date, + total_messages=daily_messages_map.get(date, 0), + total_unique_users=daily_unique_users_map.get(date, 0), + ) + ) + + # Now pull a single total distinct user count across the entire time range + total_msgs = sum(d.total_messages for d in daily_results) + total_users = fetch_assistant_unique_users_total( + db_session, assistant_id, start, end + ) + + return AssistantStatsResponse( + daily_stats=daily_results, + total_messages=total_msgs, + total_unique_users=total_users, + ) diff --git a/backend/onyx/db/persona.py b/backend/onyx/db/persona.py index c20829d83fc..d092a4c3275 100644 --- a/backend/onyx/db/persona.py +++ b/backend/onyx/db/persona.py @@ -99,6 +99,9 @@ def _add_user_filters( return stmt.where(where_clause) +# fetch_persona_by_id is used to fetch a persona by its ID. It is used to fetch a persona by its ID. + + def fetch_persona_by_id( db_session: Session, persona_id: int, user: User | None, get_editable: bool = True ) -> Persona: diff --git a/web/src/app/assistants/mine/AssistantsList.tsx b/web/src/app/assistants/mine/AssistantsList.tsx index 0c9bb50ca28..b9a9760ba3d 100644 --- a/web/src/app/assistants/mine/AssistantsList.tsx +++ b/web/src/app/assistants/mine/AssistantsList.tsx @@ -6,6 +6,7 @@ import { Persona } from "@/app/admin/assistants/interfaces"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { + FiBarChart, FiEdit2, FiList, FiMinus, @@ -59,6 +60,7 @@ import { MakePublicAssistantModal } from "@/app/chat/modal/MakePublicAssistantMo import { CustomTooltip } from "@/components/tooltip/CustomTooltip"; import { useAssistants } from "@/components/context/AssistantsContext"; import { useUser } from "@/components/user/UserProvider"; +import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; function DraggableAssistantListItem({ ...props }: any) { const { @@ -116,7 +118,9 @@ function AssistantListItem({ const router = useRouter(); const [showSharingModal, setShowSharingModal] = useState(false); + const isEnterpriseEnabled = usePaidEnterpriseFeaturesEnabled(); const isOwnedByUser = checkUserOwnsAssistant(user, assistant); + const { isAdmin } = useUser(); return ( <> @@ -243,6 +247,18 @@ function AssistantListItem({ Add ), + + (isOwnedByUser || isAdmin) && isEnterpriseEnabled ? ( + + router.push(`/assistants/stats/${assistant.id}`) + } + > + View Stats + + ) : null, isOwnedByUser ? ( )} - {makePublicPersona && ( p.id === selectedPersonaId); - return ( Persona Analytics diff --git a/web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx b/web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx new file mode 100644 index 00000000000..32b3300cc6d --- /dev/null +++ b/web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx @@ -0,0 +1,188 @@ +import { ThreeDotsLoader } from "@/components/Loading"; +import { getDatesList } from "@/app/ee/admin/performance/lib"; +import Text from "@/components/ui/text"; +import Title from "@/components/ui/title"; +import CardSection from "@/components/admin/CardSection"; +import { AreaChartDisplay } from "@/components/ui/areaChart"; +import { useEffect, useState, useMemo } from "react"; +import { + DateRangeSelector, + DateRange, +} from "@/app/ee/admin/performance/DateRangeSelector"; +import { useAssistants } from "@/components/context/AssistantsContext"; +import { AssistantIcon } from "@/components/assistants/AssistantIcon"; + +type AssistantDailyUsageEntry = { + date: string; + total_messages: number; + total_unique_users: number; +}; + +type AssistantStatsResponse = { + daily_stats: AssistantDailyUsageEntry[]; + total_messages: number; + total_unique_users: number; +}; + +export function AssistantStats({ assistantId }: { assistantId: number }) { + const [assistantStats, setAssistantStats] = + useState(null); + const { assistants } = useAssistants(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState({ + from: new Date(new Date().setDate(new Date().getDate() - 30)), + to: new Date(), + }); + + const assistant = useMemo(() => { + return assistants.find((a) => a.id === assistantId); + }, [assistants, assistantId]); + + useEffect(() => { + async function fetchStats() { + try { + setIsLoading(true); + setError(null); + + const res = await fetch( + `/api/analytics/assistant/${assistantId}/stats?start=${ + dateRange?.from?.toISOString() || "" + }&end=${dateRange?.to?.toISOString() || ""}` + ); + + if (!res.ok) { + if (res.status === 403) { + throw new Error("You don't have permission to view these stats."); + } + throw new Error("Failed to fetch assistant stats"); + } + + const data = (await res.json()) as AssistantStatsResponse; + setAssistantStats(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "An unknown error occurred" + ); + } finally { + setIsLoading(false); + } + } + + fetchStats(); + }, [assistantId, dateRange]); + + const chartData = useMemo(() => { + if (!assistantStats?.daily_stats?.length || !dateRange) { + return null; + } + + const initialDate = + dateRange.from || + new Date( + Math.min( + ...assistantStats.daily_stats.map((entry) => + new Date(entry.date).getTime() + ) + ) + ); + const endDate = dateRange.to || new Date(); + + const dateRangeList = getDatesList(initialDate); + + const statsMap = new Map( + assistantStats.daily_stats.map((entry) => [entry.date, entry]) + ); + + return dateRangeList + .filter((date) => new Date(date) <= endDate) + .map((dateStr) => { + const dayData = statsMap.get(dateStr); + return { + Day: dateStr, + Messages: dayData?.total_messages || 0, + "Unique Users": dayData?.total_unique_users || 0, + }; + }); + }, [assistantStats, dateRange]); + + const totalMessages = assistantStats?.total_messages ?? 0; + const totalUniqueUsers = assistantStats?.total_unique_users ?? 0; + + let content; + if (isLoading || !assistant) { + content = ( + + + + ); + } else if (error) { + content = ( + + {error} + + ); + } else if (!assistantStats?.daily_stats?.length) { + content = ( + + + No data found for this assistant in the selected date range + + + ); + } else if (chartData) { + content = ( + + ); + } + + return ( + + + + Assistant Analytics + + Messages and unique users per day for the assistant{" "} + {assistant?.name} + + + + {assistant && ( + + + + {assistant?.name} + + + {assistant?.description} + + + )} + + + + + Total Messages + {totalMessages} + + + Total Unique Users + {totalUniqueUsers} + + + + {content} + + ); +} diff --git a/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx b/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx new file mode 100644 index 00000000000..2e291295432 --- /dev/null +++ b/web/src/app/ee/assistants/stats/[id]/WrappedAssistantsStats.tsx @@ -0,0 +1,17 @@ +"use client"; +import SidebarWrapper from "../../../../assistants/SidebarWrapper"; +import { AssistantStats } from "./AssistantStats"; + +export default function WrappedAssistantsStats({ + initiallyToggled, + assistantId, +}: { + initiallyToggled: boolean; + assistantId: number; +}) { + return ( + + + + ); +} diff --git a/web/src/app/ee/assistants/stats/[id]/page.tsx b/web/src/app/ee/assistants/stats/[id]/page.tsx new file mode 100644 index 00000000000..cdee465208a --- /dev/null +++ b/web/src/app/ee/assistants/stats/[id]/page.tsx @@ -0,0 +1,68 @@ +import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; + +import { fetchChatData } from "@/lib/chat/fetchChatData"; +import { unstable_noStore as noStore } from "next/cache"; +import { redirect } from "next/navigation"; + +import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper"; +import { cookies } from "next/headers"; +import { ChatProvider } from "@/components/context/ChatContext"; +import WrappedAssistantsStats from "./WrappedAssistantsStats"; + +export default async function GalleryPage(props: { + params: Promise<{ id: string }>; +}) { + const params = await props.params; + noStore(); + const requestCookies = await cookies(); + + const data = await fetchChatData({}); + + if ("redirect" in data) { + redirect(data.redirect); + } + + const { + user, + chatSessions, + folders, + openedFolders, + toggleSidebar, + shouldShowWelcomeModal, + availableSources, + ccPairs, + documentSets, + tags, + llmProviders, + defaultAssistantId, + } = data; + + return ( + + {shouldShowWelcomeModal && ( + + )} + + + + + ); +} diff --git a/web/src/middleware.ts b/web/src/middleware.ts index cd601458133..80beafa94d5 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -12,6 +12,7 @@ export const config = { "/admin/whitelabeling/:path*", "/admin/performance/custom-analytics/:path*", "/admin/standard-answer/:path*", + "/assistants/stats/:path*", // Cloud only "/admin/billing/:path*",
{error}
+ No data found for this assistant in the selected date range +