feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(observability): instrument the capture seams — events flow end to end (#117) - #465

Merged
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture
Jul 30, 2026
Merged

feat(observability): instrument the capture seams — events flow end to end (#117)#465
AndresL230 merged 2 commits into
mainfrom
feat/b4-observability-capture

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B4 — activates the observability foundation that shipped in #375 with zero producers. Twelve domain.action events across four seams, taxonomy matched one-to-one against what routes/admin_analytics.py actually consumes (nothing emitted that no endpoint reads; nothing an endpoint reads left unemitted). Full detail in the commit message; the notable engineering points:

  • Errors: error.4xx/error.5xx from the request middleware with the exact payload contract /errors parses, plus the matched route template. Per-user attribution rides request.state (a contextvar cannot cross BaseHTTPMiddleware's task boundary). Truly unhandled crashes are captured via a catch-emit-reraise in dispatch — the implementation pass proved empirically that they never produce a response object, so the response-path seam alone (and the pre-existing comment claiming otherwise) misses them.
  • Documented deviations (each with an in-code comment): auth.login at the two real mint sites instead of "on session decode" (which fires per-request and would blow the analytics scan cap); user_id on error events via the state stamp (unlocks /usage/by-user error buckets).
  • Privacy: metadata + fingerprints only — chat messages and session topics go through content= → SHA-256 content_fp; encrypted columns (message text, note title/body, document text) never enter payloads; only paths, never query strings.
  • No wrapping at call sites: log_event is a cannot-raise sink by design; a test proves a raising logger can't break a route.

Verification

  • 28 red-first tests (test_event_capture_seams.py): per-seam emission + payload contracts, zero-events-on-2xx, state propagation, all three guard denials, fingerprint-never-raw, resilience, frozen taxonomy pin, and the unhandled-crash capture (red-verified against the pre-fix middleware).
  • Backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
  • New frontend/e2e/events.spec.ts journey proves rows flow end to end in the live stack: a student action + a 404 carrying a secret query string, then the seeded admin polls /usage/summary (event names appear) and /errors (path/method/status_code/duration_ms present; the secret asserted absent from every stored field).
  • Full local e2e cycle pre-merge; results below. This also unblocks [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121/[P3] Observability: admin analytics dashboard UI (charts/visualizations) #122 (the frontend analytics layers, which were waiting on real data).

Closes#117.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics tracking for sign-ins, document uploads, tutor sessions, chats, notes, and quizzes.
    • Added error analytics for failed requests, including status, route, duration, and request ID details.
    • Added permission-denial and session lifecycle activity tracking.
    • Added privacy-safe event reporting that excludes note content, extracted document text, secrets, and query strings.
  • Reliability

    • Event-processing failures no longer interrupt successful application actions.
    • Duplicate document uploads are excluded from usage counts.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f26c57e1-1ecf-4ab0-89ab-4d9672f46b47

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39edc and e256aa7.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts
📝 Walkthrough

Walkthrough

The PR adds metadata-only observability events across authentication, middleware errors, document processing, learning sessions, chat, quizzes, and notes, with a pinned taxonomy and backend/E2E coverage for payload privacy, failure isolation, and event delivery.

Changes

Application observability

Layer / File(s)Summary
Taxonomy and HTTP error events
backend/services/events_service.py, backend/services/request_context.py, backend/tests/test_event_capture_seams.py
Documents twelve event types and records restricted error.4xx/error.5xx payloads while preserving request IDs and normal response behavior.
Authentication and authorization audit events
backend/services/auth_guard.py, backend/routes/auth.py, backend/tests/test_event_capture_seams.py
Stores authenticated user IDs on request state and emits auth.login and auth.permission_denied audit events.
Document upload and processing events
backend/routes/documents.py, backend/tests/test_event_capture_seams.py
Emits document.upload and document.processed events across synchronous, SSE, replay, and legacy upload paths.
Learning, quiz, and note usage events
backend/routes/learn.py, backend/routes/quiz.py, backend/routes/notes.py, backend/tests/test_event_capture_seams.py
Emits session, chat, quiz, and note lifecycle events with identifiers, counts, scores, durations, and content fingerprints.
Failure isolation and analytics validation
backend/tests/test_event_capture_seams.py, frontend/e2e/events.spec.ts
Validates event sink failures, duplicate suppression, privacy constraints, and analytics visibility for note and error events.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant ApplicationRoutes
participant RequestIDMiddleware
participant events_service
participant EventsTable
Client->>ApplicationRoutes: authentication or feature request
ApplicationRoutes->>events_service: log_event(domain.action)
RequestIDMiddleware->>events_service: log_event(error.4xx/error.5xx)
events_service->>EventsTable: enqueue event row
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 27.42% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly states the observability instrumentation work and matches the main change set.
Description check✅ PassedIt gives a substantive summary, links the issue, and includes testing and reviewer notes, even though it doesn't mirror the template exactly.
Linked Issues check✅ PassedThe summary covers the requested middleware, auth, feature-route, privacy, and non-blocking instrumentation with matching seam tests.
Out of Scope Changes check✅ PassedThe frontend E2E and backend tests support the observability work and no unrelated changes stand out.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/b4-observability-capture
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b4-observability-capture

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staginge256aa7Commit Preview URL

Branch Preview URL
Jul 30 2026, 06:55 AM

AndresL230 added a commit that referenced this pull request Jul 30, 2026
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: CLAUDE.md audit clean; three findings from the reviewer fan-out, best-scored 75 (below the 80 posting bar), all fixed in the commit above — (1) legacy-fallback chat turns now emit chat.message_sent (was silently under-counting exactly during model degradation; empirically verified by two reviewers); (2) document.upload moved below the idempotency short-circuit so X-Request-ID replays don't inflate counts; (3) the 403 dual-emission (audit + error rows) documented as deliberate with the rationale the old comment contradicted. Backend 1378 passed + ruff clean. e2e cycle next.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 21/21 passed — including the new events.spec.ts journey proving rows flow from real actions through the queue worker into /usage/summary and /errors in the live stack — + oracles clean (0 findings, 1 allowlisted). Merging.

AndresL230and others added 2 commits July 29, 2026 23:52
…o end (#117)
The #375 foundation (events/llm_usage tables, queue+worker write path,
admin analytics API) shipped with zero producers: grep found no log_event
call site outside events_service itself. This activates it.
Taxonomy (12 events, domain.action, documented in events_service's
docstring + a frozen EVENT_TAXONOMY constant tests pin):
- error.4xx/error.5xx (middleware): path/method/status_code/duration_ms +
the matched route template (bounded cardinality; raw path kept, query
strings never recorded). user_id rides request.state, stamped by
get_session_user_id (the shared ASGI scope is the only channel that
propagates back out of BaseHTTPMiddleware's downstream task). No 2xx/3xx
events by design — curated domain events cover success, and per-request
rows would blow the analytics 100k scan cap.
- Unhandled crashes are captured too: a non-HTTPException propagates
THROUGH dispatch without ever producing a response object, so the
except-path emits the error.5xx (real duration, request id) and
re-raises — empirically-verified gap from the implementation pass; the
middleware comment claiming the exception handler covered it was wrong.
- auth.login at the two real session-mint sites (google_callback,
test-login) — NOT on every decode, which would fire per-request
(documented deviation from the issue's literal wording).
- auth.permission_denied at all three guard 403s (not_self / not_admin /
missing_role:<slug>); the 401 paths stay uninstrumented (the middleware
error.4xx already counts them — no double-emitting).
- document.upload / document.processed (agent paths AND the ADR-0001
legacy pipeline), quiz.started / quiz.completed (success path only,
after the atomic claim), chat.message_sent (message via content= →
fingerprint only), session.started (topic fingerprinted, never in
payload) / session.ended, note.created. Never text/titles/bodies in
payloads — metadata + SHA-256 fingerprints per the 0035 contract.
Call sites are deliberately unwrapped: log_event's body already cannot
raise; a test proves a raising logger doesn't break a route. Emitted
shapes stay byte-compatible with test_admin_analytics_routes' consumer
fixture. 28 red-first tests in test_event_capture_seams.py; new
frontend/e2e/events.spec.ts proves rows FLOW in the live stack — student
action + a 404 with a secret query string, then the seeded admin polls
/usage/summary and /errors (secret asserted absent everywhere).
Suites: backend 1376 passed + ruff clean; frontend tsc clean, 277 passed.
Closes#117.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- chat.message_sent now also emitted inside _legacy_chat — fallback turns
(agent guardrails tripped / unexpected agent failure, on BOTH chat
routes) are fully persisted turns and must count; emitting there is
exactly-once since both routes' fallback branches exit before the
main-path emissions. request_id rides request.state (reliable inside
the SSE generator where the contextvar is not). Mirrors the documents
D6 treatment; red-first test forces the fallback.
- document.upload moved BELOW the idempotency short-circuit on both
upload routes — an X-Request-ID replay of an already-persisted upload
no longer inflates the attempt count (red-first test).
- auth_guard module comment rewritten: the 403 dual emission
(auth.permission_denied audit row + the middleware's error.4xx) is
deliberate and now documented as such — the audit row carries the
denial reason and actor, which the HTTP-level row cannot know; the old
comment's double-count rationale only applies to 401s.
Backend 1378 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Re-ran the full gate at the rebased head (main took #462 mid-flight; the notes.py conflict resolved keeping both the F4 enrichment and the #117 emission): 21/21 + oracles clean again. Merging.

@AndresL230
AndresL230 merged commit 1102093 into mainJul 30, 2026
5 of 7 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/routes/auth.py (1)

569-585: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

auth.login fires even when no session token was actually minted.

auth_token is only minted if SESSION_SECRET: (lines 570-573), but the events_service.log_event("auth.login", ...) call at lines 579-584 is unconditional. When SESSION_SECRET is falsy, this records a false "login" audit event for a request that produced no auth_token and thus never actually established a session — contradicting the surrounding comment's own claim that this fires only "at the two real session-mint sites."

🐛 Proposed fix
 auth_token = ""
if SESSION_SECRET:
auth_token = mint_session(
user_id, ttl=_REDIRECT_TOKEN_TTL_SECONDS, secret=SESSION_SECRET
)
+ # `#117`: auth.login fires at the two real session-mint sites (here and+ # /test-login), NOT in auth_guard on session decode — decode runs on+ # every authenticated request, which would emit thousands of meaningless+ # "logins" per user per day and blow the analytics scan cap.+ events_service.log_event(+ "auth.login",+ category="audit",+ user_id=user_id,+ payload={"method": "google"},+ )-- # `#117`: auth.login fires at the two real session-mint sites (here and- # /test-login), NOT in auth_guard on session decode — decode runs on- # every authenticated request, which would emit thousands of meaningless- # "logins" per user per day and blow the analytics scan cap.- events_service.log_event(- "auth.login",- category="audit",- user_id=user_id,- payload={"method": "google"},- )

Note the existing test_google_callback_source_emits_auth_login tripwire only checks string presence, not placement, so it won't catch this.

🤖 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/routes/auth.py` around lines 569 - 585, Move the auth.login
events_service.log_event call inside the existing if SESSION_SECRET block after
mint_session succeeds, so the event is recorded only when auth_token is actually
minted. Preserve the current event payload and the separate /test-login
behavior.
🧹 Nitpick comments (1)
backend/services/request_context.py (1)

66-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared payload-building logic between the crash and >=400 branches.

The crash_payload/payload construction (path/method/status_code/duration_ms + route-template lookup) and the local events_service import are duplicated verbatim across the except branch and the >=400 branch. A future change to the payload shape only needs to be made once if factored into a small helper.

♻️ Proposed refactor
+def _route_template(request: Request) -> str | None:+ route = request.scope.get("route")+ return getattr(route, "path_format", None) or getattr(route, "path", None)+++def _base_error_payload(request: Request, status_code: int, dur_ms: float) -> dict:+ payload = {+ "path": request.url.path,+ "method": request.method,+ "status_code": status_code,+ "duration_ms": round(dur_ms, 1),+ }+ template = _route_template(request)+ if template:+ payload["route"] = template+ return payload

Also applies to: 118-154

🤖 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/services/request_context.py` around lines 66 - 104, Extract the
duplicated request event payload construction and local events_service import
from the exception and >=400 response branches into a shared helper within the
request dispatch flow. The helper should accept the request, status code, and
duration, preserve path/method/status_code/duration_ms fields and optional
route-template lookup, and be reused for both crash_payload and response payload
creation.
🤖 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.
Outside diff comments:
In `@backend/routes/auth.py`:
- Around line 569-585: Move the auth.login events_service.log_event call inside
the existing if SESSION_SECRET block after mint_session succeeds, so the event
is recorded only when auth_token is actually minted. Preserve the current event
payload and the separate /test-login behavior.
---
Nitpick comments:
In `@backend/services/request_context.py`:
- Around line 66-104: Extract the duplicated request event payload construction
and local events_service import from the exception and >=400 response branches
into a shared helper within the request dispatch flow. The helper should accept
the request, status code, and duration, preserve
path/method/status_code/duration_ms fields and optional route-template lookup,
and be reused for both crash_payload and response payload creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08fc5131-1805-41e5-9066-b26855e05839

📥 Commits

Reviewing files that changed from the base of the PR and between 6290d9f and 6f39edc.

📒 Files selected for processing (10)
  • backend/routes/auth.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/routes/notes.py
  • backend/routes/quiz.py
  • backend/services/auth_guard.py
  • backend/services/events_service.py
  • backend/services/request_context.py
  • backend/tests/test_event_capture_seams.py
  • frontend/e2e/events.spec.ts

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] Observability: instrument capture seams (middleware, auth, feature routes)

1 participant

@AndresL230