From 4d3b91af3013d5a08bffc6f1feb01ef9014d5eff Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 5 Apr 2026 12:58:16 +0500 Subject: [PATCH] feat: add Grafana Faro RUM, fix dashboards, add Grafana to admin nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @grafana/faro-web-sdk to UI; wire initializeFaro in hooks.client.ts gated on PUBLIC_FARO_COLLECTOR_URL (no-op in dev) - Add Grafana Alloy service (faro.receiver) to homelab compose; Faro endpoint → alloy:12347 (faro.libnovel.cc via cloudflared) - Add PUBLIC_FARO_COLLECTOR_URL env var to docker-compose.yml UI service - Add Web Vitals dashboard (web-vitals.json): LCP/INP/CLS/TTFB/FCP p75 stats + LCP/TTFB time-series + Faro exception logs from Loki - Fix runner.json: strip libnovel_ prefix from all metric names - Fix backend.json: replace 5 dead http_client_* panels with spanmetrics-based equivalents (Request Rate by Span Name + Latency by Span Name p95) - Fix OTel collector: add service.telemetry.metrics.address: 0.0.0.0:8888 so Prometheus can scrape collector self-metrics - Add Grafana link to admin nav external tools; add admin_nav_grafana message key to all 5 locale files; recompile paraglide --- docker-compose.yml | 2 + homelab/docker-compose.yml | 18 ++ homelab/otel/alloy.river | 43 +++ homelab/otel/collector.yaml | 3 + .../provisioning/dashboards/backend.json | 69 ++--- .../provisioning/dashboards/runner.json | 16 +- .../provisioning/dashboards/web-vitals.json | 284 ++++++++++++++++++ ui/messages/en.json | 1 + ui/messages/fr.json | 3 +- ui/messages/id.json | 3 +- ui/messages/pt.json | 3 +- ui/messages/ru.json | 3 +- ui/package-lock.json | 142 +++++++++ ui/package.json | 1 + ui/src/hooks.client.ts | 18 ++ ui/src/lib/paraglide/messages/_index.js | 1 + .../paraglide/messages/admin_nav_grafana.js | 44 +++ ui/src/routes/admin/+layout.svelte | 1 + 18 files changed, 593 insertions(+), 62 deletions(-) create mode 100644 homelab/otel/alloy.river create mode 100644 homelab/otel/grafana/provisioning/dashboards/web-vitals.json create mode 100644 ui/src/lib/paraglide/messages/admin_nav_grafana.js diff --git a/docker-compose.yml b/docker-compose.yml index f99c4bc..96cb47a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -302,6 +302,8 @@ services: PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}" # GlitchTip client + server-side error tracking PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}" + # Grafana Faro RUM (browser Web Vitals, traces, errors) + PUBLIC_FARO_COLLECTOR_URL: "${PUBLIC_FARO_COLLECTOR_URL}" # OpenTelemetry tracing OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}" OTEL_SERVICE_NAME: "ui" diff --git a/homelab/docker-compose.yml b/homelab/docker-compose.yml index ee275b0..8c4c5d0 100644 --- a/homelab/docker-compose.yml +++ b/homelab/docker-compose.yml @@ -18,6 +18,7 @@ # uptime.libnovel.cc → uptime-kuma:3001 # push.libnovel.cc → gotify:80 # grafana.libnovel.cc → grafana:3000 +# faro.libnovel.cc → alloy:12347 services: @@ -346,6 +347,23 @@ services: timeout: 5s retries: 5 + # ── Grafana Alloy (Faro RUM receiver) ─────────────────────────────────────── + # Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces, + # errors). Exposes POST /collect at faro.libnovel.cc via cloudflared. + # Forwards traces to otel-collector (→ Tempo) and logs to Loki directly. + alloy: + image: grafana/alloy:latest + restart: unless-stopped + command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"] + volumes: + - ./otel/alloy.river:/etc/alloy/alloy.river:ro + expose: + - "12347" # Faro HTTP receiver (POST /collect) + - "12348" # Alloy UI / health endpoint + depends_on: + - otel-collector + - loki + # ── OTel Collector ────────────────────────────────────────────────────────── # Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki. otel-collector: diff --git a/homelab/otel/alloy.river b/homelab/otel/alloy.river new file mode 100644 index 0000000..e068070 --- /dev/null +++ b/homelab/otel/alloy.river @@ -0,0 +1,43 @@ +// Grafana Alloy — Faro RUM receiver +// +// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the +// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk. +// +// Pipeline: +// faro.receiver → receives HTTP POST /collect from browsers +// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo +// loki.write → forwards logs/exceptions to Loki +// +// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared. +// CORS is configured to allow requests from libnovel.cc. + +faro.receiver "faro" { + server { + listen_address = "0.0.0.0" + listen_port = 12347 + + cors_allowed_origins = ["https://libnovel.cc", "https://www.libnovel.cc"] + } + + output { + logs = [loki.write.faro.receiver] + traces = [otelcol.exporter.otlphttp.faro.input] + } +} + +// Forward Faro traces to the OTel Collector (which routes to Tempo) +otelcol.exporter.otlphttp "faro" { + client { + endpoint = "http://otel-collector:4318" + tls { + insecure = true + } + } +} + +// Forward Faro logs/exceptions directly to Loki +loki.write "faro" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} diff --git a/homelab/otel/collector.yaml b/homelab/otel/collector.yaml index 521c71c..7ae0b9a 100644 --- a/homelab/otel/collector.yaml +++ b/homelab/otel/collector.yaml @@ -53,6 +53,9 @@ extensions: service: extensions: [health_check, pprof] + telemetry: + metrics: + address: 0.0.0.0:8888 pipelines: traces: receivers: [otlp] diff --git a/homelab/otel/grafana/provisioning/dashboards/backend.json b/homelab/otel/grafana/provisioning/dashboards/backend.json index 50c56b6..54da685 100644 --- a/homelab/otel/grafana/provisioning/dashboards/backend.json +++ b/homelab/otel/grafana/provisioning/dashboards/backend.json @@ -1,7 +1,7 @@ { "uid": "libnovel-backend", "title": "Backend API", - "description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.", + "description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics.", "tags": ["libnovel", "backend", "api"], "timezone": "browser", "refresh": "30s", @@ -173,7 +173,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60", + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60", "legendFormat": "5xx/min", "instant": true } @@ -182,7 +182,7 @@ { "id": 10, "type": "timeseries", - "title": "Request Rate by Status", + "title": "Request Rate (total vs errors)", "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, "options": { "tooltip": { "mode": "multi" }, @@ -191,27 +191,21 @@ "fieldConfig": { "defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [ - { "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + { "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } ] }, "targets": [ { - "refId": "success", + "refId": "total", "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))", - "legendFormat": "2xx" - }, - { - "refId": "notfound", - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))", - "legendFormat": "4xx" + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))", + "legendFormat": "total" }, { "refId": "errors", "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))", - "legendFormat": "5xx" + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m]))", + "legendFormat": "errors" } ] }, @@ -248,50 +242,30 @@ { "id": 12, "type": "timeseries", - "title": "Requests / min by HTTP method (UI → Backend)", + "title": "Request Rate by Span Name (top operations)", "gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 }, + "description": "Throughput broken down by HTTP route / span name from Tempo span metrics.", "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { - "defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } } + "defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 5 } } }, "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60", - "legendFormat": "{{http_request_method}}" + "expr": "topk(10, sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])) by (span_name))", + "legendFormat": "{{span_name}}" } ] }, { "id": 13, "type": "timeseries", - "title": "Requests / min — UI → PocketBase", + "title": "Latency by Span Name (p95)", "gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 }, - "description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).", - "options": { - "tooltip": { "mode": "multi" }, - "legend": { "displayMode": "list", "placement": "bottom" } - }, - "fieldConfig": { - "defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60", - "legendFormat": "{{http_request_method}} {{http_response_status_code}}" - } - ] - }, - { - "id": 14, - "type": "timeseries", - "title": "UI → Backend Latency (p50 / p95)", - "gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 }, - "description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.", + "description": "p95 latency per operation — helps identify slow endpoints.", "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } @@ -302,13 +276,8 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))", - "legendFormat": "p50" - }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))", - "legendFormat": "p95" + "expr": "topk(10, histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le, span_name)))", + "legendFormat": "{{span_name}}" } ] }, @@ -316,7 +285,7 @@ "id": 20, "type": "logs", "title": "Backend Errors", - "gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 }, + "gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 }, "options": { "showTime": true, "showLabels": false, diff --git a/homelab/otel/grafana/provisioning/dashboards/runner.json b/homelab/otel/grafana/provisioning/dashboards/runner.json index 79d94c0..5a9f125 100644 --- a/homelab/otel/grafana/provisioning/dashboards/runner.json +++ b/homelab/otel/grafana/provisioning/dashboards/runner.json @@ -35,7 +35,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_tasks_running", + "expr": "runner_tasks_running", "legendFormat": "running", "instant": true } @@ -61,7 +61,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_tasks_completed_total", + "expr": "runner_tasks_completed_total", "legendFormat": "completed", "instant": true } @@ -93,7 +93,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_tasks_failed_total", + "expr": "runner_tasks_failed_total", "legendFormat": "failed", "instant": true } @@ -126,7 +126,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_uptime_seconds", + "expr": "runner_uptime_seconds", "legendFormat": "uptime", "instant": true } @@ -159,7 +159,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)", + "expr": "runner_tasks_failed_total / clamp_min(runner_tasks_completed_total + runner_tasks_failed_total, 1)", "legendFormat": "failure rate", "instant": true } @@ -215,17 +215,17 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60", + "expr": "rate(runner_tasks_completed_total[5m]) * 60", "legendFormat": "completed" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60", + "expr": "rate(runner_tasks_failed_total[5m]) * 60", "legendFormat": "failed" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "libnovel_runner_tasks_running", + "expr": "runner_tasks_running", "legendFormat": "running" } ] diff --git a/homelab/otel/grafana/provisioning/dashboards/web-vitals.json b/homelab/otel/grafana/provisioning/dashboards/web-vitals.json new file mode 100644 index 0000000..a9918da --- /dev/null +++ b/homelab/otel/grafana/provisioning/dashboards/web-vitals.json @@ -0,0 +1,284 @@ +{ + "uid": "libnovel-web-vitals", + "title": "Web Vitals (RUM)", + "description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser → Alloy faro.receiver → Tempo (traces) + Loki (logs).", + "tags": ["libnovel", "frontend", "rum", "web-vitals"], + "timezone": "browser", + "refresh": "1m", + "time": { "from": "now-24h", "to": "now" }, + "schemaVersion": 39, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "LCP — p75 (Largest Contentful Paint)", + "description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.", + "gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 2500 }, + { "color": "red", "value": 4000 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000", + "legendFormat": "LCP p75", + "instant": true + } + ] + }, + { + "id": 2, + "type": "stat", + "title": "INP — p75 (Interaction to Next Paint)", + "description": "Good < 200 ms, needs improvement < 500 ms, poor ≥ 500 ms.", + "gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 200 }, + { "color": "red", "value": 500 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000", + "legendFormat": "INP p75", + "instant": true + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "CLS — p75 (Cumulative Layout Shift)", + "description": "Good < 0.1, needs improvement < 0.25, poor ≥ 0.25.", + "gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 3, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.1 }, + { "color": "red", "value": 0.25 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))", + "legendFormat": "CLS p75", + "instant": true + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "TTFB — p75 (Time to First Byte)", + "description": "Good < 800 ms, needs improvement < 1800 ms, poor ≥ 1800 ms.", + "gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 800 }, + { "color": "red", "value": 1800 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000", + "legendFormat": "TTFB p75", + "instant": true + } + ] + }, + { + "id": 5, + "type": "stat", + "title": "FCP — p75 (First Contentful Paint)", + "description": "Good < 1.8 s, needs improvement < 3 s, poor ≥ 3 s.", + "gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1800 }, + { "color": "red", "value": 3000 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000", + "legendFormat": "FCP p75", + "instant": true + } + ] + }, + { + "id": 6, + "type": "stat", + "title": "Active Sessions (30 min)", + "gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" }, + "fieldConfig": { + "defaults": { + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))", + "legendFormat": "sessions", + "instant": true + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "LCP over time (p50 / p75 / p95)", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, + "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } }, + "fieldConfig": { + "defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }, + { "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] } + ] + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000", + "legendFormat": "p50" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000", + "legendFormat": "p75" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000", + "legendFormat": "p95" + }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" }, + { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "TTFB over time (p50 / p75 / p95)", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, + "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } }, + "fieldConfig": { + "defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000", + "legendFormat": "p50" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000", + "legendFormat": "p75" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000", + "legendFormat": "p95" + } + ] + }, + { + "id": 20, + "type": "logs", + "title": "Frontend Errors & Exceptions", + "description": "JS exceptions and console errors captured by Faro and shipped to Loki.", + "gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 }, + "options": { + "showTime": true, + "showLabels": true, + "wrapLogMessage": true, + "prettifyLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + }, + "targets": [ + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`", + "legendFormat": "" + } + ] + }, + { + "id": 21, + "type": "logs", + "title": "Frontend Logs (all Faro events)", + "gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 }, + "options": { + "showTime": true, + "showLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": true, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + }, + "targets": [ + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "{service_name=\"libnovel-ui\"}", + "legendFormat": "" + } + ] + } + ] +} diff --git a/ui/messages/en.json b/ui/messages/en.json index 9f23348..26d202c 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -410,6 +410,7 @@ "admin_nav_uptime": "Uptime", "admin_nav_push": "Push", "admin_nav_gitea": "Gitea", + "admin_nav_grafana": "Grafana", "admin_scrape_status_idle": "Idle", "admin_scrape_status_running": "Running", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 02b69d3..3751c7f 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -471,5 +471,6 @@ "feed_chapters_label": "{n} chapitres", "feed_browse_cta": "Parcourir le catalogue", "feed_find_users_cta": "Trouver des lecteurs", - "admin_nav_gitea": "Gitea" + "admin_nav_gitea": "Gitea", + "admin_nav_grafana": "Grafana" } diff --git a/ui/messages/id.json b/ui/messages/id.json index bf9254c..9070748 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -471,5 +471,6 @@ "feed_chapters_label": "{n} bab", "feed_browse_cta": "Jelajahi katalog", "feed_find_users_cta": "Temukan pembaca", - "admin_nav_gitea": "Gitea" + "admin_nav_gitea": "Gitea", + "admin_nav_grafana": "Grafana" } diff --git a/ui/messages/pt.json b/ui/messages/pt.json index eb9e23f..64bf8b0 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -471,5 +471,6 @@ "feed_chapters_label": "{n} capítulos", "feed_browse_cta": "Ver catálogo", "feed_find_users_cta": "Encontrar leitores", - "admin_nav_gitea": "Gitea" + "admin_nav_gitea": "Gitea", + "admin_nav_grafana": "Grafana" } diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 5a933cc..88ccac7 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -471,5 +471,6 @@ "feed_chapters_label": "{n} глав", "feed_browse_cta": "Каталог", "feed_find_users_cta": "Найти читателей", - "admin_nav_gitea": "Gitea" + "admin_nav_gitea": "Gitea", + "admin_nav_grafana": "Grafana" } diff --git a/ui/package-lock.json b/ui/package-lock.json index c5d54ba..39940c9 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0", + "@grafana/faro-web-sdk": "^2.3.1", "@inlang/paraglide-js": "^2.15.1", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", @@ -1689,6 +1690,115 @@ "module-details-from-path": "^1.0.4" } }, + "node_modules/@grafana/faro-core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.3.1.tgz", + "integrity": "sha512-htDKO0YFKr0tfntrPoM151vOPSZzmP6oE0+0MDvbI1WDaBW4erXmYi3feGJLWDXt5/vZBg9iQRmZoRzTLTTcOA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/otlp-transformer": "^0.213.0" + } + }, + "node_modules/@grafana/faro-core/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", + "integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-metrics": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0", + "protobufjs": "^7.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@grafana/faro-core/node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", + "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz", + "integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@grafana/faro-web-sdk": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-2.3.1.tgz", + "integrity": "sha512-WMfErl2YSP+CcfcobMpCdK6apX86hc8bymMXsvYLQpBBkQ0KJjIilEQS/YXd+g/cg6F1kwbeweisBKluNNy5sA==", + "license": "Apache-2.0", + "dependencies": { + "@grafana/faro-core": "^2.3.1", + "ua-parser-js": "1.0.41", + "web-vitals": "^5.1.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -7377,6 +7487,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7540,6 +7676,12 @@ } } }, + "node_modules/web-vitals": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", + "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/ui/package.json b/ui/package.json index 74f6c0b..d765f07 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,6 +31,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0", + "@grafana/faro-web-sdk": "^2.3.1", "@inlang/paraglide-js": "^2.15.1", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", diff --git a/ui/src/hooks.client.ts b/ui/src/hooks.client.ts index dd9d965..4847b50 100644 --- a/ui/src/hooks.client.ts +++ b/ui/src/hooks.client.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/sveltekit'; import { env } from '$env/dynamic/public'; +import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk'; // Sentry / GlitchTip client-side error tracking. // No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev). @@ -13,4 +14,21 @@ if (env.PUBLIC_GLITCHTIP_DSN) { }); } +// Grafana Faro RUM — browser performance monitoring (Web Vitals, traces, errors). +// No-op when PUBLIC_FARO_COLLECTOR_URL is unset (e.g. local dev). +if (env.PUBLIC_FARO_COLLECTOR_URL) { + initializeFaro({ + url: env.PUBLIC_FARO_COLLECTOR_URL, + app: { + name: 'libnovel-ui', + version: env.PUBLIC_BUILD_VERSION || 'dev', + environment: 'production' + }, + instrumentations: [ + // Core Web Vitals (LCP, CLS, INP, TTFB, FCP) + JS errors + console + ...getWebInstrumentations({ captureConsole: false }) + ] + }); +} + export const handleError = Sentry.handleErrorWithSentry(); diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 969c01b..5e3d5f7 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -381,6 +381,7 @@ export * from './admin_nav_logs.js' export * from './admin_nav_uptime.js' export * from './admin_nav_push.js' export * from './admin_nav_gitea.js' +export * from './admin_nav_grafana.js' export * from './admin_scrape_status_idle.js' export * from './admin_scrape_full_catalogue.js' export * from './admin_scrape_single_book.js' diff --git a/ui/src/lib/paraglide/messages/admin_nav_grafana.js b/ui/src/lib/paraglide/messages/admin_nav_grafana.js new file mode 100644 index 0000000..97a5942 --- /dev/null +++ b/ui/src/lib/paraglide/messages/admin_nav_grafana.js @@ -0,0 +1,44 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Admin_Nav_GrafanaInputs */ + +const en_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Grafana`) +}; + +const ru_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Grafana`) +}; + +const id_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Grafana`) +}; + +const pt_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Grafana`) +}; + +const fr_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Grafana`) +}; + +/** +* | output | +* | --- | +* | "Grafana" | +* +* @param {Admin_Nav_GrafanaInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ +export const admin_nav_grafana = /** @type {((inputs?: Admin_Nav_GrafanaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_admin_nav_grafana(inputs) + if (locale === "ru") return ru_admin_nav_grafana(inputs) + if (locale === "id") return id_admin_nav_grafana(inputs) + if (locale === "pt") return pt_admin_nav_grafana(inputs) + return fr_admin_nav_grafana(inputs) +}); \ No newline at end of file diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index e0bd6b1..e022fd0 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -19,6 +19,7 @@ { href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() }, { href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() }, { href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() }, + { href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() }, { href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() } ];