Skip to content

Add a built-in error tracker under Monitoring - #103

Open
jhd3197 wants to merge 7 commits into
mainfrom
dev
Open

Add a built-in error tracker under Monitoring#103
jhd3197 wants to merge 7 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Until now, when the panel threw a 500 the only trace of it was a line in the Gunicorn log, and when the React tree blew up the only trace was a stack in somebody's browser console — which is to say, no trace at all. This PR gives ServerKit its own small error tracker: a new error_logs table, a service that records exceptions deduplicated by fingerprint, an admin API, and an Errors tab in the Monitoring group where the whole thing is browsable. Backend crashes are captured by hooking the existing 500 handler; frontend crashes are reported from ErrorBoundary through a dedicated ingestion endpoint. Dedup is scoped to unresolved rows on purpose — once you mark something resolved, a later recurrence opens a fresh row instead of silently ticking a counter on an issue you thought you'd closed. The whole recording path is written to swallow its own failures: an error tracker that can throw while tracking an error is worse than no error tracker, so record_error rolls back and returns None rather than ever propagating into the request that was already failing. The docs index also picked up a map of the docs/ folder and a pointer at the auto-generated OpenAPI docs, since API.md had quietly drifted from the routes it describes.

Highlights

  • A new Errors tab under Monitoring lists every uncaught backend and frontend exception, grouped so the same crash appears once with an occurrence count instead of a thousand times.
  • Backend 500s are recorded automatically — no instrumentation to add per route.
  • React crashes caught by the error boundary get reported back to the panel, including the component stack and the page the user was on.
  • Filter by source (backend/frontend) and status (resolved/unresolved), or search across messages, exception types, and endpoints.
  • A summary strip shows total, unresolved, and last-24-hour counts across the whole table, not just the page you're looking at.
  • Clicking a row opens a detail drawer with the full traceback, the captured context, first/last seen timestamps, and the fingerprint — plus resolve and delete.
  • Docs now include a map of what's in docs/ and which parts are user-facing versus internal working notes, and point at the panel's live Swagger UI at /api/v1/docs as the authoritative API reference.
