statistic.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
  1. from datetime import datetime
  2. from decimal import Decimal
  3. import pytz
  4. import sqlalchemy as sa
  5. from flask import jsonify
  6. from flask_login import current_user
  7. from flask_restx import Resource, reqparse
  8. from controllers.console import api
  9. from controllers.console.app.wraps import get_app_model
  10. from controllers.console.wraps import account_initialization_required, setup_required
  11. from core.app.entities.app_invoke_entities import InvokeFrom
  12. from extensions.ext_database import db
  13. from extensions.ext_redis import redis_client
  14. from libs.helper import DatetimeString
  15. from libs.login import login_required
  16. from models import AppMode, Message
  17. class DailyMessageStatistic(Resource):
  18. @setup_required
  19. @login_required
  20. @account_initialization_required
  21. @get_app_model
  22. def get(self, app_model):
  23. account = current_user
  24. parser = reqparse.RequestParser()
  25. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  26. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  27. args = parser.parse_args()
  28. timezone = pytz.timezone(account.timezone)
  29. utc_timezone = pytz.utc
  30. # Check if querying "today" data
  31. is_today = False
  32. if args["start"] and args["end"]:
  33. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  34. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  35. now = datetime.now(timezone)
  36. today_date = now.date()
  37. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  38. is_today = True
  39. # Choose time truncation based on whether it's today
  40. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  41. if is_today:
  42. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  43. else:
  44. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  45. sql_query = f"""SELECT
  46. {time_trunc} AS date,
  47. COUNT(*) AS message_count
  48. FROM
  49. messages
  50. WHERE
  51. app_id = :app_id"""
  52. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  53. if args["start"]:
  54. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  55. start_datetime = start_datetime.replace(second=0)
  56. start_datetime_timezone = timezone.localize(start_datetime)
  57. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  58. sql_query += " AND created_at >= :start"
  59. arg_dict["start"] = start_datetime_utc
  60. if args["end"]:
  61. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  62. end_datetime = end_datetime.replace(second=0)
  63. end_datetime_timezone = timezone.localize(end_datetime)
  64. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  65. sql_query += " AND created_at < :end"
  66. arg_dict["end"] = end_datetime_utc
  67. sql_query += " GROUP BY date ORDER BY date"
  68. response_data = []
  69. with db.engine.begin() as conn:
  70. rs = conn.execute(sa.text(sql_query), arg_dict)
  71. for i in rs:
  72. # date is already formatted string from TO_CHAR
  73. response_data.append({"date": i.date, "message_count": i.message_count})
  74. return jsonify({"data": response_data})
  75. class DailyConversationStatistic(Resource):
  76. @setup_required
  77. @login_required
  78. @account_initialization_required
  79. @get_app_model
  80. def get(self, app_model):
  81. account = current_user
  82. parser = reqparse.RequestParser()
  83. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  84. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  85. args = parser.parse_args()
  86. timezone = pytz.timezone(account.timezone)
  87. utc_timezone = pytz.utc
  88. # Check if querying "today" data
  89. is_today = False
  90. if args["start"] and args["end"]:
  91. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  92. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  93. now = datetime.now(timezone)
  94. today_date = now.date()
  95. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  96. is_today = True
  97. # Choose time truncation based on whether it's today
  98. if is_today:
  99. # Hourly aggregation - use TO_CHAR to ensure correct string format
  100. date_column = sa.func.to_char(
  101. sa.func.date_trunc("hour", sa.text("created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz")),
  102. sa.text("'YYYY-MM-DD HH24:MI:SS'")
  103. ).label("date")
  104. else:
  105. # Daily aggregation
  106. date_column = sa.func.to_char(
  107. sa.func.date_trunc("day", sa.text("created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz")),
  108. sa.text("'YYYY-MM-DD'")
  109. ).label("date")
  110. # Count conversations based on conversation creation time (not message time)
  111. # This ensures consistency with AverageSessionInteractionStatistic
  112. from models.model import Conversation
  113. stmt = (
  114. sa.select(
  115. date_column,
  116. sa.func.count(Conversation.id).label("conversation_count"),
  117. )
  118. .select_from(Conversation)
  119. .where(Conversation.app_id == app_model.id)
  120. )
  121. if args["start"]:
  122. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  123. start_datetime = start_datetime.replace(second=0)
  124. start_datetime_timezone = timezone.localize(start_datetime)
  125. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  126. stmt = stmt.where(Conversation.created_at >= start_datetime_utc)
  127. if args["end"]:
  128. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  129. end_datetime = end_datetime.replace(second=0)
  130. end_datetime_timezone = timezone.localize(end_datetime)
  131. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  132. stmt = stmt.where(Conversation.created_at < end_datetime_utc)
  133. stmt = stmt.group_by("date").order_by("date")
  134. response_data = []
  135. with db.engine.begin() as conn:
  136. rs = conn.execute(stmt, {"tz": account.timezone})
  137. for row in rs:
  138. # date is already a formatted string from TO_CHAR
  139. response_data.append({"date": row.date, "conversation_count": row.conversation_count})
  140. return jsonify({"data": response_data})
  141. class DailyTerminalsStatistic(Resource):
  142. @setup_required
  143. @login_required
  144. @account_initialization_required
  145. @get_app_model
  146. def get(self, app_model):
  147. account = current_user
  148. parser = reqparse.RequestParser()
  149. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  150. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  151. args = parser.parse_args()
  152. timezone = pytz.timezone(account.timezone)
  153. utc_timezone = pytz.utc
  154. # Check if querying "today" data
  155. is_today = False
  156. if args["start"] and args["end"]:
  157. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  158. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  159. now = datetime.now(timezone)
  160. today_date = now.date()
  161. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  162. is_today = True
  163. # Choose time truncation based on whether it's today
  164. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  165. if is_today:
  166. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  167. else:
  168. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  169. sql_query = f"""SELECT
  170. {time_trunc} AS date,
  171. COUNT(DISTINCT messages.from_end_user_id) AS terminal_count
  172. FROM
  173. messages
  174. WHERE
  175. app_id = :app_id"""
  176. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  177. if args["start"]:
  178. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  179. start_datetime = start_datetime.replace(second=0)
  180. start_datetime_timezone = timezone.localize(start_datetime)
  181. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  182. sql_query += " AND created_at >= :start"
  183. arg_dict["start"] = start_datetime_utc
  184. if args["end"]:
  185. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  186. end_datetime = end_datetime.replace(second=0)
  187. end_datetime_timezone = timezone.localize(end_datetime)
  188. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  189. sql_query += " AND created_at < :end"
  190. arg_dict["end"] = end_datetime_utc
  191. sql_query += " GROUP BY date ORDER BY date"
  192. response_data = []
  193. with db.engine.begin() as conn:
  194. rs = conn.execute(sa.text(sql_query), arg_dict)
  195. for i in rs:
  196. # date is already formatted string from TO_CHAR
  197. response_data.append({"date": i.date, "terminal_count": i.terminal_count})
  198. return jsonify({"data": response_data})
  199. class DailyTokenCostStatistic(Resource):
  200. @setup_required
  201. @login_required
  202. @account_initialization_required
  203. @get_app_model
  204. def get(self, app_model):
  205. account = current_user
  206. parser = reqparse.RequestParser()
  207. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  208. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  209. args = parser.parse_args()
  210. timezone = pytz.timezone(account.timezone)
  211. utc_timezone = pytz.utc
  212. # Check if querying "today" data
  213. is_today = False
  214. if args["start"] and args["end"]:
  215. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  216. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  217. now = datetime.now(timezone)
  218. today_date = now.date()
  219. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  220. is_today = True
  221. # Choose time truncation based on whether it's today
  222. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  223. if is_today:
  224. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  225. else:
  226. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  227. sql_query = f"""SELECT
  228. {time_trunc} AS date,
  229. (SUM(messages.message_tokens) + SUM(messages.answer_tokens)) AS token_count,
  230. SUM(total_price) AS total_price
  231. FROM
  232. messages
  233. WHERE
  234. app_id = :app_id"""
  235. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  236. if args["start"]:
  237. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  238. start_datetime = start_datetime.replace(second=0)
  239. start_datetime_timezone = timezone.localize(start_datetime)
  240. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  241. sql_query += " AND created_at >= :start"
  242. arg_dict["start"] = start_datetime_utc
  243. if args["end"]:
  244. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  245. end_datetime = end_datetime.replace(second=0)
  246. end_datetime_timezone = timezone.localize(end_datetime)
  247. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  248. sql_query += " AND created_at < :end"
  249. arg_dict["end"] = end_datetime_utc
  250. sql_query += " GROUP BY date ORDER BY date"
  251. response_data = []
  252. with db.engine.begin() as conn:
  253. rs = conn.execute(sa.text(sql_query), arg_dict)
  254. for i in rs:
  255. # date is already formatted string from TO_CHAR
  256. response_data.append(
  257. {"date": i.date, "token_count": i.token_count, "total_price": i.total_price, "currency": "USD"}
  258. )
  259. return jsonify({"data": response_data})
  260. class AverageSessionInteractionStatistic(Resource):
  261. @setup_required
  262. @login_required
  263. @account_initialization_required
  264. @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
  265. def get(self, app_model):
  266. account = current_user
  267. parser = reqparse.RequestParser()
  268. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  269. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  270. args = parser.parse_args()
  271. timezone = pytz.timezone(account.timezone)
  272. utc_timezone = pytz.utc
  273. # Check if querying "today" data
  274. is_today = False
  275. if args["start"] and args["end"]:
  276. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  277. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  278. now = datetime.now(timezone)
  279. today_date = now.date()
  280. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  281. is_today = True
  282. # Choose time truncation based on whether it's today
  283. if is_today:
  284. time_trunc = "TO_CHAR(DATE_TRUNC('hour', c.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  285. else:
  286. time_trunc = "TO_CHAR(DATE_TRUNC('day', c.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  287. # Simplified SQL: calculate average message count per conversation created in the time period
  288. # This reflects user stickiness - average interaction depth of conversations created in this period
  289. sql_query = f"""SELECT
  290. {time_trunc} AS date,
  291. AVG(COALESCE(msg_counts.message_count, 0)) AS interactions
  292. FROM
  293. conversations c
  294. LEFT JOIN
  295. (
  296. SELECT
  297. conversation_id,
  298. COUNT(id) AS message_count
  299. FROM
  300. messages
  301. WHERE
  302. app_id = :app_id
  303. GROUP BY conversation_id
  304. ) msg_counts
  305. ON c.id = msg_counts.conversation_id
  306. WHERE
  307. c.app_id = :app_id"""
  308. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  309. if args["start"]:
  310. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  311. start_datetime = start_datetime.replace(second=0)
  312. start_datetime_timezone = timezone.localize(start_datetime)
  313. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  314. sql_query += " AND c.created_at >= :start"
  315. arg_dict["start"] = start_datetime_utc
  316. if args["end"]:
  317. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  318. end_datetime = end_datetime.replace(second=0)
  319. end_datetime_timezone = timezone.localize(end_datetime)
  320. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  321. sql_query += " AND c.created_at < :end"
  322. arg_dict["end"] = end_datetime_utc
  323. sql_query += """
  324. GROUP BY
  325. date
  326. ORDER BY
  327. date"""
  328. response_data = []
  329. with db.engine.begin() as conn:
  330. rs = conn.execute(sa.text(sql_query), arg_dict)
  331. for i in rs:
  332. # date is already formatted string from TO_CHAR
  333. response_data.append(
  334. {"date": i.date, "interactions": float(i.interactions.quantize(Decimal("0.01")))}
  335. )
  336. return jsonify({"data": response_data})
  337. class UserSatisfactionRateStatistic(Resource):
  338. @setup_required
  339. @login_required
  340. @account_initialization_required
  341. @get_app_model
  342. def get(self, app_model):
  343. account = current_user
  344. parser = reqparse.RequestParser()
  345. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  346. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  347. args = parser.parse_args()
  348. timezone = pytz.timezone(account.timezone)
  349. utc_timezone = pytz.utc
  350. # Check if querying "today" data
  351. is_today = False
  352. if args["start"] and args["end"]:
  353. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  354. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  355. now = datetime.now(timezone)
  356. today_date = now.date()
  357. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  358. is_today = True
  359. # Choose time truncation based on whether it's today
  360. if is_today:
  361. time_trunc = "TO_CHAR(DATE_TRUNC('hour', m.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  362. else:
  363. time_trunc = "TO_CHAR(DATE_TRUNC('day', m.created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  364. sql_query = f"""SELECT
  365. {time_trunc} AS date,
  366. COUNT(m.id) AS message_count,
  367. COUNT(mf.id) AS feedback_count
  368. FROM
  369. messages m
  370. LEFT JOIN
  371. message_feedbacks mf
  372. ON mf.message_id=m.id AND mf.rating='like'
  373. WHERE
  374. m.app_id = :app_id"""
  375. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  376. if args["start"]:
  377. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  378. start_datetime = start_datetime.replace(second=0)
  379. start_datetime_timezone = timezone.localize(start_datetime)
  380. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  381. sql_query += " AND m.created_at >= :start"
  382. arg_dict["start"] = start_datetime_utc
  383. if args["end"]:
  384. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  385. end_datetime = end_datetime.replace(second=0)
  386. end_datetime_timezone = timezone.localize(end_datetime)
  387. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  388. sql_query += " AND m.created_at < :end"
  389. arg_dict["end"] = end_datetime_utc
  390. sql_query += " GROUP BY date ORDER BY date"
  391. response_data = []
  392. with db.engine.begin() as conn:
  393. rs = conn.execute(sa.text(sql_query), arg_dict)
  394. for i in rs:
  395. # date is already formatted string from TO_CHAR
  396. response_data.append(
  397. {
  398. "date": i.date,
  399. "rate": round((i.feedback_count * 1000 / i.message_count) if i.message_count > 0 else 0, 2),
  400. }
  401. )
  402. return jsonify({"data": response_data})
  403. class AverageResponseTimeStatistic(Resource):
  404. @setup_required
  405. @login_required
  406. @account_initialization_required
  407. @get_app_model(mode=AppMode.COMPLETION)
  408. def get(self, app_model):
  409. account = current_user
  410. parser = reqparse.RequestParser()
  411. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  412. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  413. args = parser.parse_args()
  414. timezone = pytz.timezone(account.timezone)
  415. utc_timezone = pytz.utc
  416. # Check if querying "today" data
  417. is_today = False
  418. if args["start"] and args["end"]:
  419. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  420. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  421. now = datetime.now(timezone)
  422. today_date = now.date()
  423. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  424. is_today = True
  425. # Choose time truncation based on whether it's today
  426. if is_today:
  427. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  428. else:
  429. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  430. sql_query = f"""SELECT
  431. {time_trunc} AS date,
  432. AVG(provider_response_latency) AS latency
  433. FROM
  434. messages
  435. WHERE
  436. app_id = :app_id"""
  437. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  438. if args["start"]:
  439. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  440. start_datetime = start_datetime.replace(second=0)
  441. start_datetime_timezone = timezone.localize(start_datetime)
  442. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  443. sql_query += " AND created_at >= :start"
  444. arg_dict["start"] = start_datetime_utc
  445. if args["end"]:
  446. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  447. end_datetime = end_datetime.replace(second=0)
  448. end_datetime_timezone = timezone.localize(end_datetime)
  449. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  450. sql_query += " AND created_at < :end"
  451. arg_dict["end"] = end_datetime_utc
  452. sql_query += " GROUP BY date ORDER BY date"
  453. response_data = []
  454. with db.engine.begin() as conn:
  455. rs = conn.execute(sa.text(sql_query), arg_dict)
  456. for i in rs:
  457. # date is already formatted string from TO_CHAR
  458. response_data.append({"date": i.date, "latency": round(i.latency * 1000, 4)})
  459. return jsonify({"data": response_data})
  460. class TokensPerSecondStatistic(Resource):
  461. @setup_required
  462. @login_required
  463. @account_initialization_required
  464. @get_app_model
  465. def get(self, app_model):
  466. account = current_user
  467. parser = reqparse.RequestParser()
  468. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  469. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  470. args = parser.parse_args()
  471. timezone = pytz.timezone(account.timezone)
  472. utc_timezone = pytz.utc
  473. # Check if querying "today" data
  474. is_today = False
  475. if args["start"] and args["end"]:
  476. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  477. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  478. now = datetime.now(timezone)
  479. today_date = now.date()
  480. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  481. is_today = True
  482. # Choose time truncation based on whether it's today
  483. if is_today:
  484. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  485. else:
  486. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  487. sql_query = f"""SELECT
  488. {time_trunc} AS date,
  489. CASE
  490. WHEN SUM(provider_response_latency) = 0 THEN 0
  491. ELSE (SUM(answer_tokens) / SUM(provider_response_latency))
  492. END as tokens_per_second
  493. FROM
  494. messages
  495. WHERE
  496. app_id = :app_id"""
  497. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  498. if args["start"]:
  499. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  500. start_datetime = start_datetime.replace(second=0)
  501. start_datetime_timezone = timezone.localize(start_datetime)
  502. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  503. sql_query += " AND created_at >= :start"
  504. arg_dict["start"] = start_datetime_utc
  505. if args["end"]:
  506. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  507. end_datetime = end_datetime.replace(second=0)
  508. end_datetime_timezone = timezone.localize(end_datetime)
  509. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  510. sql_query += " AND created_at < :end"
  511. arg_dict["end"] = end_datetime_utc
  512. sql_query += " GROUP BY date ORDER BY date"
  513. response_data = []
  514. with db.engine.begin() as conn:
  515. rs = conn.execute(sa.text(sql_query), arg_dict)
  516. for i in rs:
  517. # date is already formatted string from TO_CHAR
  518. response_data.append({"date": i.date, "tps": round(i.tokens_per_second, 4)})
  519. return jsonify({"data": response_data})
  520. class ResponseTimePercentilesStatistic(Resource):
  521. """Response time percentiles (P99/P95/P50) statistic."""
  522. @setup_required
  523. @login_required
  524. @account_initialization_required
  525. @get_app_model
  526. def get(self, app_model):
  527. account = current_user
  528. parser = reqparse.RequestParser()
  529. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  530. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  531. args = parser.parse_args()
  532. timezone = pytz.timezone(account.timezone)
  533. utc_timezone = pytz.utc
  534. # Check if querying "today" data
  535. is_today = False
  536. if args["start"] and args["end"]:
  537. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  538. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  539. # If start and end are both on today, use hourly granularity
  540. now = datetime.now(timezone)
  541. today_date = now.date()
  542. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  543. is_today = True
  544. # Choose time truncation based on whether it's today
  545. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  546. if is_today:
  547. # Hourly aggregation for today
  548. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  549. else:
  550. # Daily aggregation for other periods
  551. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  552. sql_query = f"""SELECT
  553. {time_trunc} AS date,
  554. PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY provider_response_latency) AS p50,
  555. PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY provider_response_latency) AS p95,
  556. PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY provider_response_latency) AS p99,
  557. AVG(provider_response_latency) AS avg_latency
  558. FROM
  559. messages
  560. WHERE
  561. app_id = :app_id"""
  562. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  563. if args["start"]:
  564. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  565. start_datetime = start_datetime.replace(second=0)
  566. start_datetime_timezone = timezone.localize(start_datetime)
  567. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  568. sql_query += " AND created_at >= :start"
  569. arg_dict["start"] = start_datetime_utc
  570. if args["end"]:
  571. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  572. end_datetime = end_datetime.replace(second=0)
  573. end_datetime_timezone = timezone.localize(end_datetime)
  574. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  575. sql_query += " AND created_at < :end"
  576. arg_dict["end"] = end_datetime_utc
  577. sql_query += " GROUP BY date ORDER BY date"
  578. response_data = []
  579. with db.engine.begin() as conn:
  580. rs = conn.execute(sa.text(sql_query), arg_dict)
  581. for i in rs:
  582. # date is already formatted string from TO_CHAR
  583. response_data.append(
  584. {
  585. "date": i.date,
  586. "p50": round(i.p50 * 1000, 4) if i.p50 else 0,
  587. "p95": round(i.p95 * 1000, 4) if i.p95 else 0,
  588. "p99": round(i.p99 * 1000, 4) if i.p99 else 0,
  589. "avg": round(i.avg_latency * 1000, 4) if i.avg_latency else 0,
  590. }
  591. )
  592. return jsonify({"data": response_data})
  593. class TokenUsageDetailStatistic(Resource):
  594. """Token usage with input/output breakdown statistic."""
  595. @setup_required
  596. @login_required
  597. @account_initialization_required
  598. @get_app_model
  599. def get(self, app_model):
  600. account = current_user
  601. parser = reqparse.RequestParser()
  602. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  603. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  604. args = parser.parse_args()
  605. timezone = pytz.timezone(account.timezone)
  606. utc_timezone = pytz.utc
  607. # Check if querying "today" data
  608. is_today = False
  609. if args["start"] and args["end"]:
  610. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  611. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  612. # If start and end are both on today, use hourly granularity
  613. now = datetime.now(timezone)
  614. today_date = now.date()
  615. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  616. is_today = True
  617. # Choose time truncation based on whether it's today
  618. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  619. if is_today:
  620. # Hourly aggregation for today
  621. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  622. else:
  623. # Daily aggregation for other periods
  624. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  625. sql_query = f"""SELECT
  626. {time_trunc} AS date,
  627. SUM(messages.message_tokens) AS input_tokens,
  628. SUM(messages.answer_tokens) AS output_tokens,
  629. (SUM(messages.message_tokens) + SUM(messages.answer_tokens)) AS total_tokens,
  630. SUM(total_price) AS total_price
  631. FROM
  632. messages
  633. WHERE
  634. app_id = :app_id"""
  635. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  636. if args["start"]:
  637. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  638. start_datetime = start_datetime.replace(second=0)
  639. start_datetime_timezone = timezone.localize(start_datetime)
  640. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  641. sql_query += " AND created_at >= :start"
  642. arg_dict["start"] = start_datetime_utc
  643. if args["end"]:
  644. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  645. end_datetime = end_datetime.replace(second=0)
  646. end_datetime_timezone = timezone.localize(end_datetime)
  647. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  648. sql_query += " AND created_at < :end"
  649. arg_dict["end"] = end_datetime_utc
  650. sql_query += " GROUP BY date ORDER BY date"
  651. response_data = []
  652. with db.engine.begin() as conn:
  653. rs = conn.execute(sa.text(sql_query), arg_dict)
  654. for i in rs:
  655. # date is already formatted string from TO_CHAR
  656. response_data.append(
  657. {
  658. "date": i.date,
  659. "input_tokens": i.input_tokens or 0,
  660. "output_tokens": i.output_tokens or 0,
  661. "total_tokens": i.total_tokens or 0,
  662. "total_price": i.total_price,
  663. "currency": "USD",
  664. }
  665. )
  666. return jsonify({"data": response_data})
  667. class ErrorRateStatistic(Resource):
  668. """Error rate statistic."""
  669. @setup_required
  670. @login_required
  671. @account_initialization_required
  672. @get_app_model
  673. def get(self, app_model):
  674. account = current_user
  675. parser = reqparse.RequestParser()
  676. parser.add_argument("start", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  677. parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
  678. args = parser.parse_args()
  679. timezone = pytz.timezone(account.timezone)
  680. utc_timezone = pytz.utc
  681. # Check if querying "today" data
  682. is_today = False
  683. if args["start"] and args["end"]:
  684. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  685. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  686. # If start and end are both on today, use hourly granularity
  687. now = datetime.now(timezone)
  688. today_date = now.date()
  689. if start_datetime.date() == today_date and end_datetime.date() == today_date:
  690. is_today = True
  691. # Choose time truncation based on whether it's today
  692. # Use TO_CHAR with HH24 to ensure 24-hour format (not 12-hour)
  693. if is_today:
  694. # Hourly aggregation for today
  695. time_trunc = "TO_CHAR(DATE_TRUNC('hour', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD HH24:MI:SS')"
  696. else:
  697. # Daily aggregation for other periods
  698. time_trunc = "TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz), 'YYYY-MM-DD')"
  699. sql_query = f"""SELECT
  700. {time_trunc} AS date,
  701. COUNT(*) AS total_count,
  702. COUNT(CASE WHEN status != 'normal' THEN 1 END) AS error_count
  703. FROM
  704. messages
  705. WHERE
  706. app_id = :app_id"""
  707. arg_dict = {"tz": account.timezone, "app_id": app_model.id}
  708. if args["start"]:
  709. start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
  710. start_datetime = start_datetime.replace(second=0)
  711. start_datetime_timezone = timezone.localize(start_datetime)
  712. start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
  713. sql_query += " AND created_at >= :start"
  714. arg_dict["start"] = start_datetime_utc
  715. if args["end"]:
  716. end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
  717. end_datetime = end_datetime.replace(second=0)
  718. end_datetime_timezone = timezone.localize(end_datetime)
  719. end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
  720. sql_query += " AND created_at < :end"
  721. arg_dict["end"] = end_datetime_utc
  722. sql_query += " GROUP BY date ORDER BY date"
  723. response_data = []
  724. with db.engine.begin() as conn:
  725. rs = conn.execute(sa.text(sql_query), arg_dict)
  726. for i in rs:
  727. error_rate = (i.error_count * 100 / i.total_count) if i.total_count > 0 else 0
  728. # date is already formatted string from TO_CHAR
  729. response_data.append(
  730. {
  731. "date": i.date,
  732. "total_count": i.total_count,
  733. "error_count": i.error_count,
  734. "error_rate": round(error_rate, 2),
  735. }
  736. )
  737. return jsonify({"data": response_data})
  738. class ConcurrentRequestsStatistic(Resource):
  739. """Concurrent requests statistic."""
  740. @setup_required
  741. @login_required
  742. @account_initialization_required
  743. @get_app_model
  744. def get(self, app_model):
  745. """Get real-time concurrent requests count."""
  746. try:
  747. # Get active requests key pattern for this app
  748. active_requests_key = f"dify:rate_limit:{app_model.id}:active_requests"
  749. # Get count of active requests from Redis
  750. concurrent_count = 0
  751. if redis_client.exists(active_requests_key):
  752. concurrent_count = redis_client.hlen(active_requests_key)
  753. # Return current concurrent requests count
  754. return jsonify({
  755. "data": {
  756. "current_concurrent_requests": concurrent_count,
  757. "timestamp": datetime.now().isoformat(),
  758. }
  759. })
  760. except Exception as e:
  761. # If Redis is unavailable or any error occurs, return 0
  762. return jsonify({
  763. "data": {
  764. "current_concurrent_requests": 0,
  765. "timestamp": datetime.now().isoformat(),
  766. "error": str(e),
  767. }
  768. })
  769. api.add_resource(DailyMessageStatistic, "/apps/<uuid:app_id>/statistics/daily-messages")
  770. api.add_resource(DailyConversationStatistic, "/apps/<uuid:app_id>/statistics/daily-conversations")
  771. api.add_resource(DailyTerminalsStatistic, "/apps/<uuid:app_id>/statistics/daily-end-users")
  772. api.add_resource(DailyTokenCostStatistic, "/apps/<uuid:app_id>/statistics/token-costs")
  773. api.add_resource(AverageSessionInteractionStatistic, "/apps/<uuid:app_id>/statistics/average-session-interactions")
  774. api.add_resource(UserSatisfactionRateStatistic, "/apps/<uuid:app_id>/statistics/user-satisfaction-rate")
  775. api.add_resource(AverageResponseTimeStatistic, "/apps/<uuid:app_id>/statistics/average-response-time")
  776. api.add_resource(TokensPerSecondStatistic, "/apps/<uuid:app_id>/statistics/tokens-per-second")
  777. # P0 New Statistics
  778. api.add_resource(ResponseTimePercentilesStatistic, "/apps/<uuid:app_id>/statistics/response-time-percentiles")
  779. api.add_resource(TokenUsageDetailStatistic, "/apps/<uuid:app_id>/statistics/token-usage-detail")
  780. api.add_resource(ErrorRateStatistic, "/apps/<uuid:app_id>/statistics/error-rate")
  781. api.add_resource(ConcurrentRequestsStatistic, "/apps/<uuid:app_id>/statistics/concurrent-requests")