Discord: Automate Stat Messages - #1012
Conversation
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds daily statistics aggregation, Discord formatting and delivery, webhook configuration, a monitored protected cron endpoint, periodic invoker wiring, and service tests. ChangesDaily statistics pipeline
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CronInvoker
participant daily_stats_cron_job
participant get_daily_stats
participant format_sections
participant post_to_discord
participant Discord
CronInvoker->>daily_stats_cron_job: GET /cron/daily-stats
daily_stats_cron_job->>get_daily_stats: retrieve statistics
get_daily_stats-->>daily_stats_cron_job: categorized statistics
daily_stats_cron_job->>format_sections: format sections
format_sections-->>daily_stats_cron_job: Markdown sections
daily_stats_cron_job->>post_to_discord: post sections
post_to_discord->>Discord: POST webhook chunks
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
OpenAPI changes ⚪ No API surface changesNote This PR does not modify the API contract.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
backend/app/tests/services/test_stats.py (1)
134-134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSilence Ruff unused-argument warnings in mock helpers.
Ruff flags unused
url,timeout(andjsoninflaky_post) in the mock callables. Use*_args, **_kwargsto match therequests.postsignature without naming unused parameters.♻️ Proposed fix
- def fake_post(url, json, timeout): + def fake_post(*_args, **kwargs): - posted.append(json["content"]) + posted.append(kwargs["json"]["content"])- def flaky_post(url, json, timeout): + def flaky_post(*_args, **_kwargs): calls["n"] += 1 raise requests.ConnectionError("nope")Also applies to: 161-161
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/app/tests/services/test_stats.py` at line 134, Update the mock helpers fake_post and flaky_post in the stats tests to accept unused positional and keyword arguments via *_args and **_kwargs instead of naming unused request parameters, while preserving each helper’s existing behavior.Source: Linters/SAST tools
backend/app/crud/stats.py (1)
76-88: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winSQL identifier interpolation via f-string is a fragile pattern.
_org_count_sqlinterpolatestabledirectly into the SQL string. All current callers pass hardcoded literals, so there is no immediate injection risk, but this pattern will silently allow SQL injection if a future caller passes dynamic input. Consider adding a whitelist assertion or a comment documenting the constraint.🛡️ Proposed guard
def _org_count_sql(table: str) -> TextClause: + # table must be a hardcoded literal — never user input + assert table.isidentifier(), f"Invalid table name: {table}" return text( f"""🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/app/crud/stats.py` around lines 76 - 88, Protect the table identifier interpolated by `_org_count_sql` from future dynamic input. Add an explicit whitelist validation for the supported table names before constructing the SQL, or document and enforce that callers may only provide trusted constants; raise an appropriate error for unsupported values.backend/app/api/routes/cron.py (1)
151-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse consistent logging format.
Line 151 and 162 use f-strings while line 156 uses %-style interpolation. Prefer %-style for logging to enable lazy evaluation, or at minimum be consistent within the same function.
♻️ Suggested consistency fix (f-string → %-style)
def daily_stats_cron_job(session: SessionDep, hours: int | None = None) -> dict[str, Any]: - logger.info(f"[daily_stats_cron_job] Cron job invoked | hours={hours}") + logger.info("[daily_stats_cron_job] Cron job invoked | hours=%s", hours) try: result = collect_daily_stats(session=session, window_hours=hours) post_daily_stats_to_discord(format_daily_stats_message(result)) logger.info( "[daily_stats_cron_job] Completed | window: %s", result["window"], ) return result except Exception as e: logger.error( - f"[daily_stats_cron_job] Error executing cron job: {e}", + "[daily_stats_cron_job] Error executing cron job: %s", + e, exc_info=True, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/app/api/routes/cron.py` around lines 151 - 163, Use consistent lazy %-style logging in the daily_stats_cron_job function: replace the f-string messages in the invocation and exception logger calls with format strings and separate arguments, matching the existing completion log.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/api/routes/cron.py`:
- Line 150: Update the return annotation of daily_stats_cron_job from dict to
dict[str, Any], matching collect_daily_stats and the project’s requirement for
parameterized, specific return types; ensure Any is imported if needed.
In `@backend/app/crud/stats.py`:
- Line 76: Replace the Any annotations in _org_count_sql and _rows with
sqlalchemy.TextClause, importing TextClause as needed; keep the existing text()
SQL construction unchanged.
In `@backend/app/services/stats.py`:
- Around line 22-25: In the window calculation within the stats function,
replace the truthiness check on window_hours with an explicit None check so a
value of 0 produces a zero-duration window while only omitted values use
DAILY_WINDOW.
- Around line 120-131: Update _chunk_message to split any individual section
that exceeds _DISCORD_CHUNK_LIMIT into smaller newline-based chunks before or
during normal paragraph chunking, ensuring every returned chunk stays within the
limit. Preserve section content and ordering so post_daily_stats_to_discord can
send the complete digest without oversized Discord messages.
In `@scripts/python/invoke-cron.py`:
- Line 21: The ENDPOINTS list in invoke-cron.py incorrectly includes
/api/v1/cron/daily-stats in the shared five-minute loop, causing repeated daily
digests. Remove it from the shared list and invoke it through a separate daily
scheduler or guard it with a daily-only condition consistent with the configured
0 0 * * * schedule.
---
Nitpick comments:
In `@backend/app/api/routes/cron.py`:
- Around line 151-163: Use consistent lazy %-style logging in the
daily_stats_cron_job function: replace the f-string messages in the invocation
and exception logger calls with format strings and separate arguments, matching
the existing completion log.
In `@backend/app/crud/stats.py`:
- Around line 76-88: Protect the table identifier interpolated by
`_org_count_sql` from future dynamic input. Add an explicit whitelist validation
for the supported table names before constructing the SQL, or document and
enforce that callers may only provide trusted constants; raise an appropriate
error for unsupported values.
In `@backend/app/tests/services/test_stats.py`:
- Line 134: Update the mock helpers fake_post and flaky_post in the stats tests
to accept unused positional and keyword arguments via *_args and **_kwargs
instead of naming unused request parameters, while preserving each helper’s
existing behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 1695d50d-6e21-4a4d-848b-95ea5e43a0ae
📒 Files selected for processing (6)
backend/app/api/routes/cron.pybackend/app/core/config.pybackend/app/crud/stats.pybackend/app/services/stats.pybackend/app/tests/services/test_stats.pyscripts/python/invoke-cron.py
| ) | ||
|
|
||
|
|
||
| def _org_count_sql(table: str) -> Any: |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Narrow Any type hints to TextClause.
_org_count_sql returns -> Any and _rows accepts stmt: Any, but both can be narrowed to sqlalchemy.TextClause since text() always returns that type. As per coding guidelines, -> Any is not acceptable unless the type cannot be narrowed.
♻️ Proposed fix
-from sqlalchemy import text
+from sqlalchemy import TextClause, text
-def _org_count_sql(table: str) -> Any:
+def _org_count_sql(table: str) -> TextClause:
-def _rows(session: Session, stmt: Any, params: dict[str, Any]) -> list[dict[str, Any]]:
+def _rows(session: Session, stmt: TextClause, params: dict[str, Any]) -> list[dict[str, Any]]:Also applies to: 97-97
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/app/crud/stats.py` at line 76, Replace the Any annotations in
_org_count_sql and _rows with sqlalchemy.TextClause, importing TextClause as
needed; keep the existing text() SQL construction unchanged.
Source: Coding guidelines
| ) | ||
|
|
||
|
|
||
| def _org_count_sql(table: str) -> Any: |
There was a problem hiding this comment.
I think we can avoid the any type here. This is good suggestion given by coderabbit https://github.com/ProjectTech4DevAI/kaapi-backend/pull/1012/changes#r3558059640. Please handle it.
| def section_counts(result: dict[str, Any]) -> dict[str, int]: | ||
| return { | ||
| section: len(rows) | ||
| for section, rows in result["stats"].items() | ||
| if isinstance(rows, list) | ||
| } |
There was a problem hiding this comment.
this function is not used anywhere, if this not needed please remove this dead code.
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DAILY_WINDOW = timedelta(hours=168) |
There was a problem hiding this comment.
this timedelta(hours=168) is 7 days, not daily. as I can see this cron(here) run daily but posts a rolling 7-day window every run → ~85% of each day's report overlaps yesterday's. Intended? If yes, rename to DEFAULT_STATS_WINDOW / WEEKLY_WINDOW so the daily name doesn't mislead. If the intent was true daily deltas, then I think window should be 24h.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/api/routes/cron.py`:
- Around line 148-151: Restore the optional hours override across the
daily-stats flow: in backend/app/api/routes/cron.py lines 148-151, validate the
hours query parameter and pass it through daily_stats_cron_job to
get_daily_stats and the CRUD boundary; in backend/app/crud/stats.py lines
10-132, use named window parameters while preserving the default 168-hour
behavior and omit *_7d fields for other windows; in
backend/app/services/stats.py lines 67-74, format labels using the selected
window, defaulting to 7d.
In `@backend/app/services/stats.py`:
- Around line 77-81: Update _post to call raise_for_status() on the
requests.post response, catch failures, and log with the [_post] prefix while
recording only the exception type rather than interpolating the exception value.
Update the successful fake response mock to implement raise_for_status(), and
add coverage confirming non-success responses are handled as failures.
In `@backend/app/tests/services/test_stats.py`:
- Around line 9-67: Update _sample_stats to return a concrete mapping type
matching the fixture structure, and add explicit parameter and return
annotations to each test function in this diff. Annotate fake_post’s parameters
and return value as well, using leading-underscore names for arguments whose
values are intentionally ignored.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 8d9913fd-78fe-4981-b9fe-7468ece8d36c
📒 Files selected for processing (5)
backend/app/api/routes/cron.pybackend/app/core/config.pybackend/app/crud/stats.pybackend/app/services/stats.pybackend/app/tests/services/test_stats.py
🚧 Files skipped from review as they are similar to previous changes (1)
- backend/app/core/config.py
| def _sample_stats() -> dict: | ||
| return { | ||
| "LLM Calls": [ | ||
| { | ||
| "organization": "Acme", | ||
| "project": "Alpha", | ||
| "calls_24h": 3, | ||
| "calls_7d": 15, | ||
| }, | ||
| ], | ||
| "STT Results": [], | ||
| } | ||
|
|
||
|
|
||
| def test_format_sections_renders_bold_title_and_aligned_table(): | ||
| sections = format_sections(_sample_stats()) | ||
| llm_section = next(s for s in sections if s.startswith("**LLM Calls**")) | ||
| assert "organization project calls_24h calls_7d" in llm_section | ||
| assert "Acme Alpha 3 15" in llm_section | ||
| assert llm_section.count("```") == 2 # wrapped in one code block | ||
|
|
||
|
|
||
| def test_format_sections_marks_empty_sections(): | ||
| sections = format_sections(_sample_stats()) | ||
| stt_section = next(s for s in sections if s.startswith("**STT Results**")) | ||
| assert stt_section == "**STT Results**\n_no data_" | ||
|
|
||
|
|
||
| def test_post_to_discord_noop_when_webhook_unset(): | ||
| with patch.object(stats_mod.settings, "DISCORD_STATS_WEBHOOK_URL", None), patch( | ||
| "app.services.stats.requests.post" | ||
| ) as mock_post: | ||
| post_to_discord(["anything"]) | ||
| mock_post.assert_not_called() | ||
|
|
||
|
|
||
| def test_post_to_discord_packs_sections_under_size_limit(): | ||
| posted: list[str] = [] | ||
|
|
||
| def fake_post(url, json, timeout): | ||
| posted.append(json["content"]) | ||
|
|
||
| big_sections = ["x" * 1000 for _ in range(4)] | ||
| with patch.object( | ||
| stats_mod.settings, "DISCORD_STATS_WEBHOOK_URL", "https://x/hook" | ||
| ), patch("app.services.stats.requests.post", side_effect=fake_post): | ||
| post_to_discord(big_sections) | ||
| assert len(posted) >= 2 # split into multiple messages | ||
| assert all(len(content) <= 2000 for content in posted) | ||
|
|
||
|
|
||
| def test_post_to_discord_swallows_request_exception(): | ||
| with patch.object( | ||
| stats_mod.settings, "DISCORD_STATS_WEBHOOK_URL", "https://x/hook" | ||
| ), patch( | ||
| "app.services.stats.requests.post", | ||
| side_effect=requests.ConnectionError("boom"), | ||
| ): | ||
| post_to_discord(["hello"]) # must not raise |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n '^\s*def ' backend/app/tests/services/test_stats.pyRepository: ProjectTech4DevAI/kaapi-backend
Length of output: 538
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import ast
path = Path("backend/app/tests/services/test_stats.py")
src = path.read_text()
tree = ast.parse(src)
issues = []
for i, node in enumerate(tree.body, 1):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name == "fake_post":
return_annot = ast.unparse(node.returns) if node.returns else "<missing>"
args = [(arg.arg, ast.unparse(arg.annotation) if arg.annotation else "<missing>") for arg in node.args.args]
issues.append(f"fake_post: return={return_annot}, args={args}")
elif node.name == "_sample_stats":
if node.returns and isinstance(node.returns, ast.Name) and node.returns.id == "dict":
issues.append(f"{node.name}: generic return value dict instead of concrete type")
else:
issues.append(f"{node.name}: no return-annotation issue")
else:
if not node.returns:
issues.append(f"{node.name}: no return annotation")
print("\n".join(issues))
PYRepository: ProjectTech4DevAI/kaapi-backend
Length of output: 591
Add narrow annotations to the test helper and test functions.
_sample_stats uses the generic dict return type. All test functions and the nested fake_post stub lack parameter and return annotations. Use a concrete fixture mapping type and annotate ignored stub arguments with leading underscores.
🧰 Tools
🪛 ast-grep (0.45.0)
[info] 37-39: no timeout was given on call to external resource
Context: patch(
"app.services.stats.requests.post"
)
Note: [CWE-1088] Synchronous Access of Remote Resource without Timeout.
(requests-timeout)
[info] 53-53: no timeout was given on call to external resource
Context: patch("app.services.stats.requests.post", side_effect=fake_post)
Note: [CWE-1088] Synchronous Access of Remote Resource without Timeout.
(requests-timeout)
[info] 62-65: no timeout was given on call to external resource
Context: patch(
"app.services.stats.requests.post",
side_effect=requests.ConnectionError("boom"),
)
Note: [CWE-1088] Synchronous Access of Remote Resource without Timeout.
(requests-timeout)
🪛 Ruff (0.16.0)
[warning] 48-48: Unused function argument: url
(ARG001)
[warning] 48-48: Unused function argument: timeout
(ARG001)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/app/tests/services/test_stats.py` around lines 9 - 67, Update
_sample_stats to return a concrete mapping type matching the fixture structure,
and add explicit parameter and return annotations to each test function in this
diff. Annotate fake_post’s parameters and return value as well, using
leading-underscore names for arguments whose values are intentionally ignored.
Sources: Coding guidelines, Linters/SAST tools
Issue
Closes #825
Summary
Automates a daily platform stats digest to a Discord channel via webhook. Adds /cron/daily-stats that runs a set of per-organization SQL rollups (LLM call counts, total tokens by model, modality mix, job types, evaluation runs, STT/TTS results, assessments), formats them as compact plain-text tables, chunks past Discord's 2000-char limit, and posts fire-and-forget so a webhook outage never breaks the cron.
Query/format/post logic lives in
app/services/stats.py;the route is a thin orchestrator. Window defaults to 7 days and accepts a ?hours=N override for backfills.Checklist
Before submitting a pull request, please ensure that you mark these task.
fastapi run --reload app/main.pyordocker compose upin the repository root and test.Summary by CodeRabbit