Technical changes
  • New ErrorLog model (backend/app/models/error_log.py) with fingerprint, source, level, exception_type, message, traceback, endpoint, method, user_id, context_json, count, first_seen, last_seen, resolved; get_context/set_context wrap the JSON column, to_dict joins the username through the User relationship. Exported from app.models.
  • Migration 085_error_logs creates the table with indexes matching the actual access patterns — fingerprint (dedup lookup), source and resolved (list filters), last_seen (ordering). Both upgrade() and downgrade() are guarded by a _has_table inspector check so re-runs are no-ops, and index drops are individually tolerant of absence.
  • New error_log_service (backend/app/services/error_log_service.py): _fingerprint is sha256(source|exception_type|message[:200]|endpoint); record_error looks up an existing unresolved row with the same fingerprint and bumps count + last_seen, otherwise inserts. Inputs are truncated before hashing (MAX_MESSAGE_LEN 2000, MAX_TRACEBACK_LEN 10000, plus per-column caps) and an unknown source falls back to backend.
  • record_error wraps its entire body in a catch-all that rolls the session back, logs, and returns (None, False) — it is called from inside a failing request and must not add a second failure on top of the first.
  • list_errors builds a filtered, last_seen-descending query with ilike search over message/exception_type/endpoint and returns a paginated envelope (items, total, page, pages); get_stats returns total, unresolved, last-24h, and a by_source group-by.
  • New blueprint app/api/error_logs.py registered at /api/v1/error-logs in create_app. GET /, GET /stats, GET /<id>, POST /<id>/resolve, and DELETE /<id> are all @jwt_required() and @admin_required — error tracebacks are sensitive, so no non-admin read path exists. per_page is clamped to 100.
  • POST /error-logs/client is the ingestion endpoint and is deliberately not JWT-gated: it uses verify_jwt_in_request(optional=True) so an authenticated report attaches user_id, while a report from an expired or missing session still lands. Every field is type- and length-validated (_optional_str raises on non-string or oversized values) and a non-dict context is rejected with a 400.
  • That open endpoint is protected by an in-memory per-IP sliding-window limiter (CLIENT_RATE_LIMIT 10 per CLIENT_RATE_WINDOW 60s, deque of hit timestamps) returning 429. Per-process state — correct given the panel's single-worker deployment model, and worth revisiting if that ever changes.
  • The 500 handler in backend/app/__init__.py now formats the original exception's traceback, resolves get_jwt_identity() (tolerating anonymous crashes), and calls record_error. The call is itself wrapped in a bare try/except on top of the service's own guarantee, so a recording failure cannot alter the 500 response. The response body is unchanged.
  • New frontend/src/services/api/errorLogs.js mixed into ApiService, with getErrorLogs/getErrorLogStats/getErrorLog/resolveErrorLog/deleteErrorLog going through this.request. reportClientError deliberately uses raw fetch instead — it must fire with an expired or absent token, and it swallows network failures because it is called from componentDidCatch, where throwing would re-trip the boundary it is reporting.
  • ErrorBoundary.componentDidCatch fires a fire-and-forget report with error.message/error.name/error.stack, window.location.pathname, and a context carrying the component stack (sliced to 2000 chars) and user agent, wrapped so payload assembly can't throw either.
  • New frontend/src/pages/Errors.jsx following the Monitoring group's tab-page pattern: SearchField + Refresh portal into the shared PageTopbar via useTopbarActions, two SegControl quick filters in a single ListToolbar, and a server-paginated DataTable whose DataTableFooter owns the count and pager. Any filter or search change resets page to 1 so you can't strand yourself on an out-of-range page.
  • Load failures keep the last good list on screen rather than blanking the table, and a stats failure degrades to hiding the summary strip. Non-admins get an EmptyState instead of a request that would 403.
  • Row click opens a Drawer; the row already carries the full record, so the detail GET only merges in fresh count/resolved, and is guarded against resolving after the drawer has moved on to another entry. Resolve and delete live in the drawer (delete behind useConfirm), not as row actions.
  • levelKind buckets the free-form level string onto the Pill palette with a gray fallback rather than guessing at unknown severities; contextText renders an object as pretty JSON and passes a string through verbatim.
  • New frontend/src/styles/pages/_errors.scss imported from main.scss; route monitoring/errors added to App.jsx with its PAGE_TITLES entry, and an Errors tab (Bug icon) added to MONITOR_TABS between Jobs and Doctor.
  • docs/README.md gains a documentation map splitting the folder into user/operator docs, extension-author docs, and internal working docs, plus a callout that the running panel serves a route-derived OpenAPI 3.0 spec at /api/v1/docs (/api/v1/docs/openapi.json) which supersedes API.md when the two disagree.
  • VERSION bumped to 1.7.105 by the release automation.

jhd3197 and others added 7 commits August 16, 2026 18:35
…stion

- error_log_service with never-raising dedup record, list/stats/resolve/delete
- /api/v1/error-logs admin blueprint and rate-limited /client endpoint
- 500 errorhandler now records unhandled exceptions without changing the response
- 12 tests covering dedup, filters, resolution, client validation, 500 hook
ErrorBoundary now fire-and-forgets crashes to /error-logs/client via raw
fetch so reporting works with an expired token and can never throw
…l drawer

Stat strip, backend/frontend + resolution SegControls, paginated DataTable,
640px drawer with traceback/context and resolve/delete actions
Separate user docs, extension-author docs and internal working docs; note
that Swagger UI at /api/v1/docs is the source of truth over API.md
Copilot AI lite review requested due to automatic review settings August 17, 2026 12:55

Copilot AI 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.

Pull request overview

