diff --git a/grafana/maps-playground-dashboard.json b/grafana/maps-playground-dashboard.json new file mode 100644 index 0000000..6639e70 --- /dev/null +++ b/grafana/maps-playground-dashboard.json @@ -0,0 +1,95 @@ +{ + "title": "Maps Playground", + "uid": "maps-playground", + "schemaVersion": 39, + "editable": true, + "time": { "from": "now-6h", "to": "now" }, + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus", + "current": {} + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Request rate by route", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum by (route) (rate(http_requests_total[5m]))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 2, + "title": "Latency p50 / p95 (s)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95" + } + ] + }, + { + "id": 3, + "title": "Error ratio (4xx+5xx)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status_code=~\"4..|5..\"}[5m])) / sum(rate(http_requests_total[5m]))", + "legendFormat": "error ratio" + } + ] + }, + { + "id": 4, + "title": "Top demos", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { + "expr": "topk(10, sum by (client, name) (rate(demo_views_total[15m])))", + "legendFormat": "{{client}}/{{name}}" + } + ] + }, + { + "id": 5, + "title": "Process memory (bytes)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "targets": [ + { "expr": "process_resident_memory_bytes", "legendFormat": "rss" } + ] + }, + { + "id": 6, + "title": "Process CPU (cores)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "targets": [ + { "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "cpu" } + ] + } + ] +} diff --git a/helm/Chart.lock b/helm/Chart.lock new file mode 100644 index 0000000..9cb66d4 --- /dev/null +++ b/helm/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mclabels + repository: oci://acrarolibotnonprod.azurecr.io/helm/infra + version: 1.0.1 +digest: sha256:a97237cd8966ab9d4f8c0b8dda2ad110fbff5d485da868124fdce2a5dbbfa208 +generated: "2026-07-21T16:25:15.223597776+03:00" diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 0b2c4bb..e3efa27 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -4,3 +4,7 @@ description: A Helm chart for developer-portal type: application version: 1.2.0 appVersion: 1.2.0 +dependencies: + - name: mclabels + version: 1.0.1 + repository: oci://acrarolibotnonprod.azurecr.io/helm/infra diff --git a/helm/charts/mclabels-1.0.1.tgz b/helm/charts/mclabels-1.0.1.tgz new file mode 100644 index 0000000..978e6df Binary files /dev/null and b/helm/charts/mclabels-1.0.1.tgz differ diff --git a/helm/grafana/dashboard-sync.test.ts b/helm/grafana/dashboard-sync.test.ts new file mode 100644 index 0000000..6e7e1d1 --- /dev/null +++ b/helm/grafana/dashboard-sync.test.ts @@ -0,0 +1,11 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; + +describe('grafana dashboard copies stay in sync', () => { + it('repo-root and chart copies are byte-identical', () => { + const root = readFileSync('grafana/maps-playground-dashboard.json', 'utf8'); + const chart = readFileSync('helm/grafana/maps-playground-dashboard.json', 'utf8'); + expect(chart).toBe(root); + }); +}); diff --git a/helm/grafana/maps-playground-dashboard.json b/helm/grafana/maps-playground-dashboard.json new file mode 100644 index 0000000..6639e70 --- /dev/null +++ b/helm/grafana/maps-playground-dashboard.json @@ -0,0 +1,95 @@ +{ + "title": "Maps Playground", + "uid": "maps-playground", + "schemaVersion": 39, + "editable": true, + "time": { "from": "now-6h", "to": "now" }, + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus", + "current": {} + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Request rate by route", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum by (route) (rate(http_requests_total[5m]))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 2, + "title": "Latency p50 / p95 (s)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95" + } + ] + }, + { + "id": 3, + "title": "Error ratio (4xx+5xx)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status_code=~\"4..|5..\"}[5m])) / sum(rate(http_requests_total[5m]))", + "legendFormat": "error ratio" + } + ] + }, + { + "id": 4, + "title": "Top demos", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { + "expr": "topk(10, sum by (client, name) (rate(demo_views_total[15m])))", + "legendFormat": "{{client}}/{{name}}" + } + ] + }, + { + "id": 5, + "title": "Process memory (bytes)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "targets": [ + { "expr": "process_resident_memory_bytes", "legendFormat": "rss" } + ] + }, + { + "id": 6, + "title": "Process CPU (cores)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "targets": [ + { "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "cpu" } + ] + } + ] +} diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index da12d9c..36caabc 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -36,6 +36,7 @@ helm.sh/chart: {{ include "developer-portal.chart" . }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} +{{ include "mclabels.labels" . }} {{- end }} {{/* diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index b45d872..9364756 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -30,10 +30,11 @@ spec: release: {{ $releaseName }} run: {{ $releaseName }}-{{ $chartName }} {{- include "developer-portal.selectorLabels" . | nindent 8 }} - {{- if .Values.resetOnConfigChange }} annotations: + {{- if .Values.resetOnConfigChange }} checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - {{- end }} + {{- end }} + {{- include "mclabels.annotations" . | trim | nindent 8 }} spec: {{- if $cloudProviderImagePullSecretName }} imagePullSecrets: diff --git a/helm/templates/grafana-dashboard-configmap.yaml b/helm/templates/grafana-dashboard-configmap.yaml new file mode 100644 index 0000000..9ca307a --- /dev/null +++ b/helm/templates/grafana-dashboard-configmap.yaml @@ -0,0 +1,12 @@ +{{- if and .Values.enabled .Values.metrics.grafanaDashboard.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-{{ include "developer-portal.name" . }}-grafana-dashboard + labels: + {{ .Values.metrics.grafanaDashboard.label }}: {{ .Values.metrics.grafanaDashboard.value | quote }} + {{- include "developer-portal.labels" . | nindent 4 }} +data: + maps-playground-dashboard.json: |- + {{- .Files.Get "grafana/maps-playground-dashboard.json" | nindent 4 }} +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 7e0e30d..d721476 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -16,6 +16,23 @@ image: tag: 0.0.1-4 imagePullPolicy: IfNotPresent +mclabels: + environment: production + owner: common + partOf: maps-playground + component: frontend + prometheus: + enabled: true + port: 8080 + path: /metrics + logScraping: false + +metrics: + grafanaDashboard: + enabled: true + label: grafana_dashboard + value: "1" + s3: url: 'http://localhost:9000' bucket: 'temp' diff --git a/package-lock.json b/package-lock.json index 80017bb..500da39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@popperjs/core": "^2.11.7", "ajv": "^8.12.0", "async-cache-dedupe": "^1.12.0", - "classnames": "^2.3.2" + "classnames": "^2.3.2", + "prom-client": "^15.1.3" }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", @@ -1657,6 +1658,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3236,6 +3246,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/bitmap-sdf": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", @@ -6843,6 +6859,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/protobufjs": { "version": "8.6.1", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.1.tgz", @@ -7869,6 +7898,15 @@ "node": ">=14.0.0" } }, + "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/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index d9a3fd1..c902482 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@popperjs/core": "^2.11.7", "ajv": "^8.12.0", "async-cache-dedupe": "^1.12.0", - "classnames": "^2.3.2" + "classnames": "^2.3.2", + "prom-client": "^15.1.3" } } diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts new file mode 100644 index 0000000..886f825 --- /dev/null +++ b/src/hooks.server.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { handle } from './hooks.server'; +import { register } from '$lib/server/metrics'; + +function makeEvent(pathname: string, method = 'GET', routeId: string | null = '/') { + return { + url: new URL(`http://localhost${pathname}`), + request: new Request(`http://localhost${pathname}`, { method }), + route: { id: routeId } + } as any; +} + +async function requestCount(): Promise { + const metric = (await register.getMetricsAsJSON()).find((m) => m.name === 'http_requests_total'); + return (metric?.values ?? []).reduce((sum, v) => sum + v.value, 0); +} + +describe('handle hook', () => { + it('serves /metrics as prometheus text and does not count it', async () => { + const before = await requestCount(); + const res = await handle({ + event: makeEvent('/metrics'), + resolve: async () => new Response('should-not-be-used') + } as any); + expect(res.headers.get('content-type')).toContain('text/plain'); + expect(await res.text()).toContain('http_requests_total'); + expect(await requestCount()).toBe(before); + }); + + it('counts a normal request labeled by route id', async () => { + const before = await requestCount(); + const res = await handle({ + event: makeEvent('/demo/openlayers/basic', 'GET', '/demo/[client]/[name]'), + resolve: async () => new Response('ok', { status: 200 }) + } as any); + expect(res.status).toBe(200); + expect(await requestCount()).toBe(before + 1); + }); +}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..f78acaa --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,20 @@ +import type { Handle } from '@sveltejs/kit'; +import { httpRequestDuration, httpRequestsTotal, register } from '$lib/server/metrics'; + +export const handle: Handle = async ({ event, resolve }) => { + if (event.url.pathname === '/metrics' && event.request.method === 'GET') { + const body = await register.metrics(); + return new Response(body, { headers: { 'content-type': register.contentType } }); + } + + const stop = httpRequestDuration.startTimer(); + const response = await resolve(event); + const labels = { + method: event.request.method, + route: event.route.id ?? 'unknown', + status_code: String(response.status) + }; + stop(labels); + httpRequestsTotal.inc(labels); + return response; +}; diff --git a/src/lib/server/metrics.test.ts b/src/lib/server/metrics.test.ts new file mode 100644 index 0000000..d6b85ea --- /dev/null +++ b/src/lib/server/metrics.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { register } from './metrics'; + +describe('metrics registry', () => { + it('registers the three custom metrics', async () => { + const names = (await register.getMetricsAsJSON()).map((m) => m.name); + expect(names).toContain('http_requests_total'); + expect(names).toContain('http_request_duration_seconds'); + expect(names).toContain('demo_views_total'); + }); + + it('collects default process metrics', async () => { + const names = (await register.getMetricsAsJSON()).map((m) => m.name); + expect(names).toContain('process_cpu_user_seconds_total'); + }); +}); diff --git a/src/lib/server/metrics.ts b/src/lib/server/metrics.ts new file mode 100644 index 0000000..cf57bb8 --- /dev/null +++ b/src/lib/server/metrics.ts @@ -0,0 +1,39 @@ +import client from 'prom-client'; + +export const register = client.register; + +// Guard: only start default-metric collection once per process. +if (!register.getSingleMetric('process_cpu_user_seconds_total')) { + client.collectDefaultMetrics({ register }); +} + +function counter(config: client.CounterConfiguration): client.Counter { + return ( + (register.getSingleMetric(config.name) as client.Counter) ?? new client.Counter(config) + ); +} + +function histogram(config: client.HistogramConfiguration): client.Histogram { + return ( + (register.getSingleMetric(config.name) as client.Histogram) ?? + new client.Histogram(config) + ); +} + +export const httpRequestsTotal = counter({ + name: 'http_requests_total', + help: 'Total number of HTTP requests', + labelNames: ['method', 'route', 'status_code'] +}); + +export const httpRequestDuration = histogram({ + name: 'http_request_duration_seconds', + help: 'HTTP request duration in seconds', + labelNames: ['method', 'route', 'status_code'] +}); + +export const demoViewsTotal = counter({ + name: 'demo_views_total', + help: 'Total number of demo views', + labelNames: ['client', 'name'] +}); diff --git a/src/routes/demo/[client]/[name]/+page.server.ts b/src/routes/demo/[client]/[name]/+page.server.ts index 65d337c..e7d171c 100644 --- a/src/routes/demo/[client]/[name]/+page.server.ts +++ b/src/routes/demo/[client]/[name]/+page.server.ts @@ -1,4 +1,6 @@ +import { error } from '@sveltejs/kit'; import { getDemoIndex, getFile } from '$lib/server/demoManager.js'; +import { demoViewsTotal } from '$lib/server/metrics'; import type { Link, File } from '$lib/types'; export async function load({ params }): Promise<{ @@ -10,7 +12,12 @@ export async function load({ params }): Promise<{ }> { const { client, name: demoName } = params; - const demoMetadata = (await getDemoIndex())[client][demoName]; + const demoMetadata = (await getDemoIndex())[client]?.[demoName]; + if (!demoMetadata) { + throw error(404, `no demo named ${demoName} found in client ${client}`); + } + + demoViewsTotal.inc({ client, name: demoName }); const files = await Promise.all( demoMetadata.files.map(async (fileName) => { diff --git a/src/routes/demo/[client]/[name]/views.test.ts b/src/routes/demo/[client]/[name]/views.test.ts new file mode 100644 index 0000000..6168623 --- /dev/null +++ b/src/routes/demo/[client]/[name]/views.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('$lib/server/demoManager.js', () => ({ + getDemoIndex: async () => ({ + openlayers: { basic: { files: [], links: [], displayName: 'Basic', description: '' } } + }), + getFile: async () => '' +})); + +import { load } from './+page.server'; +import { demoViewsTotal } from '$lib/server/metrics'; + +async function viewCount(client: string, name: string): Promise { + const metric = (await (await import('$lib/server/metrics')).register.getMetricsAsJSON()).find( + (m) => m.name === 'demo_views_total' + ); + const match = (metric?.values ?? []).find( + (v) => v.labels.client === client && v.labels.name === name + ); + return match?.value ?? 0; +} + +describe('demo view counter', () => { + it('increments demo_views_total labeled by client and name', async () => { + const before = await viewCount('openlayers', 'basic'); + await load({ params: { client: 'openlayers', name: 'basic' } } as any); + expect(await viewCount('openlayers', 'basic')).toBe(before + 1); + expect(demoViewsTotal).toBeDefined(); + }); + + it('does not increment for an unknown demo', async () => { + const before = await viewCount('nope', 'missing'); + await expect(load({ params: { client: 'nope', name: 'missing' } } as any)).rejects.toThrow(); + expect(await viewCount('nope', 'missing')).toBe(before); + }); +}); diff --git a/vitest-setup.ts b/vitest-setup.ts index 06ced1f..f28418e 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -4,5 +4,9 @@ import { afterEach, vi } from 'vitest'; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - localStorage.clear(); + // `localStorage` only exists under the jsdom environment. Node-env test files + // (e.g. server-side metrics tests) share this teardown but have no jsdom globals. + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } });