-
Notifications
You must be signed in to change notification settings - Fork 0
Add observability dashboards and alerting config #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Literal | ||
|
|
||
| from fastapi import APIRouter, HTTPException, status | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| from api.metrics import observe_lcp | ||
|
|
||
| router = APIRouter(prefix="/observability", tags=["observability"]) | ||
|
|
||
|
|
||
| class WebVitalPayload(BaseModel): | ||
| name: Literal["LCP", "FCP", "CLS", "FID", "INP", "TTFB"] = Field( | ||
| ..., description="Name of the reported Web Vital metric" | ||
| ) | ||
| value: float = Field(..., description="Value of the metric as reported by web-vitals") | ||
| app: str = Field("frontend", description="Logical application label for the metric") | ||
| id: str | None = Field(None, description="Unique identifier assigned by the web-vitals reporter") | ||
|
|
||
|
|
||
| @router.post("/web-vitals", status_code=status.HTTP_202_ACCEPTED) | ||
| async def ingest_web_vitals(payload: WebVitalPayload) -> dict[str, bool]: | ||
| """Ingest Web Vital measurements emitted by the frontend.""" | ||
|
|
||
| if payload.name != "LCP": | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Only LCP metrics are supported at this time", | ||
| ) | ||
|
|
||
| # web-vitals reports timings in milliseconds; convert to seconds for Prometheus | ||
| seconds = payload.value / 1000.0 | ||
| observe_lcp(app=payload.app, seconds=seconds) | ||
| return {"ok": True} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from fastapi.testclient import TestClient | ||
| from prometheus_client import REGISTRY | ||
|
|
||
| from api.main import app | ||
|
|
||
| client = TestClient(app) | ||
|
|
||
|
|
||
| def _get_sample(name: str, labels: dict[str, str]) -> float: | ||
| value = REGISTRY.get_sample_value(name, labels) | ||
| return float(value) if value is not None else 0.0 | ||
|
|
||
|
|
||
| def test_lcp_ingest_records_histogram() -> None: | ||
| before = _get_sample("web_vitals_lcp_count", {"app": "frontend"}) | ||
|
|
||
| response = client.post( | ||
| "/observability/web-vitals", | ||
| json={"name": "LCP", "value": 2400, "app": "frontend"}, | ||
| ) | ||
|
|
||
| assert response.status_code == 202 | ||
| after = _get_sample("web_vitals_lcp_count", {"app": "frontend"}) | ||
| assert after == before + 1 | ||
|
|
||
|
|
||
| def test_lcp_ingest_rejects_unsupported_metrics() -> None: | ||
| response = client.post( | ||
| "/observability/web-vitals", | ||
| json={"name": "CLS", "value": 0.04}, | ||
| ) | ||
|
|
||
| assert response.status_code == 400 | ||
| payload = response.json() | ||
| assert payload["detail"] == "Only LCP metrics are supported at this time" | ||
|
|
||
|
|
||
| def test_http_metrics_recorded_for_requests() -> None: | ||
| route_labels = { | ||
| "service": "backend", | ||
| "route": "/healthcheck", | ||
| "method": "GET", | ||
| "status": "200", | ||
| } | ||
| before = _get_sample("http_requests_total", route_labels) | ||
|
|
||
| resp = client.get("/healthcheck") | ||
|
|
||
| assert resp.status_code == 200 | ||
| after = _get_sample("http_requests_total", route_labels) | ||
| assert after == before + 1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| apiVersion: 1 | ||
| providers: | ||
| - name: Observability Dashboards | ||
| orgId: 1 | ||
| folder: Platform Observability | ||
| type: file | ||
| disableDeletion: false | ||
| allowUiUpdates: true | ||
| updateIntervalSeconds: 30 | ||
| options: | ||
| path: ops/grafana/provisioning/dashboards | ||
| foldersFromFilesStructure: false |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,55 @@ | ||||||||||||||||||||||||||||||||||||
| import type { Metric } from 'next/app'; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const DEFAULT_ENDPOINT = '/observability/web-vitals'; | ||||||||||||||||||||||||||||||||||||
| const APP_LABEL = process.env.NEXT_PUBLIC_WEB_VITALS_APP ?? 'frontend'; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function buildEndpoint(): string { | ||||||||||||||||||||||||||||||||||||
| if (typeof window === 'undefined') { | ||||||||||||||||||||||||||||||||||||
| return DEFAULT_ENDPOINT; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const protocol = process.env.NEXT_PUBLIC_BACKEND_PROTOCOL ?? window.location.protocol ?? 'http:'; | ||||||||||||||||||||||||||||||||||||
| const host = process.env.NEXT_PUBLIC_BACKEND_HOST ?? window.location.hostname; | ||||||||||||||||||||||||||||||||||||
| const port = process.env.NEXT_PUBLIC_BACKEND_PORT ?? ''; | ||||||||||||||||||||||||||||||||||||
| const endpoint = process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ?? DEFAULT_ENDPOINT; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const portSegment = port ? `:${port}` : ''; | ||||||||||||||||||||||||||||||||||||
| return `${protocol}//${host}${portSegment}${endpoint}`; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+18
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normalize protocol when building the reporting URL. If Apply this diff to normalize the protocol before interpolation: - const protocol = process.env.NEXT_PUBLIC_BACKEND_PROTOCOL ?? window.location.protocol ?? 'http:';
+ const protocol = process.env.NEXT_PUBLIC_BACKEND_PROTOCOL ?? window.location.protocol ?? 'http:';
+ const normalizedProtocol = protocol.endsWith(':') ? protocol : `${protocol}:`;
@@
- return `${protocol}//${host}${portSegment}${endpoint}`;
+ return `${normalizedProtocol}//${host}${portSegment}${endpoint}`;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function sendMetric(endpoint: string, body: string) { | ||||||||||||||||||||||||||||||||||||
| if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) { | ||||||||||||||||||||||||||||||||||||
| const blob = new Blob([body], { type: 'application/json' }); | ||||||||||||||||||||||||||||||||||||
| const success = navigator.sendBeacon(endpoint, blob); | ||||||||||||||||||||||||||||||||||||
| if (success) { | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| fetch(endpoint, { | ||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||
| body, | ||||||||||||||||||||||||||||||||||||
| keepalive: true, | ||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| }).catch(() => { | ||||||||||||||||||||||||||||||||||||
| // Swallow network errors; metrics reporting should be fire-and-forget | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export function reportWebVitals(metric: Metric) { | ||||||||||||||||||||||||||||||||||||
| if (metric.name !== 'LCP') { | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const endpoint = buildEndpoint(); | ||||||||||||||||||||||||||||||||||||
| const body = JSON.stringify({ | ||||||||||||||||||||||||||||||||||||
| name: metric.name, | ||||||||||||||||||||||||||||||||||||
| value: metric.value, | ||||||||||||||||||||||||||||||||||||
| app: APP_LABEL, | ||||||||||||||||||||||||||||||||||||
| id: metric.id, | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| sendMetric(endpoint, body); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Record the real status code for HTTPException responses.
call_nextraisesHTTPExceptionfor anticipated errors (e.g. 404, 422), but our broadexcept Exceptionblock records every one of them as status500. That skews the metrics for client errors and validation failures. HandleHTTPExceptionseparately (use itsstatus_code) before catching generic exceptions.Apply this diff to record accurate status codes:
📝 Committable suggestion
🤖 Prompt for AI Agents