add personal assistant usage stats (#3543)

This commit is contained in:
pablonyx 2025-01-04 10:38:41 -08:00 committed by GitHub
parent 62302e3faf
commit 6c018cb53f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 493 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import datetime
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.configs.constants import MessageType
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

View File

@ -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,
)

View File

@ -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:

View File

@ -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({
<FiPlus size={18} className="text-text-800" /> Add
</button>
),
(isOwnedByUser || isAdmin) && isEnterpriseEnabled ? (
<button
key="view-stats"
className="flex items-center gap-x-2 px-4 py-2 hover:bg-gray-100 w-full text-left"
onClick={() =>
router.push(`/assistants/stats/${assistant.id}`)
}
>
<FiBarChart size={18} /> View Stats
</button>
) : null,
isOwnedByUser ? (
<button
key="delete"
@ -373,7 +389,6 @@ export function AssistantsList() {
}}
/>
)}
{makePublicPersona && (
<MakePublicAssistantModal
isPublic={makePublicPersona.is_public}

View File

@ -172,8 +172,6 @@ export function PersonaMessagesChart({
);
}
const selectedPersona = personaList?.find((p) => p.id === selectedPersonaId);
return (
<CardSection className="mt-8">
<Title>Persona Analytics</Title>

View File

@ -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<AssistantStatsResponse | null>(null);
const { assistants } = useAssistants();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<DateRange>({
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 = (
<div className="h-80 flex flex-col">
<ThreeDotsLoader />
</div>
);
} else if (error) {
content = (
<div className="h-80 text-red-600 text-bold flex flex-col">
<p className="m-auto">{error}</p>
</div>
);
} else if (!assistantStats?.daily_stats?.length) {
content = (
<div className="h-80 text-gray-500 flex flex-col">
<p className="m-auto">
No data found for this assistant in the selected date range
</p>
</div>
);
} else if (chartData) {
content = (
<AreaChartDisplay
className="mt-4"
data={chartData}
categories={["Messages", "Unique Users"]}
index="Day"
colors={["indigo", "fuchsia"]}
yAxisWidth={60}
/>
);
}
return (
<CardSection className="mt-8">
<div className="flex justify-between items-start mb-6">
<div className="flex flex-col gap-2">
<Title>Assistant Analytics</Title>
<Text>
Messages and unique users per day for the assistant{" "}
<b>{assistant?.name}</b>
</Text>
<DateRangeSelector value={dateRange} onValueChange={setDateRange} />
</div>
{assistant && (
<div className="bg-gray-100 p-4 rounded-lg shadow-sm">
<div className="flex items-center mb-2">
<AssistantIcon
disableToolip
size="medium"
assistant={assistant}
/>
<Title className="text-lg ml-3">{assistant?.name}</Title>
</div>
<Text className="text-gray-600 text-sm">
{assistant?.description}
</Text>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<div className="flex justify-between">
<div>
<Text className="font-semibold">Total Messages</Text>
<Text>{totalMessages}</Text>
</div>
<div>
<Text className="font-semibold">Total Unique Users</Text>
<Text>{totalUniqueUsers}</Text>
</div>
</div>
</div>
{content}
</CardSection>
);
}

View File

@ -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 (
<SidebarWrapper page="chat" initiallyToggled={initiallyToggled}>
<AssistantStats assistantId={assistantId} />
</SidebarWrapper>
);
}

View File

@ -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 (
<ChatProvider
value={{
chatSessions,
availableSources,
ccPairs,
documentSets,
tags,
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,
folders,
openedFolders,
shouldShowWelcomeModal,
defaultAssistantId,
}}
>
{shouldShowWelcomeModal && (
<WelcomeModal user={user} requestCookies={requestCookies} />
)}
<InstantSSRAutoRefresh />
<WrappedAssistantsStats
initiallyToggled={toggleSidebar}
assistantId={parseInt(params.id)}
/>
</ChatProvider>
);
}

View File

@ -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*",