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

1import logging 

2from collections import defaultdict 

3from datetime import datetime, timedelta 

4from typing import Optional 

5 

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 

16 

17log = logging.getLogger(__name__) 

18 

19 

20router = APIRouter() 

21 

22 

23#################### 

24# Response Models 

25#################### 

26 

27 

28class ModelAnalyticsEntry(BaseModel): 

29 model_id: str 

30 count: int 

31 unique_users: int = 0 

32 unique_chats: int = 0 

33 

34 

35class ModelAnalyticsResponse(BaseModel): 

36 models: list[ModelAnalyticsEntry] 

37 

38 

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 

47 

48 

49class UserAnalyticsResponse(BaseModel): 

50 users: list[UserAnalyticsEntry] 

51 

52 

53#################### 

54# Endpoints 

55#################### 

56 

57 

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) 

83 

84 

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 ) 

101 

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)} 

105 

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 ) 

121 

122 return UserAnalyticsResponse(users=users) 

123 

124 

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 [] 

154 

155 

156class SummaryResponse(BaseModel): 

157 total_messages: int 

158 total_chats: int 

159 total_models: int 

160 total_users: int 

161 

162 

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 ) 

181 

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 ) 

188 

189 

190class DailyStatsEntry(BaseModel): 

191 date: str 

192 models: dict[str, int] 

193 

194 

195class DailyStatsResponse(BaseModel): 

196 data: list[DailyStatsEntry] 

197 

198 

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 ) 

218 

219 

220class TokenUsageEntry(BaseModel): 

221 model_id: str 

222 input_tokens: int 

223 output_tokens: int 

224 total_tokens: int 

225 message_count: int 

226 

227 

228class TokenUsageResponse(BaseModel): 

229 models: list[TokenUsageEntry] 

230 total_input_tokens: int 

231 total_output_tokens: int 

232 total_tokens: int 

233 

234 

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 ) 

247 

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 ] 

252 

253 total_input = sum(m.input_tokens for m in models) 

254 total_output = sum(m.output_tokens for m in models) 

255 

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 ) 

262 

263 

264#################### 

265# Model Chats Browser 

266#################### 

267 

268 

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 

275 

276 

277class ModelChatsResponse(BaseModel): 

278 chats: list[ModelChatEntry] 

279 total: int 

280 

281 

282MODEL_CHAT_ORDER_FIELDS = {'title', 'updated_at', 'user_name'} 

283 

284 

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 

307 

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 ) 

315 

316 return ModelChatsResponse( 

317 chats=[ModelChatEntry.model_validate(chat) for chat in result['items']], 

318 total=result['total'] or 0, 

319 ) 

320 

321 

322#################### 

323# Model Overview 

324#################### 

325 

326 

327class HistoryEntry(BaseModel): 

328 date: str 

329 won: int = 0 

330 lost: int = 0 

331 

332 

333class TagEntry(BaseModel): 

334 tag: str 

335 count: int 

336 

337 

338class ModelOverviewResponse(BaseModel): 

339 history: list[HistoryEntry] 

340 tags: list[TagEntry] 

341 

342 

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.""" 

351 

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) 

357 

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 ) 

367 

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 } 

380 

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 

393 

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) 

405 

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 

417 

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]] 

420 

421 return ModelOverviewResponse(history=history, tags=tags)