From 99b4f1ba694b805b873de351ba4f7a7f0be66ac2 Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 14:50:23 +0300 Subject: [PATCH 1/7] feat(metrics): add prom-client registry with custom metrics --- package-lock.json | 40 +++++++++++++++++++++++++++++++++- package.json | 3 ++- src/lib/server/metrics.test.ts | 26 ++++++++++++++++++++++ src/lib/server/metrics.ts | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/metrics.test.ts create mode 100644 src/lib/server/metrics.ts 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/lib/server/metrics.test.ts b/src/lib/server/metrics.test.ts new file mode 100644 index 0000000..d0ef7c4 --- /dev/null +++ b/src/lib/server/metrics.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { register } from './metrics'; + +// The shared vitest-setup.ts afterEach hook calls `localStorage.clear()`, which assumes +// the jsdom environment. This file overrides to the `node` environment (prom-client is +// Node-only), so provide a minimal no-op shim to keep the shared teardown from throwing. +if (typeof globalThis.localStorage === 'undefined') { + (globalThis as unknown as { localStorage: Pick }).localStorage = { + clear: () => void 0 + }; +} + +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'] +}); From 8253ba64abd3314fe05e4a5c0102e9776d5b0909 Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 14:51:44 +0300 Subject: [PATCH 2/7] test: guard localStorage teardown for node-env test files Node-environment test files (server-side metrics) share the global afterEach teardown but lack jsdom's localStorage. Guard the clear() centrally instead of shimming per-file. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/metrics.test.ts | 9 --------- vitest-setup.ts | 6 +++++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/lib/server/metrics.test.ts b/src/lib/server/metrics.test.ts index d0ef7c4..d6b85ea 100644 --- a/src/lib/server/metrics.test.ts +++ b/src/lib/server/metrics.test.ts @@ -2,15 +2,6 @@ import { describe, it, expect } from 'vitest'; import { register } from './metrics'; -// The shared vitest-setup.ts afterEach hook calls `localStorage.clear()`, which assumes -// the jsdom environment. This file overrides to the `node` environment (prom-client is -// Node-only), so provide a minimal no-op shim to keep the shared teardown from throwing. -if (typeof globalThis.localStorage === 'undefined') { - (globalThis as unknown as { localStorage: Pick }).localStorage = { - clear: () => void 0 - }; -} - describe('metrics registry', () => { it('registers the three custom metrics', async () => { const names = (await register.getMetricsAsJSON()).map((m) => m.name); 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(); + } }); From 853f39d2463243e0cdf7d0ba676da0dd501a8c08 Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 14:56:06 +0300 Subject: [PATCH 3/7] feat(metrics): time requests and expose /metrics via handle hook --- src/hooks.server.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/hooks.server.ts | 20 ++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/hooks.server.test.ts create mode 100644 src/hooks.server.ts 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; +}; From 62cdf8c309a83e753b2ee3aa5864f9a3d785e9ed Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 16:21:47 +0300 Subject: [PATCH 4/7] feat(metrics): count demo views by client and name --- .../demo/[client]/[name]/+page.server.ts | 3 ++ src/routes/demo/[client]/[name]/views.test.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/routes/demo/[client]/[name]/views.test.ts diff --git a/src/routes/demo/[client]/[name]/+page.server.ts b/src/routes/demo/[client]/[name]/+page.server.ts index 65d337c..3ce3618 100644 --- a/src/routes/demo/[client]/[name]/+page.server.ts +++ b/src/routes/demo/[client]/[name]/+page.server.ts @@ -1,4 +1,5 @@ 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,6 +11,8 @@ export async function load({ params }): Promise<{ }> { const { client, name: demoName } = params; + demoViewsTotal.inc({ client, name: demoName }); + const demoMetadata = (await getDemoIndex())[client][demoName]; const files = await Promise.all( 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..e19d750 --- /dev/null +++ b/src/routes/demo/[client]/[name]/views.test.ts @@ -0,0 +1,31 @@ +// @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(); + }); +}); From 866bf21137c3b008ee3cc0b877035d71d1566efd Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 16:26:26 +0300 Subject: [PATCH 5/7] feat(helm): scrape metrics via mclabels dependency chart --- helm/Chart.lock | 6 ++++++ helm/Chart.yaml | 4 ++++ helm/charts/mclabels-1.0.1.tgz | Bin 0 -> 1578 bytes helm/templates/_helpers.tpl | 1 + helm/templates/deployment.yaml | 5 +++-- helm/values.yaml | 11 +++++++++++ 6 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 helm/Chart.lock create mode 100644 helm/charts/mclabels-1.0.1.tgz 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 0000000000000000000000000000000000000000..978e6dfdc996c71d9f45c83ca6a534694de77d3a GIT binary patch literal 1578 zcmV+_2G#i=iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PI*_Z`(K$&$B+ooN~Z5K`qH~oD_a5(!-%>ixxC~DuMli}c?Ka9?zli~Tv>EIyhpPUcR4gDo|zRiTIlem3&Z+B6!g6xy;OfoTE_nrc}Y6Z zaYi`?*IJ65>3Agm2j~5E{GXnl?c@JR=-vbH|6T36KLS59rWl2x-Tz*V1*y_7Q8+QZ z=sc`=hyHUbXT#MB$?-MCrsgrj9rMZo3ND?1%M^`l0&iCd;pHkJR8uJWQZhw}KZ0NU zSmuQkB#i$Y`~sDEKe12pR*>>Px$%XP1u4Tw`y;Rp0r(`QOi7Vj!3DEnu{Mf{)Tyh7 zm$_4y(3H3=@>-!bn3B`1WKzb~$V#b`YGg}0>Bo$qp%<8d+l<8-n2aEyg3DRO*Pu!v ztO0JOc{O3f)o-x4eh2U;N(w_FS&jkl#~9zVSP2>&qoyXWQ1Wip)1;=Pn*12!j5_#C zOcZLP%Gi{O{QAR#3$t_%oJVVp*W3CPUgu1_C6SuUA3^^ib$X@^P&`_92#eE`=LV8kytcSTcmx12iCSg>L(@ z?m)`)E14q`J9477-jGtrL=Oe>nP&Q7ga7yX5)x;{#=DMOy`Xmb*Rcuk-P)=AR9RgW(qcUPLdVhupJUXH5dR zNLbq(y(^@qWQ$YiqO#Me762FH4Jl{9ui>_>@xxkwd%&gxXtM8cz-u|OUVIj~`m}~w zF4N0cVPReB0Bsv>+t6fWjU3=I1zpeBiK50-YoPTOKFq!O_w~oi{?AZexDX9LXT%Fq zIxxj<&VrBhe<#uKyw(2=2g9>{|MwJhe;*!u7xoC}N^~y8;;d$Ih@8XC@*KCS;o3V6 z=X3A=z6S|Sm>}>MKngT*S5>MG&xc+;kVFFWx##U(BX4NRv54y)Ghi}ov|}TuF|ZYP$) z(165Oz@zcU;JY!>m}3P=HgN}#XItHEbSFn0`@j4eELwp1+-q=|3|(5M5%@-x$Nv8g zPd4iR+5Z2}(@-;6cGSdPm69JW3eA5l%iOw=cA9@imPwy$vQt0nNWt2OBz&qlq&n z(&yEm9yYB{{;03QP zKVKTD$UB6Ksw=#@xP*i$?FA__p}SV*d%^fO6}sz!k)@%%EME0ggsX|h7~hlyOc*Ea z9S8ch@Q#Bq-gw7>$?daLEcK56@DAY$6_cfg*RTGoJ=;iKQ*69|C4^x$T*+%MnCe(2 c6#hI}U{8D6(?5~^3;+QC|4J&)MF1iI0A>RcGXMYp literal 0 HcmV?d00001 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/values.yaml b/helm/values.yaml index 7e0e30d..4604578 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -16,6 +16,17 @@ 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 + s3: url: 'http://localhost:9000' bucket: 'temp' From 546db8ee9d80873c219f5883172c3bb1ec208e6d Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 16:42:57 +0300 Subject: [PATCH 6/7] feat(grafana): ship maps-playground dashboard as configmap --- grafana/maps-playground-dashboard.json | 95 +++++++++++++++++++ helm/grafana/maps-playground-dashboard.json | 95 +++++++++++++++++++ .../grafana-dashboard-configmap.yaml | 12 +++ helm/values.yaml | 6 ++ 4 files changed, 208 insertions(+) create mode 100644 grafana/maps-playground-dashboard.json create mode 100644 helm/grafana/maps-playground-dashboard.json create mode 100644 helm/templates/grafana-dashboard-configmap.yaml 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/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/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 4604578..d721476 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -27,6 +27,12 @@ mclabels: path: /metrics logScraping: false +metrics: + grafanaDashboard: + enabled: true + label: grafana_dashboard + value: "1" + s3: url: 'http://localhost:9000' bucket: 'temp' From 60f4ae5ba3dc8805b8e728b53a8845c738edb1e9 Mon Sep 17 00:00:00 2001 From: shimoncohen Date: Tue, 21 Jul 2026 17:08:22 +0300 Subject: [PATCH 7/7] fix(metrics): only count validated demo views; guard dashboard drift Increment demo_views_total after resolving the demo, so bogus /demo/*/* paths (SvelteKit runs page + layout load concurrently, so the layout 404 does not stop the page load) can't create unbounded label series, and failed S3 fetches no longer count as views. Add a test pinning the two grafana dashboard copies in sync. --- helm/grafana/dashboard-sync.test.ts | 11 +++++++++++ src/routes/demo/[client]/[name]/+page.server.ts | 8 ++++++-- src/routes/demo/[client]/[name]/views.test.ts | 6 ++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 helm/grafana/dashboard-sync.test.ts 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/src/routes/demo/[client]/[name]/+page.server.ts b/src/routes/demo/[client]/[name]/+page.server.ts index 3ce3618..e7d171c 100644 --- a/src/routes/demo/[client]/[name]/+page.server.ts +++ b/src/routes/demo/[client]/[name]/+page.server.ts @@ -1,3 +1,4 @@ +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'; @@ -11,9 +12,12 @@ export async function load({ params }): Promise<{ }> { const { client, name: demoName } = params; - demoViewsTotal.inc({ client, name: demoName }); + const demoMetadata = (await getDemoIndex())[client]?.[demoName]; + if (!demoMetadata) { + throw error(404, `no demo named ${demoName} found in client ${client}`); + } - const demoMetadata = (await getDemoIndex())[client][demoName]; + 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 index e19d750..6168623 100644 --- a/src/routes/demo/[client]/[name]/views.test.ts +++ b/src/routes/demo/[client]/[name]/views.test.ts @@ -28,4 +28,10 @@ describe('demo view counter', () => { 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); + }); });