Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ SHOP_TIMEZONE=Europe/Berlin
# ingest endpoint returns 404.
STOREFRONT_ANALYTICS_ENABLED=false

# Accept uncaught error reports from the frontends. Off by default, for the same
# reason. While false the report endpoint returns 404.
FRONTEND_ERRORS_ENABLED=false

# ----------------------------------
# OpenTelemetry
# ----------------------------------
Expand Down
75 changes: 75 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,78 @@ which looks like a working panel. That mistake is already made and fixed here.
output and Prometheus, not against the fact that setup was called. That
distinction caught the real bug: instrumenting the app before configuring
telemetry logged a clean start and produced no HTTP metrics at all.

---

# Frontend Errors

Uncaught errors from the storefront and the admin UI.

Traces and metrics see nothing that happens in a browser. A component that
throws leaves the server returning 200 with healthy metrics while the shop is
broken for a real customer — and for a shop, by the time someone reports it the
sale is gone.

```
POST /v1/telemetry/errors report (public, rate limited, opt-in)
GET /v1/admin/telemetry/errors read, grouped (admin)
```

## Public, and treated as such

Storefront visitors are not signed in, and an error that happens before login is
exactly the one worth catching — so the report endpoint takes no token. It is
therefore hostile-input territory:

| Guard | Why |
|---|---|
| 30 requests/minute per address | A component throwing in a render loop reports as fast as the browser can loop |
| 10 errors per batch | One request cannot be a bulk insert |
| Closed `app` vocabulary | The field cannot become free text |
| `extra="forbid"` | Unknown fields are refused, not ignored |
| Stack truncated at 4000 chars | Unbounded input, and the top frames are where the fault is |
| Off unless `FRONTEND_ERRORS_ENABLED` | Returns 404 while off |

## The user agent is reduced, never stored

A raw user-agent string is a fingerprint. But "which browser?" is genuinely
diagnostic — a large share of frontend bugs are one engine behaving differently
— so discarding it entirely makes the reports much weaker.

The compromise: reduce it at the boundary to a family and major version, and
store only that. `Safari 18` reproduces a bug; it does not recognise anyone.

The reduction is also a filter. Whatever a client sends, the output is a known
family name and an integer — never a fragment of the input:

```
"Mozilla/5.0 Chrome/140 user=alice@example.com token=abc123" → "Chrome 140"
```

There is no column for an IP address, an email or a customer id, and a test
fails if one appears.

## Errors are grouped

One bug produces thousands of identical rows, so the read endpoint groups by
application, error class and message, ordered by frequency.

Deliberately **not** grouped by stack: the same fault reached from two routes
produces two different stacks and is one bug. `affected_paths` shows the spread
instead, and one representative stack is returned for debugging.

## What this cannot tell you

It reports only what browsers managed to send. An error that breaks a page badly
enough to stop the reporter is precisely the one that will not appear — so a
quiet report is weaker evidence than a noisy one. Read silence as "no news",
never as "no errors".

## Testing

- `tests/test_frontend_errors_unit.py` — the user-agent reduction, including
Edge not being reported as Chrome, and that nothing from the input string
survives it.
- `tests/test_frontend_errors_integration.py` — reporting without auth, the
PII-column assertion, the raw agent never reaching storage, query-string
stripping, stale timestamps, and grouping across routes.
5 changes: 5 additions & 0 deletions src/app/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
# Returns (RMA)
from app.services.returns.models.returns_db_models import ReturnDB # noqa: F401

# Frontend errors (S4)
from app.services.frontend_errors.models.frontend_errors_db_models import ( # noqa: F401
FrontendErrorDB,
)

