-
Notifications
You must be signed in to change notification settings - Fork 10
Discord: Automate Stat Messages #1012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e222617
db92ae6
53ba5ed
e228b43
034b501
bcc57ea
f322b37
ff8dd33
48f96b9
8a0186e
ca35534
819d149
ac11c46
bf5baff
24407b9
9724762
a4e91e8
6f9d916
c367a6d
4234ba6
b0b7833
984a12e
bf04c13
029fba8
defb7ad
5d7ef15
33e161b
34f2c66
0f11988
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import re | ||
|
|
||
| from sqlalchemy import text | ||
| from sqlmodel import Session | ||
|
|
||
| StatValue = str | int | float | ||
| StatRow = dict[str, StatValue] | ||
|
|
||
|
|
||
| LLM_CALLS = """ | ||
| SELECT | ||
| o.name AS organization, | ||
| p.name AS project, | ||
| COUNT(*) FILTER (WHERE l.inserted_at >= now() - INTERVAL '24 hours') AS calls_24h, | ||
| COUNT(*) AS calls_7d | ||
| FROM llm_call l | ||
| INNER JOIN organization o ON l.organization_id = o.id | ||
| INNER JOIN project p ON l.project_id = p.id | ||
| WHERE l.inserted_at >= now() - INTERVAL '168 hours' | ||
| AND l.deleted_at IS NULL | ||
| GROUP BY o.name, p.name | ||
| ORDER BY calls_7d DESC | ||
| """ | ||
|
|
||
| LLM_TOKENS = """ | ||
| SELECT | ||
| o.name AS organization, | ||
| p.name AS project, | ||
| l.model AS model, | ||
| COALESCE(SUM((l.usage->>'total_tokens')::INTEGER) | ||
| FILTER (WHERE l.inserted_at >= now() - INTERVAL '24 hours'), 0) AS tokens_24h, | ||
| COALESCE(SUM((l.usage->>'total_tokens')::INTEGER), 0) AS tokens_7d | ||
| FROM llm_call l | ||
| INNER JOIN organization o ON l.organization_id = o.id | ||
| INNER JOIN project p ON l.project_id = p.id | ||
| WHERE l.inserted_at >= now() - INTERVAL '168 hours' | ||
| AND l.deleted_at IS NULL | ||
| GROUP BY o.name, p.name, l.model | ||
| ORDER BY tokens_7d DESC | ||
| """ | ||
|
|
||
| LLM_MODALITY = """ | ||
| SELECT | ||
| o.name AS organization, | ||
| p.name AS project, | ||
| CASE | ||
| WHEN l.input_type = 'text' AND l.output_type = 'text' THEN 'TEXT' | ||
| WHEN l.input_type = 'audio' AND l.output_type = 'text' THEN 'STT' | ||
| WHEN l.input_type = 'text' AND l.output_type = 'audio' THEN 'TTS' | ||
| ELSE 'OTHER' | ||
| END AS modality, | ||
| COUNT(*) FILTER (WHERE l.inserted_at >= now() - INTERVAL '24 hours') AS calls_24h, | ||
| COUNT(*) AS calls_7d | ||
| FROM llm_call l | ||
| INNER JOIN organization o ON l.organization_id = o.id | ||
| INNER JOIN project p ON l.project_id = p.id | ||
| WHERE l.inserted_at >= now() - INTERVAL '168 hours' | ||
| AND l.deleted_at IS NULL | ||
| GROUP BY o.name, p.name, modality | ||
| ORDER BY o.name, p.name, modality | ||
| """ | ||
|
|
||
| JOBS = """ | ||
| SELECT | ||
| o.name AS organization, | ||
| p.name AS project, | ||
| j.job_type AS job_type, | ||
| COUNT(*) FILTER (WHERE j.inserted_at >= now() - INTERVAL '24 hours') AS jobs_24h, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we rename column to |
||
| COUNT(*) AS jobs_7d | ||
| FROM job j | ||
| INNER JOIN project p ON j.project_id = p.id | ||
| INNER JOIN organization o ON p.organization_id = o.id | ||
| WHERE j.inserted_at >= now() - INTERVAL '168 hours' | ||
| GROUP BY o.name, p.name, j.job_type | ||
| ORDER BY o.name, p.name, j.job_type | ||
| """ | ||
|
|
||
| _SIMPLE_COUNT = """ | ||
| SELECT | ||
| o.name AS organization, | ||
| p.name AS project, | ||
| COUNT(*) FILTER (WHERE t.inserted_at >= now() - INTERVAL '24 hours') AS count_24h, | ||
| COUNT(*) AS count_7d | ||
| FROM {table} t | ||
| INNER JOIN organization o ON t.organization_id = o.id | ||
| INNER JOIN project p ON t.project_id = p.id | ||
| WHERE t.inserted_at >= now() - INTERVAL '168 hours' | ||
| GROUP BY o.name, p.name | ||
| ORDER BY count_7d DESC | ||
| """ | ||
|
|
||
| SIMPLE_COUNT_TABLES = { | ||
| "Evaluation Runs": "evaluation_run", | ||
| "STT Results": "stt_result", | ||
| "TTS Results": "tts_result", | ||
| "Assessments": "assessment", | ||
| } | ||
|
|
||
| _IDENTIFIER = re.compile(r"^[a-z_][a-z0-9_]*$") | ||
|
|
||
|
|
||
| def _simple_count_sql(table: str) -> str: | ||
| if not _IDENTIFIER.match(table): | ||
| raise ValueError(f"unsafe table identifier: {table!r}") | ||
| return _SIMPLE_COUNT.format(table=table) | ||
|
|
||
|
|
||
| def _rows(session: Session, sql: str) -> list[StatRow]: | ||
| result = session.connection().execute(text(sql)) | ||
| return [dict(row) for row in result.mappings().all()] | ||
|
|
||
|
|
||
| def get_daily_stats(*, session: Session) -> dict[str, list[StatRow]]: | ||
| stats: dict[str, list[StatRow]] = { | ||
| "LLM Calls": _rows(session, LLM_CALLS), | ||
| "LLM Tokens": _rows(session, LLM_TOKENS), | ||
| "LLM Modality": _rows(session, LLM_MODALITY), | ||
| "Jobs by Type": _rows(session, JOBS), | ||
| } | ||
| for label, table in SIMPLE_COUNT_TABLES.items(): | ||
| stats[label] = _rows(session, _simple_count_sql(table)) | ||
| return stats | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please check this comment: #1012 (comment)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Prajna1999 this UI here feels a bit unstructured. can we update it and structure it more like this so that everything is presented more clearly?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. structured as in not having multiple sub-headings and sort in descending order like above ^^? Or only two columns in one go? Since we have to show stats grouped by Org/Project having less columns and make it concise we will lose information.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Ayush8923 This is done
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in this, i am thinking that right now, all the stats are being shown in a single message, which doesn’t feel like the best or most organized way to present them. Instead, i am thinking we could trigger separate messages for each stat category in different message blocks. If any stat count is 0, we can create a separate message to clearly indicate that as well.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One combined message gets the job done. trigerring 5 messages would be an overkill with no added benefit. Anyway, the stats messages would be rarely out of order to be significant, and most of the time background noise. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import logging | ||
| from datetime import date | ||
| from typing import TypedDict | ||
|
|
||
| import requests | ||
|
|
||
| from app.core.config import settings | ||
| from app.crud.stats import StatRow, StatValue | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DISCORD_EMBED_TOTAL_TEXT_LIMIT = ( | ||
| 5900 # Discord caps all embed text in a message at 6000 chars. | ||
| ) | ||
| DISCORD_EMBED_FIELD_VALUE_LIMIT = ( | ||
| 1000 # Discord caps a single field value at 1024 chars. | ||
| ) | ||
| DISCORD_EMBED_FIELD_COUNT_LIMIT = 25 # Discord embed field cap. | ||
| DISCORD_EMBED_BORDER_COLOR = 0x3B82F6 # Blue left-border accent on the Discord embed. | ||
| COLUMN_LABELS = {"24h": "Last 24hrs", "7d": "Last 7 days"} | ||
|
|
||
|
|
||
| class EmbedField(TypedDict): | ||
| name: str | ||
| value: str | ||
|
|
||
|
|
||
| class DiscordEmbed(TypedDict): | ||
| title: str | ||
| description: str | ||
| color: int | ||
| fields: list[EmbedField] | ||
|
|
||
|
|
||
| def _column_label(column: str) -> str: | ||
| suffix = column.rsplit("_", 1)[-1] | ||
| return COLUMN_LABELS.get(suffix, column) | ||
|
|
||
|
|
||
| def format_sections(stats: dict[str, list[StatRow]]) -> list[EmbedField]: | ||
| """Build one Discord embed field per stat section (name = title, value = table).""" | ||
| fields: list[EmbedField] = [] | ||
| inactive_titles: list[str] = [] | ||
| for title, rows in stats.items(): | ||
| if not rows: | ||
| inactive_titles.append(title) | ||
| continue | ||
|
|
||
| columns = list(rows[0].keys()) | ||
| numeric_cols = [ | ||
| c for c in columns if all(isinstance(row[c], (int, float)) for row in rows) | ||
| ] | ||
| text_cols = [c for c in columns if c not in numeric_cols] | ||
|
|
||
| # Repeating org/project on every row is what pushed rows past Discord's | ||
| # embed width and wrapped the trailing columns. Instead, group rows by | ||
| # every text column but the last one and print that group once as a | ||
| # bold heading (bold only works outside the code block), leaving just | ||
| # the varying column + metrics — short enough to fit — in the table. | ||
| group_cols = text_cols[:-1] | ||
| row_col = text_cols[-1] if text_cols else None | ||
| table_cols = ([row_col] if row_col else []) + numeric_cols | ||
| headers = {c: _column_label(c) for c in table_cols} | ||
|
|
||
| def cell(column: str, row: StatRow) -> str: | ||
| value = row[column] | ||
| return f"{value:,}" if column in numeric_cols else str(value) | ||
|
|
||
| groups: dict[tuple[StatValue, ...], list[StatRow]] = {} | ||
| for row in rows: | ||
| groups.setdefault(tuple(row[c] for c in group_cols), []).append(row) | ||
|
|
||
| blocks = [] | ||
| for key, group_rows in groups.items(): | ||
| # Column width is driven by the widest value, uncapped, so long | ||
| # names (e.g. model ids) are never truncated. | ||
| widths = { | ||
| c: max(len(headers[c]), max(len(cell(c, r)) for r in group_rows)) | ||
| for c in table_cols | ||
| } | ||
|
|
||
| def align(text: str, column: str) -> str: | ||
| width = widths[column] | ||
| return ( | ||
| text.rjust(width) if column in numeric_cols else text.ljust(width) | ||
| ) | ||
|
|
||
| lines = [ | ||
| " ".join(align(headers[c], c) for c in table_cols).rstrip(), | ||
| ] | ||
| for row in group_rows: | ||
| lines.append( | ||
| " ".join(align(cell(c, row), c) for c in table_cols).rstrip() | ||
| ) | ||
|
|
||
| table = "\n".join(lines) | ||
| block = f"```\n{table}\n```" | ||
| if group_cols: | ||
| block = f"**{' / '.join(str(k) for k in key)}**\n{block}" | ||
| blocks.append(block) | ||
|
|
||
| value = "\n".join(blocks) | ||
| if len(value) > DISCORD_EMBED_FIELD_VALUE_LIMIT: | ||
| cutoff = DISCORD_EMBED_FIELD_VALUE_LIMIT - len("\n…\n```") | ||
| truncated = value[:cutoff] | ||
| value = ( | ||
| f"{truncated}\n…\n```" | ||
| if truncated.count("```") % 2 == 1 | ||
| else f"{truncated}\n…" | ||
| ) | ||
| fields.append({"name": title, "value": value}) | ||
|
|
||
| if inactive_titles: | ||
| bullets = "\n".join(f"• {t}" for t in inactive_titles) | ||
| fields.append({"name": "No activity this week", "value": bullets}) | ||
| return fields | ||
|
|
||
|
|
||
| def post_to_discord(fields: list[EmbedField], *, today: date | None = None) -> None: | ||
| url = settings.DISCORD_STATS_WEBHOOK_URL | ||
| if not url: | ||
| return | ||
|
|
||
| stat_date = today or date.today() | ||
| title = f"Date: {stat_date.day}/{stat_date.month}/{stat_date.year}" | ||
| description = "Daily platform feature stats" | ||
|
|
||
| embed = _new_embed(title, description) | ||
| for field in fields: | ||
| field_cost = len(field["name"]) + len(field["value"]) | ||
| if embed["fields"] and ( | ||
| len(embed["fields"]) >= DISCORD_EMBED_FIELD_COUNT_LIMIT | ||
| or _embed_size(embed) + field_cost > DISCORD_EMBED_TOTAL_TEXT_LIMIT | ||
| ): | ||
| _post(str(url), embed) | ||
| embed = _new_embed(title, description) | ||
| embed["fields"].append(field) | ||
| if embed["fields"]: | ||
| _post(str(url), embed) | ||
|
|
||
|
|
||
| def _new_embed(title: str, description: str) -> DiscordEmbed: | ||
| return { | ||
| "title": title, | ||
| "description": description, | ||
| "color": DISCORD_EMBED_BORDER_COLOR, | ||
| "fields": [], | ||
| } | ||
|
|
||
|
|
||
| def _embed_size(embed: DiscordEmbed) -> int: | ||
| return ( | ||
| len(embed["title"]) | ||
| + len(embed["description"]) | ||
| + sum(len(f["name"]) + len(f["value"]) for f in embed["fields"]) | ||
| ) | ||
|
|
||
|
|
||
| def _post(url: str, embed: DiscordEmbed) -> None: | ||
| try: | ||
| response = requests.post(url, json={"embeds": [embed]}, timeout=5) | ||
| response.raise_for_status() | ||
| except requests.RequestException as e: | ||
| # Log only the exception type — the message can contain the webhook URL. | ||
| logger.warning(f"[_post] Webhook post failed: {type(e).__name__}") |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this, these 4 query are duplicate just only the table name change, so I think we can optimize this more:
EVALUATION_RUNSSTT_RESULTSTTS_RESULTSASSESSMENTlike as:
then create the one mapping object which is associated with their table name:
then create the one private function,
and the aggregator, to fetch the count corresponding to the table:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment is addressed here #1012 (comment)