Coverage for open_webui/routers/analytics.py: 89%
164 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import logging
2from collections import defaultdict
3from datetime import datetime, timedelta
4from typing import Optional
6from fastapi import APIRouter, Depends, Query
7from open_webui.internal.db import get_async_session
8from open_webui.models.chat_messages import ChatMessageModel, ChatMessages
9from open_webui.models.chats import Chats
10from open_webui.models.feedbacks import Feedbacks
11from open_webui.models.groups import Groups
12from open_webui.models.users import Users
13from open_webui.utils.auth import get_admin_user
14from pydantic import BaseModel
15from sqlalchemy.ext.asyncio import AsyncSession
17log = logging.getLogger(__name__)
20router = APIRouter()
23####################
24# Response Models
25####################
28class ModelAnalyticsEntry(BaseModel):
29 model_id: str
30 count: int
31 unique_users: int = 0
32 unique_chats: int = 0
35class ModelAnalyticsResponse(BaseModel):
36 models: list[ModelAnalyticsEntry]
39class UserAnalyticsEntry(BaseModel):
40 user_id: str
41 name: Optional[str] = None
42 email: Optional[str] = None
43 count: int
44 input_tokens: int = 0
45 output_tokens: int = 0
46 total_tokens: int = 0
49class UserAnalyticsResponse(BaseModel):
50 users: list[UserAnalyticsEntry]
53####################
54# Endpoints
55####################
58@router.get('/models', response_model=ModelAnalyticsResponse)
59async def get_model_analytics(
60 start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
61 end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
62 group_id: Optional[str] = Query(None, description='Filter by user group ID'),
63 user=Depends(get_admin_user),
64 db: AsyncSession = Depends(get_async_session),
65):
66 """Get message counts per model."""
67 counts = await ChatMessages.get_message_count_by_model(
68 start_date=start_date, end_date=end_date, group_id=group_id, db=db
69 )
70 unique_counts = await ChatMessages.get_unique_counts_by_model(
71 start_date=start_date, end_date=end_date, group_id=group_id, db=db
72 )
73 models = [
74 ModelAnalyticsEntry(
75 model_id=model_id,
76 count=count,
77 unique_users=unique_counts.get(model_id, {}).get('unique_users', 0),
78 unique_chats=unique_counts.get(model_id, {}).get('unique_chats', 0),
79 )
80 for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
81 ]
82 return ModelAnalyticsResponse(models=models)
85@router.get('/users', response_model=UserAnalyticsResponse)
86async def get_user_analytics(
87 start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
88 end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
89 group_id: Optional[str] = Query(None, description='Filter by user group ID'),
90 limit: int = Query(50, description='Max users to return'),
91 user=Depends(get_admin_user),
92 db: AsyncSession = Depends(get_async_session),
93):
94 """Get message counts and token usage per user with user info."""
95 counts = await ChatMessages.get_message_count_by_user(
96 start_date=start_date, end_date=end_date, group_id=group_id, db=db
97 )
98 token_usage = await ChatMessages.get_token_usage_by_user(
99 start_date=start_date, end_date=end_date, group_id=group_id, db=db
100 )
102 # Get user info for top users
103 top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]]
104 user_info = {u.id: u for u in await Users.get_users_by_user_ids(top_user_ids, db=db)}
106 users = []
107 for user_id in top_user_ids: 107 ↛ 108line 107 didn't jump to line 108 because the loop on line 107 never started
108 u = user_info.get(user_id)
109 tokens = token_usage.get(user_id, {})
110 users.append(
111 UserAnalyticsEntry(
112 user_id=user_id,
113 name=u.name if u else None,
114 email=u.email if u else None,
115 count=counts[user_id],
116 input_tokens=tokens.get('input_tokens', 0),
117 output_tokens=tokens.get('output_tokens', 0),
118 total_tokens=tokens.get('total_tokens', 0),
119 )
120 )
122 return UserAnalyticsResponse(users=users)
125@router.get('/messages', response_model=list[ChatMessageModel])
126async def get_messages(
127 model_id: Optional[str] = Query(None, description='Filter by model ID'),
128 user_id: Optional[str] = Query(None, description='Filter by user ID'),
129 chat_id: Optional[str] = Query(None, description='Filter by chat ID'),
130 start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
131 end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
132 skip: int = Query(0),
133 limit: int = Query(50, le=100),
134 user=Depends(get_admin_user),
135 db: AsyncSession = Depends(get_async_session),
136):
137 """Query messages with filters."""
138 if chat_id:
139 return await ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db)
140 elif model_id:
141 return await ChatMessages.get_messages_by_model_id(
142 model_id=model_id,
143 start_date=start_date,
144 end_date=end_date,
145 skip=skip,
146 limit=limit,
147 db=db,
148 )
149 elif user_id:
150 return await ChatMessages.get_messages_by_user_id(user_id=user_id, skip=skip, limit=limit, db=db)
151 else:
152 # Return empty if no filter specified
153 return []
156class SummaryResponse(BaseModel):
157 total_messages: int
158 total_chats: int
159 total_models: int
160 total_users: int
163@router.get('/summary', response_model=SummaryResponse)
164async def get_summary(
165 start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
166 end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
167 group_id: Optional[str] = Query(None, description='Filter by user group ID'),
168 user=Depends(get_admin_user),
169 db: AsyncSession = Depends(get_async_session),
170):
171 """Get summary statistics for the dashboard."""
172 model_counts = await ChatMessages.get_message_count_by_model(
173 start_date=start_date, end_date=end_date, group_id=group_id, db=db
174 )
175 user_counts = await ChatMessages.get_message_count_by_user(
176 start_date=start_date, end_date=end_date, group_id=group_id, db=db
177 )
178 chat_counts = await ChatMessages.get_message_count_by_chat(
179 start_date=start_date, end_date=end_date, group_id=group_id, db=db
180 )
182 return SummaryResponse(
183 total_messages=sum(model_counts.values()),
184 total_chats=len(chat_counts),
185 total_models=len(model_counts),
186 total_users=len(user_counts),
187 )
190class DailyStatsEntry(BaseModel):
191 date: str
192 models: dict[str, int]
195class DailyStatsResponse(BaseModel):
196 data: list[DailyStatsEntry]
199@router.get('/daily', response_model=DailyStatsResponse)
200async def get_daily_stats(
201 start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
202 end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
203 group_id: Optional[str] = Query(None, description='Filter by user group ID'),
204 granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"),
205 user=Depends(get_admin_user),
206 db: AsyncSession = Depends(get_async_session),
207):
208 """Get message counts grouped by model for time-series chart."""
209 if granularity == 'hourly': 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 counts = await ChatMessages.get_hourly_message_counts_by_model(start_date=start_date, end_date=end_date, db=db)
211 else:
212 counts = await ChatMessages.get_daily_message_counts_by_model(
213 start_date=start_date, end_date=end_date, group_id=group_id, db=db
214 )
215 return DailyStatsResponse(
216 data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())]
217 )
220class TokenUsageEntry(BaseModel):
221 model_id: str
222 input_tokens: int
223 output_tokens: int
224 total_tokens: int
225 message_count: int
228class TokenUsageResponse(BaseModel):
229 models: list[TokenUsageEntry]
230 total_input_tokens: int
231 total_output_tokens: int
232 total_tokens: int
235@router.get('/tokens', response_model=TokenUsageResponse)
236async def get_token_usage(
237 start_date: Optional[int] = Query(None),
238 end_date: Optional[int] = Query(None),
239 group_id: Optional[str] = Query(None, description='Filter by user group ID'),
240 user=Depends(get_admin_user),
241 db: AsyncSession = Depends(get_async_session),
242):
243 """Get token usage aggregated by model."""
244 usage = await ChatMessages.get_token_usage_by_model(
245 start_date=start_date, end_date=end_date, group_id=group_id, db=db
246 )
248 models = [
249 TokenUsageEntry(model_id=model_id, **data)
250 for model_id, data in sorted(usage.items(), key=lambda x: -x[1]['total_tokens'])
251 ]
253 total_input = sum(m.input_tokens for m in models)
254 total_output = sum(m.output_tokens for m in models)
256 return TokenUsageResponse(
257 models=models,
258 total_input_tokens=total_input,
259 total_output_tokens=total_output,
260 total_tokens=total_input + total_output,
261 )
264####################
265# Model Chats Browser
266####################
269class ModelChatEntry(BaseModel):
270 chat_id: str
271 user_id: Optional[str] = None
272 user_name: Optional[str] = None
273 first_message: Optional[str] = None
274 updated_at: int
277class ModelChatsResponse(BaseModel):
278 chats: list[ModelChatEntry]
279 total: int
282MODEL_CHAT_ORDER_FIELDS = {'title', 'updated_at', 'user_name'}
285@router.get('/models/{model_id:path}/chats', response_model=ModelChatsResponse)
286async def get_model_chats(
287 model_id: str,
288 start_date: Optional[int] = Query(None),
289 end_date: Optional[int] = Query(None),
290 skip: int = Query(0),
291 limit: int = Query(50, le=100),
292 order_by: str = Query('updated_at'),
293 direction: str = Query('desc'),
294 user=Depends(get_admin_user),
295 db: AsyncSession = Depends(get_async_session),
296):
297 """Get chats that used a specific model, with preview and feedback info."""
298 filter = {}
299 if start_date:
300 filter['start_date'] = start_date
301 if end_date:
302 filter['end_date'] = end_date
303 if order_by in MODEL_CHAT_ORDER_FIELDS:
304 filter['order_by'] = order_by
305 if direction in {'asc', 'desc'}:
306 filter['direction'] = direction
308 result = await Chats.get_chats_by_model_id(
309 model_id=model_id,
310 filter=filter,
311 skip=skip,
312 limit=limit,
313 db=db,
314 )
316 return ModelChatsResponse(
317 chats=[ModelChatEntry.model_validate(chat) for chat in result['items']],
318 total=result['total'] or 0,
319 )
322####################
323# Model Overview
324####################
327class HistoryEntry(BaseModel):
328 date: str
329 won: int = 0
330 lost: int = 0
333class TagEntry(BaseModel):
334 tag: str
335 count: int
338class ModelOverviewResponse(BaseModel):
339 history: list[HistoryEntry]
340 tags: list[TagEntry]
343@router.get('/models/{model_id:path}/overview', response_model=ModelOverviewResponse)
344async def get_model_overview(
345 model_id: str,
346 days: int = Query(30, description='Number of days of history (0 for all)'),
347 user=Depends(get_admin_user),
348 db: AsyncSession = Depends(get_async_session),
349):
350 """Get model overview with feedback history and chat tags."""
352 # Calculate start date for history
353 now = datetime.now()
354 start_dt = None
355 if days > 0:
356 start_dt = now - timedelta(days=days)
358 # Get chat IDs that used this model
359 chat_ids = await ChatMessages.get_chat_ids_by_model_id(
360 model_id=model_id,
361 start_date=None,
362 end_date=None,
363 skip=0,
364 limit=10000, # Get all chats
365 db=db,
366 )
368 history_rows = await Feedbacks.get_model_feedback_counts_by_day(
369 model_id=model_id,
370 start_date=int(start_dt.timestamp()) if start_dt else None,
371 db=db,
372 )
373 history_counts = {
374 entry.date: {
375 'won': entry.won,
376 'lost': entry.lost,
377 }
378 for entry in history_rows
379 }
381 # Fill in missing days
382 history = []
383 if history_counts or days > 0:
384 end_dt = now
385 if days > 0: 385 ↛ 387line 385 didn't jump to line 387 because the condition on line 385 was always true
386 current = start_dt
387 elif history_counts:
388 # Find earliest date
389 min_date = min(history_counts.keys())
390 current = datetime.strptime(min_date, '%Y-%m-%d')
391 else:
392 current = now
394 while current <= end_dt:
395 date_str = current.strftime('%Y-%m-%d')
396 counts = history_counts.get(date_str, {'won': 0, 'lost': 0})
397 history.append(
398 HistoryEntry(
399 date=date_str,
400 won=counts['won'],
401 lost=counts['lost'],
402 )
403 )
404 current += timedelta(days=1)
406 # Get chat tags
407 tag_counts: dict[str, int] = defaultdict(int)
408 if chat_ids: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true
409 chat_metas = await Chats.get_chat_metas_by_chat_ids(
410 chat_ids,
411 include_archived=True,
412 db=db,
413 )
414 for meta in chat_metas:
415 for tag in meta.get('tags', []):
416 tag_counts[tag] += 1
418 # Sort by count and take top 10
419 tags = [TagEntry(tag=tag, count=count) for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]]
421 return ModelOverviewResponse(history=history, tags=tags)