# Storefront analytics (S2)
from app.services.storefront_analytics.models.storefront_events_db_models import ( # noqa: F401
StorefrontEventDB,
Expand Down
8 changes: 8 additions & 0 deletions src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
from app.services.crud_item_store import router as item_store_router
from app.services.customers import customers_api_router
from app.services.fulfillment import fulfillment_api_router
from app.services.frontend_errors import (
admin_frontend_errors_api_router,
frontend_errors_api_router,
)
from app.services.health import health_api_router
from app.services.inventory import inventory_api_router
from app.services.mail import mail_api_router
Expand Down Expand Up @@ -170,6 +174,10 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes
# Include analytics service router (S1)
app.include_router(analytics_api_router, prefix="/v1")

# Include frontend error reporting (S4) — public ingest plus admin read
app.include_router(frontend_errors_api_router, prefix="/v1")
app.include_router(admin_frontend_errors_api_router, prefix="/v1")

# Include storefront analytics ingest (S2) — public, rate limited, opt-in
app.include_router(storefront_analytics_api_router, prefix="/v1")

Expand Down
35 changes: 35 additions & 0 deletions src/app/services/frontend_errors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Frontend Errors Service

Uncaught browser errors from the storefront and the admin UI.

S3 gave the API traces and metrics; neither can see a component that throws in
somebody's browser. The server returns 200, the metrics look healthy, and the
shop is broken for a real customer.

Endpoints:
POST /telemetry/errors — report (public, rate limited, opt-in)
GET /admin/telemetry/errors — read, grouped (admin)
"""

from fastapi import APIRouter

from app.authorize import require_admin
from fastapi import Depends

from .routers import admin_router, report_router

frontend_errors_api_router = APIRouter(
prefix="/telemetry",
tags=["Frontend Errors"],
)
frontend_errors_api_router.include_router(report_router)

admin_frontend_errors_api_router = APIRouter(
prefix="/admin/telemetry",
tags=["Frontend Errors"],
dependencies=[Depends(require_admin)],
)
admin_frontend_errors_api_router.include_router(admin_router)

__all__ = ["admin_frontend_errors_api_router", "frontend_errors_api_router"]
5 changes: 5 additions & 0 deletions src/app/services/frontend_errors/functions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Frontend error helpers."""

from .user_agent import coarse_browser

__all__ = ["coarse_browser"]
49 changes: 49 additions & 0 deletions src/app/services/frontend_errors/functions/user_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Coarse browser identification.

A raw user-agent string is a fingerprint: combined with a handful of other
signals it identifies a device, which is exactly what the rest of this system
refuses to collect. But "which browser?" is genuinely diagnostic — half of all
frontend bugs are one engine behaving differently — so throwing it away entirely
makes the error reports much less useful.

The compromise is to reduce the string to a family and a major version at the
boundary and store only that. "Safari 18" is enough to reproduce a bug and not
enough to recognise anyone.
"""

from __future__ import annotations

import re

_UNKNOWN = "unknown"

# Order matters. Edge and Opera both claim to be Chrome, and Chrome claims to be
# Safari, so the most specific pattern has to win.
_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("Edge", re.compile(r"Edg(?:e|A|iOS)?/(\d+)")),
("Opera", re.compile(r"OPR/(\d+)")),
("Samsung Internet", re.compile(r"SamsungBrowser/(\d+)")),
("Firefox", re.compile(r"Firefox/(\d+)")),
("Chrome", re.compile(r"Chrome/(\d+)")),
("Safari", re.compile(r"Version/(\d+).*Safari")),
)


def coarse_browser(user_agent: str | None) -> str:
"""
Reduce a user-agent string to "Family Major", or "unknown".

Never returns anything derived from the original string beyond the family
name and an integer, so no amount of unusual input can smuggle a fingerprint
through.
"""
if not user_agent:
return _UNKNOWN

for family, pattern in _PATTERNS:
match = pattern.search(user_agent)
if match:
return f"{family} {match.group(1)}"

return _UNKNOWN
25 changes: 25 additions & 0 deletions src/app/services/frontend_errors/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Frontend error models."""

from .frontend_errors_db_models import FrontendErrorDB
from .frontend_errors_models import (
MAX_ERRORS_PER_BATCH,
MAX_STACK_CHARS,
ErrorGroup,
FrontendApp,
FrontendErrorBatch,
FrontendErrorIngestResponse,
FrontendErrorInput,
FrontendErrorsResponse,
)

__all__ = [
"MAX_ERRORS_PER_BATCH",
"MAX_STACK_CHARS",
"ErrorGroup",
"FrontendApp",
"FrontendErrorBatch",
"FrontendErrorDB",
"FrontendErrorIngestResponse",
"FrontendErrorInput",
"FrontendErrorsResponse",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Frontend Error Database Model

One row per reported uncaught error from a browser.

As with storefront_events, what is absent matters: no IP address, no raw user
agent, no customer id, no email. `browser` holds a coarse family and major
version parsed server-side — enough to reproduce a bug, not enough to recognise
anyone.
"""

from datetime import datetime
from uuid import UUID, uuid4

from sqlalchemy import DateTime, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column

from app.shared.database.base import Base


class FrontendErrorDB(Base):
"""An uncaught error reported by one of the frontends."""

__tablename__ = "frontend_errors"

id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
primary_key=True,
default=uuid4,
server_default=text("gen_random_uuid()"),
)

app: Mapped[str] = mapped_column(
String(20), nullable=False, doc="storefront | admin"
)
name: Mapped[str] = mapped_column(
String(120), nullable=False, doc="Error class, e.g. TypeError"
)
message: Mapped[str] = mapped_column(String(500), nullable=False)
stack: Mapped[str | None] = mapped_column(
Text, nullable=True, doc="Truncated at ingest — a full trace is unbounded input"
)
path: Mapped[str | None] = mapped_column(
String(255), nullable=True, doc="Route only; query strings are stripped"
)
browser: Mapped[str] = mapped_column(
String(60),
nullable=False,
server_default=text("'unknown'"),
doc="Coarse family and major version. Never the raw user agent.",
)

occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=text("now()")
)

__table_args__ = (
Index("ix_frontend_errors_occurred_at", "occurred_at"),
Index("ix_frontend_errors_app_occurred", "app", "occurred_at"),
# Grouping is the only way anyone reads this table: one bug in a render
# loop produces thousands of identical rows, and the useful question is
# "which distinct errors, how often".
Index("ix_frontend_errors_grouping", "app", "name", "message"),
)

def __repr__(self) -> str:
return f"FrontendErrorDB(id={self.id}, app={self.app!r}, name={self.name!r})"
Loading
Loading