mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-05-03 08:20:40 +02:00
add personal assistant usage stats (#3543)
This commit is contained in:
parent
62302e3faf
commit
6c018cb53f
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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}
|
||||
|
@ -172,8 +172,6 @@ export function PersonaMessagesChart({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedPersona = personaList?.find((p) => p.id === selectedPersonaId);
|
||||
|
||||
return (
|
||||
<CardSection className="mt-8">
|
||||
<Title>Persona Analytics</Title>
|
||||
|
188
web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx
Normal file
188
web/src/app/ee/assistants/stats/[id]/AssistantStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
68
web/src/app/ee/assistants/stats/[id]/page.tsx
Normal file
68
web/src/app/ee/assistants/stats/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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*",
|
||||
|
Loading…
x
Reference in New Issue
Block a user