diff --git a/infra/grafana/Dockerfile b/infra/grafana/Dockerfile new file mode 100644 index 0000000..d387315 --- /dev/null +++ b/infra/grafana/Dockerfile @@ -0,0 +1,5 @@ +FROM grafana/grafana:11.1.4 +FROM grafana/grafana:11.1.4 + +# Copy provisioning configs +COPY provisioning /etc/grafana/provisioning diff --git a/infra/grafana/provisioning/dashboards/dashboards.yml b/infra/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..636f645 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: "default" + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards diff --git a/infra/grafana/provisioning/dashboards/nodejs-dashboard.json b/infra/grafana/provisioning/dashboards/nodejs-dashboard.json new file mode 100644 index 0000000..2bf7d98 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/nodejs-dashboard.json @@ -0,0 +1,71 @@ +{ + "id": null, + "title": "Node.js App Observability", + "tags": ["nodejs", "prometheus"], + "timezone": "browser", + "schemaVersion": 36, + "version": 1, + "refresh": "10s", + "panels": [ + { + "type": "graph", + "title": "CPU Usage (%)", + "targets": [ + { + "expr": "process_cpu_user_seconds_total", + "legendFormat": "CPU User Time", + "refId": "A" + } + ], + "id": 1 + }, + { + "type": "graph", + "title": "Memory Usage (MB)", + "targets": [ + { + "expr": "process_resident_memory_bytes / 1024 / 1024", + "legendFormat": "Resident Memory", + "refId": "A" + } + ], + "id": 2 + }, + { + "type": "graph", + "title": "HTTP Requests per Second", + "targets": [ + { + "expr": "rate(http_requests_total[1m])", + "legendFormat": "{{method}} {{route}}", + "refId": "A" + } + ], + "id": 3 + }, + { + "type": "graph", + "title": "Error Rate (5xx per Second)", + "targets": [ + { + "expr": "rate(http_requests_total{status=~\"5..\"}[5m])", + "legendFormat": "Errors (5xx)", + "refId": "A" + } + ], + "id": 4 + }, + { + "type": "graph", + "title": "95th Percentile Latency", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95 latency", + "refId": "A" + } + ], + "id": 5 + } + ] +} diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..86fd346 --- /dev/null +++ b/infra/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/infra/prometheus/Dockerfile b/infra/prometheus/Dockerfile new file mode 100644 index 0000000..e0a6382 --- /dev/null +++ b/infra/prometheus/Dockerfile @@ -0,0 +1,6 @@ +FROM prom/prometheus:v2.53.0 + +# Copy custom config +COPY prometheus.yml /etc/prometheus/prometheus.yml +# force rebuild +ARG CACHEBUST=1 \ No newline at end of file diff --git a/infra/prometheus/prometheus.yml b/infra/prometheus/prometheus.yml new file mode 100644 index 0000000..96da60c --- /dev/null +++ b/infra/prometheus/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "mydev" + metrics_path: /metrics + scheme: https # Render apps use HTTPS + static_configs: + - targets: + - "mydev-staging.onrender.com" + - "mydev-prod.onrender.com" diff --git a/package-lock.json b/package-lock.json index 7464de7..a376d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@sentry/node": "^7.120.4", "@sentry/tracing": "^7.120.4", - "express": "^4.21.2" + "express": "^4.21.2", + "prom-client": "^15.1.3" }, "devDependencies": { "eslint": "^8.57.1", @@ -1131,6 +1132,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -1677,6 +1687,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -5255,6 +5271,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6076,6 +6105,15 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 627923a..a7f2051 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "dependencies": { "@sentry/node": "^7.120.4", "@sentry/tracing": "^7.120.4", - "express": "^4.21.2" + "express": "^4.21.2", + "prom-client": "^15.1.3" }, "devDependencies": { "eslint": "^8.57.1", diff --git a/render.yaml b/render.yaml index 7db226d..d90b0ab 100644 --- a/render.yaml +++ b/render.yaml @@ -30,3 +30,25 @@ services: value: 10000 - key: SENTRY_DSN sync: false + + - type: web + name: prometheus + env: docker + plan: free + rootDir: ./infra/prometheus + dockerfilePath: Dockerfile + autoDeploy: false + envVars: + - key: PORT + value: 9090 + + - type: web + name: grafana + env: docker + plan: free + rootDir: ./infra/grafana + dockerfilePath: Dockerfile + autoDeploy: false + envVars: + - key: PORT + value: 3000 diff --git a/src/app.js b/src/app.js index 11f2d61..8adbc12 100644 --- a/src/app.js +++ b/src/app.js @@ -1,19 +1,42 @@ const express = require("express"); const Sentry = require("@sentry/node"); const Tracing = require("@sentry/tracing"); +const client = require("prom-client"); const app = express(); -// Initialize Sentry (v7 API) +// --- Prometheus Setup --- +const collectDefaultMetrics = client.collectDefaultMetrics; +collectDefaultMetrics(); // Collect Node.js process metrics + +// Custom counter +const httpRequestCounter = new client.Counter({ + name: "http_requests_total", + help: "Total number of HTTP requests", + labelNames: ["method", "route", "status"], +}); + +// Middleware to count requests +app.use((req, res, next) => { + res.on("finish", () => { + httpRequestCounter.labels(req.method, req.path, res.statusCode).inc(); + }); + next(); +}); + +// Expose /metrics endpoint for Prometheus +app.get("/metrics", async (req, res) => { + res.set("Content-Type", client.register.contentType); + res.end(await client.register.metrics()); +}); + +// --- Sentry Setup (v7) --- Sentry.init({ dsn: process.env.SENTRY_DSN || "", - integrations: [ - new Tracing.Integrations.Express({ app }), // works fine with v7 - ], - tracesSampleRate: 1.0, // lower this in prod (e.g. 0.1) + integrations: [new Tracing.Integrations.Express({ app })], + tracesSampleRate: 1.0, }); -// Request handler (before routes) app.use(Sentry.Handlers.requestHandler()); app.use(Sentry.Handlers.tracingHandler()); @@ -22,13 +45,12 @@ app.get("/", (req, res) => { res.send("You are safe in Wizfi's Pipeline!"); }); -// Debug route to test Sentry (ESLint safe) +// Debug route app.get("/debug-sentry", (req, res) => { res.status(500).send("Triggering Sentry debug error..."); throw new Error("Debug Sentry error!"); }); -// Error handler (after routes) app.use(Sentry.Handlers.errorHandler()); module.exports = app;