From a59bf8eda263300078567161fd0bb6c976bb721f Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 11 Apr 2026 01:39:37 +0700 Subject: [PATCH] feat(infra): add web vitals Grafana dashboard and admin audit log migration - Add Grafana dashboard for web vitals metrics visualization - Add Prisma migration for admin audit log table Co-Authored-By: Paperclip --- monitoring/grafana/dashboards/web-vitals.json | 397 ++++++++++++++++++ .../migration.sql | 38 ++ 2 files changed, 435 insertions(+) create mode 100644 monitoring/grafana/dashboards/web-vitals.json create mode 100644 prisma/migrations/20260410100000_add_admin_audit_log/migration.sql diff --git a/monitoring/grafana/dashboards/web-vitals.json b/monitoring/grafana/dashboards/web-vitals.json new file mode 100644 index 0000000..774a6ff --- /dev/null +++ b/monitoring/grafana/dashboards/web-vitals.json @@ -0,0 +1,397 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "title": "Core Web Vitals — Overview", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "collapsed": false + }, + { + "title": "LCP (Largest Contentful Paint)", + "description": "Target: < 2.5s (good), < 4.0s (needs improvement)", + "type": "gauge", + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_lcp_seconds_bucket[5m])) by (le))", + "legendFormat": "p75 LCP", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 10, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 2.5 }, + { "color": "red", "value": 4.0 } + ] + } + } + } + }, + { + "title": "FCP (First Contentful Paint)", + "description": "Target: < 1.8s (good), < 3.0s (needs improvement)", + "type": "gauge", + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_fcp_seconds_bucket[5m])) by (le))", + "legendFormat": "p75 FCP", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 5, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1.8 }, + { "color": "red", "value": 3.0 } + ] + } + } + } + }, + { + "title": "CLS (Cumulative Layout Shift)", + "description": "Target: < 0.1 (good), < 0.25 (needs improvement)", + "type": "gauge", + "gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_cls_bucket[5m])) by (le))", + "legendFormat": "p75 CLS", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.1 }, + { "color": "red", "value": 0.25 } + ] + } + } + } + }, + { + "title": "TTFB (Time to First Byte)", + "description": "Target: < 800ms (good)", + "type": "gauge", + "gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_ttfb_seconds_bucket[5m])) by (le))", + "legendFormat": "p75 TTFB", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 5, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.8 }, + { "color": "red", "value": 1.8 } + ] + } + } + } + }, + { + "title": "Rating Distribution", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 7 }, + "collapsed": false + }, + { + "title": "Web Vital Ratings (Good / Needs Improvement / Poor)", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "sum(goodgo_web_vitals_total) by (rating)", + "legendFormat": "{{rating}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [ + { "matcher": { "id": "byName", "options": "good" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "needs-improvement" }, "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "poor" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] } + ] + } + }, + { + "title": "Events by Vital Type", + "type": "barchart", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 }, + "targets": [ + { + "expr": "sum(goodgo_web_vitals_total) by (name)", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { "unit": "short" } + } + }, + { + "title": "INP (Interaction to Next Paint)", + "description": "Target: < 200ms (good), < 500ms (needs improvement)", + "type": "gauge", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_inp_seconds_bucket[5m])) by (le))", + "legendFormat": "p75 INP", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.2 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "title": "Trends Over Time", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 }, + "collapsed": false + }, + { + "title": "LCP Trend (p50 / p75 / p95)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(goodgo_web_vitals_lcp_seconds_bucket[15m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_lcp_seconds_bucket[15m])) by (le))", + "legendFormat": "p75", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(goodgo_web_vitals_lcp_seconds_bucket[15m])) by (le))", + "legendFormat": "p95", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { "drawStyle": "line", "fillOpacity": 10, "pointSize": 5 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 2.5 }, + { "color": "red", "value": 4.0 } + ] + } + } + } + }, + { + "title": "CLS Trend (p50 / p75 / p95)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(goodgo_web_vitals_cls_bucket[15m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_cls_bucket[15m])) by (le))", + "legendFormat": "p75", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(goodgo_web_vitals_cls_bucket[15m])) by (le))", + "legendFormat": "p95", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "custom": { "drawStyle": "line", "fillOpacity": 10, "pointSize": 5 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.1 }, + { "color": "red", "value": 0.25 } + ] + } + } + } + }, + { + "title": "TTFB Trend (p50 / p75 / p95)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 25 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(goodgo_web_vitals_ttfb_seconds_bucket[15m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_ttfb_seconds_bucket[15m])) by (le))", + "legendFormat": "p75", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(goodgo_web_vitals_ttfb_seconds_bucket[15m])) by (le))", + "legendFormat": "p95", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { "drawStyle": "line", "fillOpacity": 10, "pointSize": 5 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.8 }, + { "color": "red", "value": 1.8 } + ] + } + } + } + }, + { + "title": "INP Trend (p50 / p75 / p95)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 25 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(goodgo_web_vitals_inp_seconds_bucket[15m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_inp_seconds_bucket[15m])) by (le))", + "legendFormat": "p75", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(goodgo_web_vitals_inp_seconds_bucket[15m])) by (le))", + "legendFormat": "p95", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { "drawStyle": "line", "fillOpacity": 10, "pointSize": 5 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.2 }, + { "color": "red", "value": 0.5 } + ] + } + } + } + }, + { + "title": "Page Breakdown", + "type": "row", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 }, + "collapsed": false + }, + { + "title": "LCP by Page (p75)", + "type": "table", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 }, + "targets": [ + { + "expr": "histogram_quantile(0.75, sum(rate(goodgo_web_vitals_lcp_seconds_bucket[1h])) by (le, page)) > 0", + "legendFormat": "{{page}}", + "refId": "A", + "format": "table", + "instant": true + } + ], + "fieldConfig": { + "defaults": { "unit": "s" } + }, + "transformations": [ + { "id": "organize", "options": { "excludeByName": { "Time": true, "le": true } } } + ] + }, + { + "title": "Vitals Event Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 }, + "targets": [ + { + "expr": "sum(rate(goodgo_web_vitals_total[5m])) by (name)", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "drawStyle": "line", "fillOpacity": 10 } + } + } + } + ], + "schemaVersion": 39, + "tags": ["web-vitals", "rum", "frontend"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "title": "Frontend Performance — Core Web Vitals", + "uid": "goodgo-web-vitals", + "version": 1 +} diff --git a/prisma/migrations/20260410100000_add_admin_audit_log/migration.sql b/prisma/migrations/20260410100000_add_admin_audit_log/migration.sql new file mode 100644 index 0000000..e2e0efd --- /dev/null +++ b/prisma/migrations/20260410100000_add_admin_audit_log/migration.sql @@ -0,0 +1,38 @@ +-- CreateEnum +CREATE TYPE "AdminAction" AS ENUM ('LISTING_APPROVED', 'LISTING_REJECTED', 'LISTING_BULK_APPROVED', 'LISTING_BULK_REJECTED', 'USER_BANNED', 'USER_UNBANNED', 'USER_STATUS_UPDATED', 'KYC_APPROVED', 'KYC_REJECTED', 'SUBSCRIPTION_ADJUSTED'); + +-- CreateEnum +CREATE TYPE "AuditTargetType" AS ENUM ('USER', 'LISTING', 'SUBSCRIPTION'); + +-- CreateTable +CREATE TABLE "AdminAuditLog" ( + "id" TEXT NOT NULL, + "action" "AdminAction" NOT NULL, + "actorId" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "targetType" "AuditTargetType" NOT NULL, + "metadata" JSONB, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_actorId_idx" ON "AdminAuditLog"("actorId"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_targetId_targetType_idx" ON "AdminAuditLog"("targetId", "targetType"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_action_idx" ON "AdminAuditLog"("action"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_createdAt_idx" ON "AdminAuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt" DESC);