Adds a first-party, deduplicated error tracker to ServerKit, spanning backend 500s (captured from the Flask 500 handler) and frontend React crashes (reported from ErrorBoundary), with an admin-only API and a new “Errors” tab under Monitoring to browse, resolve, and delete tracked issues.

Changes:

  • Backend: new error_logs persistence (model + Alembic migration), recording/dedup service, admin browsing endpoints, and 500-handler hook to record uncaught exceptions.
  • Frontend: new Monitoring → Errors tab/page with server-side pagination + stats strip + detail drawer, plus client-side crash reporting via ErrorBoundary and a dedicated ingestion API method.
  • Docs/UI wiring: docs index updates, new styles, new route/tab registration, and version bump.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
VERSION Bumps release version to include the new feature.
frontend/src/styles/pages/_errors.scss Adds page-specific styling for the Errors list and detail drawer.
frontend/src/styles/main.scss Imports the new Errors page SCSS.
frontend/src/services/api/index.js Mixes errorLogs API methods into ApiService.
frontend/src/services/api/errorLogs.js Adds admin error-log CRUD + public-ish client ingestion request.
frontend/src/pages/Errors.jsx Implements Monitoring → Errors list page, filters, stats, and drawer actions.
frontend/src/components/monitoring/monitorTabs.jsx Adds the “Errors” tab under Monitoring.
frontend/src/components/ErrorBoundary.jsx Reports React error boundary crashes to the backend tracker.
frontend/src/App.jsx Registers the /monitoring/errors route + page title.
docs/README.md Updates API docs guidance and adds a documentation map.
backend/tests/test_error_logs.py Adds tests for service dedup, admin API, ingestion endpoint, and 500-hook.
backend/migrations/versions/085_error_logs.py Creates the new error_logs table + indexes.
backend/app/services/error_log_service.py Implements fingerprinting, record/dedup, listing, and stats.
backend/app/models/error_log.py Adds the ErrorLog model + context helpers + serialization.
backend/app/models/__init__.py Exports ErrorLog from the models package.
backend/app/api/error_logs.py Adds admin endpoints and the unauthenticated client ingestion endpoint + limiter.
backend/app/__init__.py Registers the blueprint and records errors in the 500 handler.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +46 to +51
try {
await fetch(`${this.baseUrl}/error-logs/client`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
Comment on lines +75 to +80
const load = useCallback(async () => {
try {
const params = { page, per_page: PAGE_SIZE };
if (source !== 'all') params.source = source;
if (status !== 'all') params.resolved = status === 'resolved';
if (q) params.search = q;
Comment on lines +46 to +56
resolved_arg = request.args.get('resolved')
resolved = None
if resolved_arg is not None:
resolved = resolved_arg.lower() in ('1', 'true', 'yes')
per_page = min(request.args.get('per_page', 25, type=int), 100)
result = error_log_service.list_errors(
source=request.args.get('source'),
resolved=resolved,
search=request.args.get('search'),
page=request.args.get('page', 1, type=int),
per_page=per_page,
Comment on lines +31 to +45
sa.Column('fingerprint', sa.String(length=64), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('level', sa.String(length=20), nullable=False),
sa.Column('exception_type', sa.String(length=200), nullable=True),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('traceback', sa.Text(), nullable=True),
sa.Column('endpoint', sa.String(length=255), nullable=True),
sa.Column('method', sa.String(length=10), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('context_json', sa.Text(), nullable=True),
sa.Column('count', sa.Integer(), nullable=False),
sa.Column('first_seen', sa.DateTime(), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.Column('resolved', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
Comment on lines +52 to +56
fp = _fingerprint(source, exception_type, message, endpoint)
existing = ErrorLog.query.filter_by(fingerprint=fp, resolved=False).first()
if existing:
existing.count = (existing.count or 1) + 1
existing.last_seen = datetime.utcnow()
Sign up for free to 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.

2 participants