diff --git a/docs/design/curated-pack-1471-kubernetes-apps.md b/docs/design/curated-pack-1471-kubernetes-apps.md new file mode 100644 index 00000000..1b1b2e54 --- /dev/null +++ b/docs/design/curated-pack-1471-kubernetes-apps.md @@ -0,0 +1,56 @@ +# Curated Pack — Grafana 1471 "Kubernetes App Metrics" + +> Design + living discoveries for the 1471 curated pack. Follows the general +> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on 315 +> (cAdvisor) and 741 (Deployment metrics). + +- Source: community **"Kubernetes App Metrics"**, + +- gnetId **1471**, only revision **1**. +- canonical sha256 (rev 1) = `27552f4c9ba5ce3e43ae7c962c98c6979e87e1e4c2f4d463f26df62652cabc52`. +- Datasource: Prometheus — curated-pack eligible. +- Schema: **v14 (`rows[]`)**. 13 leaf panels. +- Metric family: **cAdvisor** (`container_*`, `container_spec_*`) **and** + app HTTP (`http_requests_total`, `nginx_http_*`, `haproxy_backend_http_*`). +- Variables: `$namespace` = + `label_values(container_memory_usage_bytes{…container_name!="POD"}, namespace)`; + `$container` = + `label_values(…{namespace=~"$namespace",container_name!="POD"}, container_name)`. + HTTP panels also filter `app="$container"` (app name == container name). + +## Goal + +Ship a curated pack so 1471 renders in Kibana against a modern cAdvisor scrape +plus optional native/nginx/haproxy HTTP metrics, with working namespace and +container controls. + +## The core problem + +| Dashboard convention | Modern reality | Pack handling | +|---|---|---| +| `container_name` / `pod_name` | cAdvisor `container` / `pod` | `label_rewrites` | +| `kubernetes_io_hostname` | scrape `instance` | rewrite → `instance` | +| HTTP `kubernetes_namespace` | `namespace` | rewrite → `namespace` | +| `app="$container"` | app label equals container name | HTTP ES\|QL binds `?container` to `app` | +| nginx grouped by `status`, native/haproxy by `code` | engine drops nginx | curated ES\|QL; per-doc `series` like `native \| 200` then `STATS rate BY time_bucket, series` so Lens matches Grafana legendFormat | +| `histogram_quantile(*_bucket)` | no classic-histogram field | `PERCENTILE` of duration gauge | +| per-pod `by (id, pod_name)` + limit/request lines | Lens XY one breakdown | group by `pod`; drop reference lines | + +## Engine vs pack split + +Pipeline already handles irate/rate, `rows[]` → sections, flattening +single-panel legacy rows, and control synthesis. The pack carries the Heapster +label bridge, populate rewrites, HTTP named series, ratio ES|QL, and +`LAST_OVER_TIME` gauges (raw `SUM` of gauge docs over-counts). + +## Fidelity + +- **PERFECT**: request/error rates, pod/host counts, usage-vs-request/limit, + avg-per-pod and total CPU/memory. +- **APPROXIMATE**: response-time percentiles (PERCENTILE vs histogram_quantile); + per-pod CPU/memory (reference lines dropped). + +## Validation + +Shared curated rig `k8s_exporter.py` emits cAdvisor spec/user/system + HTTP +series (`app` = container name) into `metrics-k8s.prometheus-default`. diff --git a/docs/design/curated-pack-3831-kubernetes-autoscaler.md b/docs/design/curated-pack-3831-kubernetes-autoscaler.md new file mode 100644 index 00000000..91a6d50e --- /dev/null +++ b/docs/design/curated-pack-3831-kubernetes-autoscaler.md @@ -0,0 +1,48 @@ +# Curated Pack — Grafana 3831 "Kubernetes Cluster Autoscaler (via Prometheus)" + +> Design + living discoveries for the 3831 curated pack. Follows the general +> Curation Playbook in `curated-dashboard-packs-plan.md`. Stacks on the +> Kubernetes 315 / 6417 / 741 / 8171 packs. + +- Source: community **"Kubernetes Cluster Autoscaler (via Prometheus)"**, + +- gnetId **3831**, only revision **1**. +- canonical sha256 (rev 1) = `fbfc5ff33d138f2a449d4d28b0c0dcd09d351f08a2c78b91e3455d00e2b0b597`. +- Datasource: Prometheus — curated-pack eligible. +- Schema: **v14 (`rows[]`)**. 10 leaf panels. No template variables. +- Metric family: **cluster-autoscaler** `/metrics` + (`cluster_autoscaler_nodes_count`, `*_last_activity`, `*_total` counters). + +## Goal + +Ship a curated pack so 3831 renders in Kibana against a cluster-autoscaler +scrape. Force ES|QL for every panel (native PROMQL `LAST(value, step)` is +empty in Lens on prometheus_native ingest) and polish KPI titles for Kibana +tiles. + +## Engine vs pack split + +Pipeline already handles `rows[]` → sections, flattening Grafana's placeholder +"New row", and singlestat reducers. The pack pins metric kinds, emits ES|QL +for `time()-last_activity` (`DATE_DIFF`), named Activity/Autoscaling series, +and the ready/total + scaled_up−scaled_down KPIs. + +Grafana plots `*_total` counters with bare `sum()` (no `rate()`). Kibana +matches that with `LAST_OVER_TIME`, not `RATE`. + +Grafana's placeholder row `"New row"` is flattened to top-level panels. +Those must sit at layout `y: 2` (after Info `y=0` and Activity `y=1`) or +they overlap the Info KPIs. Nodes available is `ready/total * 100` so the +Kibana number+% tile shows 100%, not 1.00%. + +## Fidelity + +- **PERFECT**: Total nodes, Nodes available, last-activity tiles, Pod/Node/ + Autoscaling activity graphs. +- **APPROXIMATE**: Safe to autoscale (0/1, no Yes/No value map), shortened + Unscheduled pods / Net scaled nodes titles. + +## Validation + +Shared curated rig `k8s_exporter.py` emits `cluster_autoscaler_*` into +`metrics-k8s.prometheus-default`. diff --git a/docs/sources/grafana.md b/docs/sources/grafana.md index f6637867..75b4dfe0 100644 --- a/docs/sources/grafana.md +++ b/docs/sources/grafana.md @@ -280,6 +280,32 @@ ES|QL (`time_bucket` + `labels.device`, `$server` bound) because native PROMQL "No results found". `$server` stays `label_values(node_boot_time_seconds, instance)`. +The Kubernetes Cluster Autoscaler (3831) pack targets cluster-autoscaler +`/metrics`. Grafana plots several `*_total` counters with bare `sum()` (no +`rate()`), so Kibana keeps `LAST_OVER_TIME` cumulative totals rather than +`RATE`. Every panel is curated ES|QL because native PROMQL `LAST(value, step)` +is empty in Lens on prometheus_native ingest. `time()-last_activity` becomes +`DATE_DIFF` seconds. Nodes available is `ready/total * 100` (Grafana percent +0–100) so the Kibana number+% tile shows 100%, not 1%. The placeholder +Grafana row `"New row"` flattens to top-level panels placed at `y: 2` after +the Info and Activity sections. KPI chrome titles are shortened for Kibana +tiles (Safe to autoscale, Unscheduled pods, Since scale-down/autoscale, Net +scaled nodes); the 0/1 safe-to-scale gauge has no Yes/No value map. + +The Kubernetes App Metrics (1471) pack is a pre-1.16 cAdvisor + app-HTTP mix. +Heapster labels (`container_name`, `pod_name`, `kubernetes_io_hostname`) and +HTTP `kubernetes_namespace` rewrite to canonical `container` / `pod` / +`instance` / `namespace`. `$container` is used both as the cAdvisor container +and as HTTP `app` (dashboard convention: `app` equals container name). The +pack plugin rewrites the populate queries off `container_name`. Request rate +restores the nginx series the engine drops (status vs code grouping) as +Grafana-style `native | 200` / `nginx | 500` / `haproxy | 2xx` series (one +Lens breakdown, not a cartesian Native-by-code legend). nginx/haproxy metrics are +`live_optional`. Per-pod graphs group by `pod` rather than `(id, pod_name)` +and drop Grafana's limit/request reference lines (Lens XY has one breakdown). +Response-time panels approximate `histogram_quantile` with `PERCENTILE` of +the duration gauge. + Each pack is registered in `curated_packs/registry.yaml` with a `gnet_revision` and `dashboard_sha256` — maintainer-verified provenance pins recording the exact grafana.com revision the pack authors read, re-checkable diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml new file mode 100644 index 00000000..b544e86d --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/fidelity_manifest.yaml @@ -0,0 +1,71 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Fidelity manifest — Grafana dashboard 1471 (Kubernetes App Metrics) +# https://grafana.com/grafana/dashboards/1471-kubernetes-apps/ +# +# PERFECT = same information as Grafana on modern cAdvisor + app HTTP scrape. +# APPROXIMATE = documented delta (histogram_quantile, dropped reference lines). + +# Field-profile portable: pack.yaml authors canonical names; the resolver +# namespaces per --field-profile. Verified by the cross-profile leakage gate. + +schema_version: 1 +gnet_id: 1471 +gnet_revision: 1 +dashboard_title: "Kubernetes App Metrics" +maintainer: "community" + +panels: + - title: "Request rate" + fidelity: PERFECT + notes: "irate of native/nginx/haproxy request counters. Nginx restored (engine dropped it for status vs code grouping). One rate series per source+status (native | 200 / nginx | 500 / haproxy | 2xx) so Lens breaks down on `series` instead of a cartesian source x code legend; $container binds app, $namespace binds namespace." + - title: "Error rate" + fidelity: PERFECT + notes: "5xx / total per native (code=~5xx, handler!=prometheus), nginx (status=~5xx), haproxy (code=5xx) as named series." + - title: "Response time percentiles" + fidelity: APPROXIMATE + notes: "Grafana histogram_quantile on *_bucket; Kibana PERCENTILE of the duration gauge (t-digest scrape samples)." + - title: "Number of pods" + fidelity: PERFECT + notes: "COUNT_DISTINCT(pod) / COUNT_DISTINCT(instance) replacing nested count(count()) by pod_name / kubernetes_io_hostname." + - title: "Cpu usage (relative to request)" + fidelity: PERFECT + notes: "irate(cpu_system|user) / (cpu_shares/1024) as system/user series." + - title: "Cpu usage (relative to limit)" + fidelity: PERFECT + notes: "irate(cpu_usage) / (quota/period). Curated ES|QL; native PROMQL LAST step is empty in Lens." + - title: "Memory usage (relative to limit)" + fidelity: PERFECT + notes: "working memory_usage / spec_memory_limit LAST_OVER_TIME ratio." + - title: "Cpu usage (per pod)" + fidelity: APPROXIMATE + notes: "Per-pod IRATE by labels.pod. Grafana also plotted limit/request reference lines; Lens XY has one breakdown so those lines are dropped." + - title: "Memory usage (per pod)" + fidelity: APPROXIMATE + notes: "Per-pod LAST_OVER_TIME by labels.pod. Limit reference line dropped for the same Lens breakdown reason." + - title: "Cpu usage (avg per pod)" + fidelity: PERFECT + notes: "total CPU / COUNT_DISTINCT(pod) plus per-pod-average limit (quota/period) and request (shares/1024)." + - title: "Memory usage (avg per pod)" + fidelity: PERFECT + notes: "SUM(memory)/COUNT_DISTINCT(pod) and SUM(limit)/COUNT_DISTINCT(pod)." + - title: "Cpu usage (total)" + fidelity: PERFECT + notes: "IRATE cpu plus limit=quota/period and request=shares/1024 as named series." + - title: "Memory usage (total)" + fidelity: PERFECT + notes: "LAST_OVER_TIME memory vs spec limit." + +summary: + total_panels: 13 + perfect: 10 + approximate: 3 + gap: 0 + notes: > + $namespace / $container populate from container_memory_usage_bytes with + canonical container/namespace labels (plugin rewrites container_name). + HTTP panels bind $container to app (dashboard convention: app == container + name) and $namespace after kubernetes_namespace → namespace. nginx/haproxy + series are live_optional. Detailed usage rows stay collapsed like Grafana; + Request rate, Error rate, latency, pod count, and usage-vs-request stay open. diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml new file mode 100644 index 00000000..bb6698c1 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/pack.yaml @@ -0,0 +1,315 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Curated pack — Grafana dashboard 1471 +# https://grafana.com/grafana/dashboards/1471-kubernetes-apps/ +# +# Source: community "Kubernetes App Metrics". +# Revision 1. schemaVersion 14 (old `rows[]` layout), 13 leaf panels. +# Variables: $namespace, $container (container_name; also used as HTTP `app`). +# Metric family: cAdvisor container_* + container_spec_* AND app HTTP +# (http_requests_total / nginx_http_* / haproxy_backend_http_*). +# +# Authored against a pre-1.16 cAdvisor + Heapster-era label lineage: +# - container_name / pod_name -> modern container / pod +# - kubernetes_io_hostname -> instance +# - kubernetes_namespace (HTTP metrics) -> namespace +# - app="$container" -> HTTP series keyed by app=container +# +# Native PROMQL LAST/GROK step is empty in Lens on prometheus_native ingest, so +# every panel is curated ES|QL. nginx/haproxy are live_optional so a native-only +# scrape still renders the remaining series. +# +# Engine vs pack split: +# Engine: irate/rate, rows[] → sections, single-panel-row flatten, control +# synthesis, histogram_quantile → PERCENTILE disclosure. +# Pack: Heapster label bridge, control populate rewrite, HTTP named series +# (native/nginx/haproxy, nginx restored), ratio ES|QL, per-pod +# breakdown on pod not (id,pod_name), LAST_OVER_TIME gauges. + +query: + metrics_dataset_filter: "prometheus" + + metric_kinds: + http_requests_total: counter + nginx_http_requests_total: counter + haproxy_backend_http_responses_total: counter + http_request_duration_seconds_bucket: counter + nginx_http_request_duration_seconds_bucket: counter + http_request_duration_seconds: gauge + nginx_http_request_duration_seconds: gauge + container_cpu_usage_seconds_total: counter + container_cpu_system_seconds_total: counter + container_cpu_user_seconds_total: counter + container_memory_usage_bytes: gauge + container_spec_cpu_shares: gauge + container_spec_cpu_quota: gauge + container_spec_cpu_period: gauge + container_spec_memory_limit_bytes: gauge + + label_rewrites: + container_name: container + pod_name: pod + kubernetes_io_hostname: instance + kubernetes_namespace: namespace + + source_label_names: + container: container_name + pod: pod_name + instance: kubernetes_io_hostname + + live_optional_metrics: + - nginx_http_requests_total + - haproxy_backend_http_responses_total + - nginx_http_request_duration_seconds + - nginx_http_request_duration_seconds_bucket + - http_request_duration_seconds_bucket + +controls: + field_overrides: + namespace: namespace + container: container + +panel: + query_overrides: + - title_match: "Request rate" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{metric:http_requests_total:counter}} IS NOT NULL OR {{metric:nginx_http_requests_total:counter}} IS NOT NULL OR {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL + | EVAL series = CASE( + {{metric:http_requests_total:counter}} IS NOT NULL AND ({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL), CONCAT("native | ", COALESCE(TO_STRING({{label:code}}), "unknown")), + {{metric:nginx_http_requests_total:counter}} IS NOT NULL, CONCAT("nginx | ", COALESCE(TO_STRING({{label:status}}), "unknown")), + {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL, CONCAT("haproxy | ", COALESCE(TO_STRING({{label:code}}), "unknown")), + NULL) + | WHERE series IS NOT NULL + | STATS native = SUM(IRATE({{metric:http_requests_total:counter}})), nginx = SUM(IRATE({{metric:nginx_http_requests_total:counter}})), haproxy = SUM(IRATE({{metric:haproxy_backend_http_responses_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), series + | EVAL rate = COALESCE(native, 0) + COALESCE(nginx, 0) + COALESCE(haproxy, 0) + | KEEP time_bucket, series, rate + | SORT time_bucket ASC + status_override: migrated + - title_match: "Error rate" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{metric:http_requests_total:counter}} IS NOT NULL OR {{metric:nginx_http_requests_total:counter}} IS NOT NULL OR {{metric:haproxy_backend_http_responses_total:counter}} IS NOT NULL + | STATS native_5xx = SUM(CASE(({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL) AND {{label:code}} RLIKE "5[0-9]+", IRATE({{metric:http_requests_total:counter}}), NULL)), native_all = SUM(CASE(({{label:handler}} != "prometheus" OR {{label:handler}} IS NULL), IRATE({{metric:http_requests_total:counter}}), NULL)), nginx_5xx = SUM(CASE({{label:status}} RLIKE "5[0-9]+", IRATE({{metric:nginx_http_requests_total:counter}}), NULL)), nginx_all = SUM(IRATE({{metric:nginx_http_requests_total:counter}})), haproxy_5xx = SUM(CASE({{label:code}} == "5xx", IRATE({{metric:haproxy_backend_http_responses_total:counter}}), NULL)), haproxy_all = SUM(IRATE({{metric:haproxy_backend_http_responses_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL Native = CASE(native_all > 0, native_5xx / native_all, NULL) + | EVAL Nginx = CASE(nginx_all > 0, nginx_5xx / nginx_all, NULL) + | EVAL HAProxy = CASE(haproxy_all > 0, haproxy_5xx / haproxy_all, NULL) + | KEEP time_bucket, Native, Nginx, HAProxy + | SORT time_bucket ASC + status_override: migrated + - title_match: "Response time percentiles" + kibana_type_override: line + approximation_note: "Grafana histogram_quantile on *_bucket series; Kibana plots PERCENTILE of the duration gauge (t-digest scrape samples, not Prometheus bucket interpolation)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:app}} RLIKE ?container OR ({{label:app}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{metric:http_request_duration_seconds:gauge}} IS NOT NULL OR {{metric:nginx_http_request_duration_seconds:gauge}} IS NOT NULL OR {{metric:http_request_duration_seconds_bucket:counter}} IS NOT NULL + | STATS native_p99 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 99), native_p90 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 90), native_p50 = PERCENTILE({{metric:http_request_duration_seconds:gauge}}, 50), nginx_p99 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 99), nginx_p90 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 90), nginx_p50 = PERCENTILE({{metric:nginx_http_request_duration_seconds:gauge}}, 50) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, native_p99, native_p90, native_p50, nginx_p99, nginx_p90, nginx_p50 + | SORT time_bucket ASC + - title_match: "Number of pods" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL AND {{label:container}} != "POD" + | STATS pods = COUNT_DISTINCT({{label:pod}}), hosts = COUNT_DISTINCT({{label:instance}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, pods, hosts + | SORT time_bucket ASC + status_override: migrated + - title_match: "Cpu usage (relative to request)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_cpu_system_seconds_total:counter}} IS NOT NULL OR {{metric:container_cpu_user_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL + | STATS system_cpu = SUM(IRATE({{metric:container_cpu_system_seconds_total:counter}})), user_cpu = SUM(IRATE({{metric:container_cpu_user_seconds_total:counter}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL system = CASE(shares > 0, system_cpu / (shares / 1024.0), NULL) + | EVAL user = CASE(shares > 0, user_cpu / (shares / 1024.0), NULL) + | KEEP time_bucket, system, user + | SORT time_bucket ASC + status_override: migrated + - title_match: "Cpu usage (relative to limit)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL + | STATS used = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL actual = CASE(quota > 0 AND period > 0, used / (quota / period), NULL) + | KEEP time_bucket, actual + | SORT time_bucket ASC + status_override: migrated + - title_match: "Memory usage (relative to limit)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL + | STATS used = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), total = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL actual = CASE(total > 0, used / total, NULL) + | KEEP time_bucket, actual + | SORT time_bucket ASC + status_override: migrated + - title_match: "Cpu usage (per pod)" + kibana_type_override: line + approximation_note: "Dropped the Grafana limit/request reference lines (Lens XY has one breakdown). Per-pod CPU is grouped by pod rather than (id, pod_name)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" AND {{label:pod}} IS NOT NULL + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_period:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL + | STATS CPU = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, CPU + | SORT time_bucket ASC + - title_match: "Memory usage (per pod)" + kibana_type_override: line + approximation_note: "Dropped the Grafana per-pod memory limit reference line (Lens XY has one breakdown). Grouped by pod rather than (id, pod_name)." + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" AND {{label:pod}} IS NOT NULL + | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL + | STATS Memory = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend), {{label:pod}} + | KEEP time_bucket, `{{label:pod}}`, Memory + | SORT time_bucket ASC + - title_match: "Cpu usage (avg per pod)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL OR {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL + | STATS cpu = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})), pods = COUNT_DISTINCT({{label:pod}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL actual = CASE(pods > 0, cpu / pods, NULL) + | EVAL `limit` = CASE(pods > 0 AND period > 0, (quota / period) / pods, NULL) + | EVAL request = CASE(pods > 0, (shares / 1024.0) / pods, NULL) + | KEEP time_bucket, actual, `limit`, request + | SORT time_bucket ASC + status_override: migrated + - title_match: "Memory usage (avg per pod)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL + | STATS used = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), total = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})), pods = COUNT_DISTINCT({{label:pod}}) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL actual = CASE(pods > 0, used / pods, NULL) + | EVAL `limit` = CASE(pods > 0, total / pods, NULL) + | KEEP time_bucket, actual, `limit` + | SORT time_bucket ASC + status_override: migrated + - title_match: "Cpu usage (total)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_cpu_usage_seconds_total:counter}} IS NOT NULL OR {{metric:container_spec_cpu_shares:gauge}} IS NOT NULL OR {{metric:container_spec_cpu_quota:gauge}} IS NOT NULL + | STATS actual = SUM(IRATE({{metric:container_cpu_usage_seconds_total:counter}})), quota = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_quota:gauge}})), period = MAX(LAST_OVER_TIME({{metric:container_spec_cpu_period:gauge}})), shares = SUM(LAST_OVER_TIME({{metric:container_spec_cpu_shares:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | EVAL `limit` = CASE(period > 0, quota / period, NULL) + | EVAL request = shares / 1024.0 + | KEEP time_bucket, actual, `limit`, request + | SORT time_bucket ASC + status_override: migrated + - title_match: "Memory usage (total)" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE (?container == "" OR ({{label:container}} RLIKE ?container OR ({{label:container}} IS NULL AND "" RLIKE ?container))) + | WHERE (?namespace == "" OR ({{label:namespace}} RLIKE ?namespace OR ({{label:namespace}} IS NULL AND "" RLIKE ?namespace))) + | WHERE {{label:container}} != "POD" + | WHERE {{metric:container_memory_usage_bytes:gauge}} IS NOT NULL OR {{metric:container_spec_memory_limit_bytes:gauge}} IS NOT NULL + | STATS actual = SUM(LAST_OVER_TIME({{metric:container_memory_usage_bytes:gauge}})), `limit` = SUM(LAST_OVER_TIME({{metric:container_spec_memory_limit_bytes:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, actual, `limit` + | SORT time_bucket ASC + status_override: migrated + + layout_overrides: + - title_match: "Request rate" + hide_title: false + legend_position: right + size: {w: 24, h: 16} + - title_match: "Error rate" + hide_title: false + legend_position: right + size: {w: 24, h: 16} + - title_match: "Response time percentiles" + hide_title: false + legend_position: right + size: {w: 48, h: 16} + - title_match: "Number of pods" + hide_title: false + size: {w: 48, h: 12} + - title_match: "Cpu usage (relative to request)" + hide_title: false + legend_position: right + size: {w: 48, h: 14} + - title_match: "Usage relative to limit" + collapsed: true + - title_match: "Cpu usage (relative to limit)" + hide_title: false + size: {w: 24, h: 14} + - title_match: "Memory usage (relative to limit)" + hide_title: false + size: {w: 24, h: 14} + - title_match: "Usage per pod" + collapsed: true + - title_match: "Cpu usage (per pod)" + hide_title: false + legend_position: right + size: {w: 24, h: 14} + - title_match: "Memory usage (per pod)" + hide_title: false + legend_position: right + size: {w: 24, h: 14} + - title_match: "Usage per pod (average)" + collapsed: true + - title_match: "Cpu usage (avg per pod)" + hide_title: false + size: {w: 24, h: 14} + - title_match: "Memory usage (avg per pod)" + hide_title: false + size: {w: 24, h: 14} + - title_match: "Usage total" + collapsed: true + - title_match: "Cpu usage (total)" + hide_title: false + size: {w: 24, h: 14} + - title_match: "Memory usage (total)" + hide_title: false + size: {w: 24, h: 14} diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py new file mode 100644 index 00000000..7e440666 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_1471_kubernetes_app_metrics/plugin.py @@ -0,0 +1,48 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 + +"""Grafana 1471 (Kubernetes App Metrics) curated pack plugin. + +The dashboard's ``$namespace`` / ``$container`` populate queries still use the +pre-1.16 cAdvisor label ``container_name``. Rewrite them onto the canonical +``container`` / ``namespace`` labels so the Kibana controls fill from a modern +scrape. Panel PromQL already goes through ``label_rewrites``; only the +templating queries need this hook. +""" + + +_PACK_NAME = "grafana_1471_kubernetes_app_metrics" + + +def register(api): + @api["variable_translators"].register("grafana_1471_k8s_controls", priority=5) + def rewrite_namespace_and_container_controls(context): + pack = getattr(context, "rule_pack", None) + if getattr(pack, "_curated_pack_name", "") != _PACK_NAME: + return None + variable = context.variable or {} + name = str(variable.get("name") or "") + lname = name.lower() + query_text = context.query_text or str(variable.get("query") or "") + compact = query_text.replace(" ", "").lower() + + if lname == "namespace" and "label_values(" in compact: + rewritten = ( + 'label_values(container_memory_usage_bytes{container!="POD"}, namespace)' + ) + context.query_text = rewritten + context.variable = dict(variable) + context.variable["query"] = rewritten + return None + + if lname == "container" and "label_values(" in compact: + rewritten = ( + 'label_values(container_memory_usage_bytes' + '{namespace=~"$namespace",container!="POD"}, container)' + ) + context.query_text = rewritten + context.variable = dict(variable) + context.variable["query"] = rewritten + return None + + return None diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml new file mode 100644 index 00000000..11c6e874 --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/fidelity_manifest.yaml @@ -0,0 +1,60 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Fidelity manifest — Grafana dashboard 3831 (Kubernetes Cluster Autoscaler) +# https://grafana.com/grafana/dashboards/3831-autoscaler/ +# +# PERFECT = same information as Grafana on a cluster-autoscaler /metrics scrape. +# APPROXIMATE = documented Kibana-better title polish; query semantics match. + +# Field-profile portable: pack.yaml authors canonical names; the resolver +# namespaces per --field-profile. Verified by the cross-profile leakage gate. + +schema_version: 1 +gnet_id: 3831 +gnet_revision: 1 +dashboard_title: "Kubernetes Cluster Autoscaler (via Prometheus)" +maintainer: "community" + +panels: + - title: "Total nodes" + fidelity: PERFECT + notes: "sum(cluster_autoscaler_nodes_count) as a metric tile. Curated ES|QL LAST_OVER_TIME (native PROMQL LAST step is empty in Lens)." + - title: "Nodes available" + fidelity: PERFECT + notes: "ready/total * 100 as a percent gauge (Grafana percent 0-100). Kibana number+% tiles need percent-points, not a 0-1 ratio." + - title: "Is cluster safe to scale?" + fidelity: APPROXIMATE + notes: "0/1 gauge (Grafana value-mapped Yes/No). Layout title Safe to autoscale so the tile fits Kibana chrome. Kibana metric/gauge has no Yes/No value map." + - title: "Number of unscheduled pods" + fidelity: APPROXIMATE + notes: "Same unschedulable_pods_count sum; layout title Unscheduled pods." + - title: "Last scaleDown activity" + fidelity: PERFECT + notes: "time()-last_activity{activity=scaleDown} as DATE_DIFF seconds, duration-formatted. Layout title Since scale-down." + - title: "Last autoscale activity" + fidelity: PERFECT + notes: "Same DATE_DIFF for activity=autoscaling. Layout title Since autoscale." + - title: "Pod activity" + fidelity: PERFECT + notes: "Cumulative evicted pods (counter without rate, matching Grafana) plus unschedulable gauge, named series." + - title: "Node activity" + fidelity: PERFECT + notes: "ready / unready / notStarted node counts as named series." + - title: "Autoscaling activity" + fidelity: PERFECT + notes: "scaled_up_nodes_total (cumulative), unneeded_nodes_count, total nodes — Grafana's mixed cumulative+gauge graph, named series." + - title: "Cluster direction?" + fidelity: APPROXIMATE + notes: "scaled_up_total - scaled_down_total. Layout title Net scaled nodes." + +summary: + total_panels: 10 + perfect: 7 + approximate: 3 + gap: 0 + notes: > + No dashboard variables. All ten panels are curated ES|QL so they render on + prometheus_native ingest (Lens does not paint native PROMQL LAST/GROK step). + KPI titles are shortened for Kibana tiles; Safe to autoscale stays 0/1 + because Kibana has no Grafana value map. diff --git a/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml new file mode 100644 index 00000000..992d923d --- /dev/null +++ b/observability_migration/adapters/source/grafana/curated_packs/grafana_3831_kubernetes_autoscaler/pack.yaml @@ -0,0 +1,185 @@ +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. +# SPDX-License-Identifier: Elastic-2.0 +# +# Curated pack — Grafana dashboard 3831 +# https://grafana.com/grafana/dashboards/3831-autoscaler/ +# +# Source: community "Kubernetes Cluster Autoscaler (via Prometheus)". +# Revision 1. schemaVersion 14 (old `rows[]` layout), 10 leaf panels, no +# template variables. Metric family: cluster-autoscaler /metrics. +# +# Grafana plots several *_total counters with bare sum() (no rate()), so the +# graphs are cumulative totals — match that with LAST_OVER_TIME, not RATE. +# Native PROMQL LAST(value, step) is empty in Lens XY/metric on prometheus_native +# ingest (same Lens gap as 8171 Network), so every panel is curated ES|QL. +# +# Engine vs pack split: +# Engine: rows[] → sections, singlestat reducers, "New row" flatten. +# Pack: metric_kinds, ES|QL KPIs (incl. time()-last_activity → DATE_DIFF), +# named Activity/Autoscaling series, Kibana-native KPI titles. + +query: + metrics_dataset_filter: "prometheus" + + metric_kinds: + cluster_autoscaler_nodes_count: gauge + cluster_autoscaler_cluster_safe_to_autoscale: gauge + cluster_autoscaler_unschedulable_pods_count: gauge + cluster_autoscaler_last_activity: gauge + cluster_autoscaler_unneeded_nodes_count: gauge + cluster_autoscaler_evicted_pods_total: counter + cluster_autoscaler_scaled_up_nodes_total: counter + cluster_autoscaler_scaled_down_nodes_total: counter + +panel: + query_overrides: + - title_match: "Total nodes" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Nodes available" + kibana_type_override: gauge + # No primary_format: the query pre-scales ready/total to 0-100 and the + # source Grafana `percent` unit already renders number+"%" (percentunit + # would imply a 0-1 ratio and double-scale if ever applied to a primary). + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL + | STATS ready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "ready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), total = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}})) + | EVAL computed_value = CASE(total > 0, ready / total * 100, NULL) + | WHERE computed_value IS NOT NULL + | KEEP computed_value + status_override: migrated + - title_match: "Is cluster safe to scale?" + kibana_type_override: gauge + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_cluster_safe_to_autoscale:gauge}} IS NOT NULL + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_cluster_safe_to_autoscale:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Number of unscheduled pods" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_unschedulable_pods_count:gauge}} IS NOT NULL + | STATS computed_value = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unschedulable_pods_count:gauge}})) + | KEEP computed_value + status_override: migrated + - title_match: "Last scaleDown activity" + primary_format: s + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:activity}} == "scaleDown" + | WHERE {{metric:cluster_autoscaler_last_activity:gauge}} IS NOT NULL + | STATS last_ts = MAX(LAST_OVER_TIME({{metric:cluster_autoscaler_last_activity:gauge}})) + | EVAL computed_value = DATE_DIFF("seconds", TO_DATETIME(last_ts * 1000), NOW()) + | KEEP computed_value + status_override: migrated + - title_match: "Last autoscale activity" + primary_format: s + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{label:activity}} == "autoscaling" + | WHERE {{metric:cluster_autoscaler_last_activity:gauge}} IS NOT NULL + | STATS last_ts = MAX(LAST_OVER_TIME({{metric:cluster_autoscaler_last_activity:gauge}})) + | EVAL computed_value = DATE_DIFF("seconds", TO_DATETIME(last_ts * 1000), NOW()) + | KEEP computed_value + status_override: migrated + - title_match: "Pod activity" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_evicted_pods_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_unschedulable_pods_count:gauge}} IS NOT NULL + | STATS `evicted pods` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_evicted_pods_total:counter}})), `unscheduled pods` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unschedulable_pods_count:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, `evicted pods`, `unscheduled pods` + | SORT time_bucket ASC + status_override: migrated + - title_match: "Node activity" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL + | STATS ready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "ready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), unready = SUM(LAST_OVER_TIME(CASE({{label:state}} == "unready", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))), `not started` = SUM(LAST_OVER_TIME(CASE({{label:state}} == "notStarted", {{metric:cluster_autoscaler_nodes_count:gauge}}, NULL))) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, ready, unready, `not started` + | SORT time_bucket ASC + status_override: migrated + - title_match: "Autoscaling activity" + kibana_type_override: line + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_scaled_up_nodes_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_unneeded_nodes_count:gauge}} IS NOT NULL OR {{metric:cluster_autoscaler_nodes_count:gauge}} IS NOT NULL + | STATS `scaled up total` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_up_nodes_total:counter}})), `unneeded nodes` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_unneeded_nodes_count:gauge}})), `total nodes` = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_nodes_count:gauge}})) BY time_bucket = TBUCKET(20, ?_tstart, ?_tend) + | KEEP time_bucket, `scaled up total`, `unneeded nodes`, `total nodes` + | SORT time_bucket ASC + status_override: migrated + - title_match: "Cluster direction?" + esql_query: | + TS metrics-* + | WHERE @timestamp >= ?_tstart AND @timestamp <= ?_tend + | WHERE {{metric:cluster_autoscaler_scaled_up_nodes_total:counter}} IS NOT NULL OR {{metric:cluster_autoscaler_scaled_down_nodes_total:counter}} IS NOT NULL + | STATS scaled_up = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_up_nodes_total:counter}})), scaled_down = SUM(LAST_OVER_TIME({{metric:cluster_autoscaler_scaled_down_nodes_total:counter}})) + | EVAL computed_value = scaled_up - scaled_down + | KEEP computed_value + status_override: migrated + + layout_overrides: + - title_match: "Total nodes" + hide_title: false + size: {w: 8, h: 10} + position: {x: 0, y: 0} + - title_match: "Nodes available" + hide_title: false + size: {w: 8, h: 10} + position: {x: 8, y: 0} + - title_match: "Is cluster safe to scale?" + title: "Safe to autoscale" + hide_title: false + size: {w: 8, h: 10} + position: {x: 16, y: 0} + - title_match: "Number of unscheduled pods" + title: "Unscheduled pods" + hide_title: false + size: {w: 8, h: 10} + position: {x: 24, y: 0} + - title_match: "Last scaleDown activity" + title: "Since scale-down" + hide_title: false + size: {w: 8, h: 10} + position: {x: 32, y: 0} + - title_match: "Last autoscale activity" + title: "Since autoscale" + hide_title: false + size: {w: 8, h: 10} + position: {x: 40, y: 0} + - title_match: "Pod activity" + hide_title: false + size: {w: 24, h: 14} + position: {x: 0, y: 0} + - title_match: "Node activity" + hide_title: false + size: {w: 24, h: 14} + position: {x: 24, y: 0} + # Flattened off Grafana's placeholder "New row". Top-level items share a + # y-order with sections (Info=0, Activity=1), so these must sit at y=2 + # or they overlap the Info KPIs. + - title_match: "Autoscaling activity" + hide_title: false + size: {w: 36, h: 14} + position: {x: 0, y: 2} + - title_match: "Cluster direction?" + title: "Net scaled nodes" + hide_title: false + size: {w: 12, h: 14} + position: {x: 36, y: 2} diff --git a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml index f45f5fee..67d1ae6c 100644 --- a/observability_migration/adapters/source/grafana/curated_packs/registry.yaml +++ b/observability_migration/adapters/source/grafana/curated_packs/registry.yaml @@ -163,3 +163,21 @@ packs: gnet_revision: 1 dashboard_sha256: "5d580022bf35bc2cbe42056affd0a1670aa2adb0afd6942f2e642c550a6b29b2" description: "Kubernetes Nodes (node_exporter 0.16+) — CPU Busy rename, nfsd→node_disk I/O map, instance-pinned $server" + + - gnet_id: 3831 + name: grafana_3831_kubernetes_autoscaler + title_hint: "Kubernetes Cluster Autoscaler (via Prometheus)" + tags_hint: ["prometheus"] + path: grafana_3831_kubernetes_autoscaler + gnet_revision: 1 + dashboard_sha256: "fbfc5ff33d138f2a449d4d28b0c0dcd09d351f08a2c78b91e3455d00e2b0b597" + description: "Kubernetes Cluster Autoscaler — ES|QL KPIs (time()-last_activity), named activity series, Kibana tile titles" + + - gnet_id: 1471 + name: grafana_1471_kubernetes_app_metrics + title_hint: "Kubernetes App Metrics" + tags_hint: [] + path: grafana_1471_kubernetes_app_metrics + gnet_revision: 1 + dashboard_sha256: "27552f4c9ba5ce3e43ae7c962c98c6979e87e1e4c2f4d463f26df62652cabc52" + description: "Kubernetes App Metrics — Heapster label remap, $namespace/$container populate, HTTP native/nginx/haproxy series, cAdvisor ratios" diff --git a/observability_migration/adapters/source/grafana/panels.py b/observability_migration/adapters/source/grafana/panels.py index 8c281d73..68882f00 100644 --- a/observability_migration/adapters/source/grafana/panels.py +++ b/observability_migration/adapters/source/grafana/panels.py @@ -11219,15 +11219,16 @@ def _clear_duplicate_inner_title_label(panel: dict) -> None: primary = esql.get("primary") if isinstance(primary, dict): label = str(primary.get("label") or "").strip() - if label.casefold() in {title.casefold(), "value", "computed_value"}: - # Blank, not omitted: Lens falls back to the field name - # (``value`` / ``computed_value``) when the label key is missing. + # Blank, not omitted: Lens falls back to the field name + # (``value`` / ``computed_value``) when the label key is missing. + # Flattened metric tiles often arrive with no label at all. + if not label or label.casefold() in {title.casefold(), "value", "computed_value"}: primary["label"] = " " elif chart_type == "gauge": metric = esql.get("metric") if isinstance(metric, dict): label = str(metric.get("label") or "").strip() - if label.casefold() in {title.casefold(), "value", "computed_value"}: + if not label or label.casefold() in {title.casefold(), "value", "computed_value"}: metric["label"] = " " diff --git a/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml b/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml index 73081078..f54e8f55 100644 --- a/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml +++ b/parity-rig/curated/grafana_763_redis_exporter/docker-compose.yml @@ -384,8 +384,9 @@ services: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=1d - # Synthetic Kubernetes exporter (cAdvisor + kube-state-metrics + node_exporter) - # so the Kubernetes curated packs (Grafana 315 + 6417) validate on real data. + # Synthetic Kubernetes exporter (cAdvisor + kube-state-metrics + node_exporter + # + cluster-autoscaler + app HTTP) so the Kubernetes curated packs + # (Grafana 315, 6417, 741, 3831, 1471) validate on real data. k8s_exporter: image: python:3.11-slim container_name: redis-rig-k8s-exporter diff --git a/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py b/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py index 20dd5bbd..08061c1c 100644 --- a/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py +++ b/parity-rig/curated/grafana_763_redis_exporter/k8s_exporter.py @@ -15,6 +15,10 @@ # including kube_deployment_status_replicas_available. Pod names are # `{deployment}-{ordinal}` so Grafana's `pod=~"^$Deployment.*$"` prefix # matches. +# - Grafana 3831 (Cluster Autoscaler): cluster_autoscaler_* gauges/counters. +# - Grafana 1471 (App Metrics): container_memory_usage_bytes, container_cpu_user/ +# system, container_spec_*, plus http_requests_total / nginx / haproxy with +# app=container name so $container binds both cAdvisor and HTTP panels. # # Counters are wall-clock monotonic so RATE()/DELTA() render on the rig. The # `OutOfDisk` node condition is deliberately NOT emitted (removed in k8s 1.12) so @@ -217,6 +221,109 @@ def render() -> str: L.append(f'kube_job_status_active{{namespace="{ns}",job_name="backup"}} 1') L.append(f'kube_job_status_failed{{namespace="{ns}",job_name="backup"}} 0') + # ---- cAdvisor extras for Grafana 1471 (usage + spec + user/system) -- + L.append("# HELP container_memory_usage_bytes Current memory usage") + L.append("# TYPE container_memory_usage_bytes gauge") + L.append("# HELP container_cpu_user_seconds_total Cumulative user cpu time") + L.append("# TYPE container_cpu_user_seconds_total counter") + L.append("# HELP container_cpu_system_seconds_total Cumulative system cpu time") + L.append("# TYPE container_cpu_system_seconds_total counter") + L.append("# HELP container_spec_cpu_shares CPU share (relative weight)") + L.append("# TYPE container_spec_cpu_shares gauge") + L.append("# HELP container_spec_cpu_quota CPU CFS quota period") + L.append("# TYPE container_spec_cpu_quota gauge") + L.append("# HELP container_spec_cpu_period CPU CFS period") + L.append("# TYPE container_spec_cpu_period gauge") + L.append("# HELP container_spec_memory_limit_bytes Memory limit in bytes") + L.append("# TYPE container_spec_memory_limit_bytes gauge") + idx = 0 + for ns, pods in WORKLOADS.items(): + for pod, container in pods: + n = _node_for(idx) + idx += 1 + cgroup_id = f"/kubepods/{pod}/{container}" + base = ( + f'id="{cgroup_id}",namespace="{ns}",pod="{pod}",container="{container}",' + f'image="registry/{container}:latest",name="k8s_{container}_{pod}",instance="{n}"' + ) + cpu = elapsed * (0.05 + 0.02 * (idx % 5)) + mem = int((128 + 40 * (idx % 6)) * 1024**2) + L.append(f"container_memory_usage_bytes{{{base}}} {mem}") + L.append(f"container_cpu_user_seconds_total{{{base}}} {cpu * 0.7:.4f}") + L.append(f"container_cpu_system_seconds_total{{{base}}} {cpu * 0.3:.4f}") + L.append(f"container_spec_cpu_shares{{{base}}} 256") + L.append(f"container_spec_cpu_quota{{{base}}} 50000") + L.append(f"container_spec_cpu_period{{{base}}} 100000") + L.append(f"container_spec_memory_limit_bytes{{{base}}} {512 * 1024**2}") + + # ---- App HTTP metrics for Grafana 1471 (app == container name) ------ + L.append("# HELP http_requests_total Total HTTP requests (native app)") + L.append("# TYPE http_requests_total counter") + L.append("# HELP nginx_http_requests_total Total nginx HTTP requests") + L.append("# TYPE nginx_http_requests_total counter") + L.append("# HELP haproxy_backend_http_responses_total HAProxy backend responses") + L.append("# TYPE haproxy_backend_http_responses_total counter") + L.append("# HELP http_request_duration_seconds Native request duration sample") + L.append("# TYPE http_request_duration_seconds gauge") + L.append("# HELP nginx_http_request_duration_seconds Nginx request duration sample") + L.append("# TYPE nginx_http_request_duration_seconds gauge") + L.append("# HELP http_request_duration_seconds_bucket Native duration histogram") + L.append("# TYPE http_request_duration_seconds_bucket counter") + L.append("# HELP nginx_http_request_duration_seconds_bucket Nginx duration histogram") + L.append("# TYPE nginx_http_request_duration_seconds_bucket counter") + idx = 0 + for ns, pods in WORKLOADS.items(): + for pod, container in pods: + idx += 1 + app = f'app="{container}",namespace="{ns}",kubernetes_namespace="{ns}",pod="{pod}"' + reqs = elapsed * (20 + 5 * (idx % 4)) + L.append(f'http_requests_total{{{app},handler="app",code="200"}} {reqs:.0f}') + L.append(f'http_requests_total{{{app},handler="app",code="500"}} {reqs * 0.02:.0f}') + L.append(f'http_requests_total{{{app},handler="prometheus",code="200"}} {elapsed:.0f}') + L.append(f'nginx_http_requests_total{{{app},status="200"}} {reqs * 0.8:.0f}') + L.append(f'nginx_http_requests_total{{{app},status="500"}} {reqs * 0.01:.0f}') + L.append(f'haproxy_backend_http_responses_total{{{app},code="2xx"}} {reqs * 0.9:.0f}') + L.append(f'haproxy_backend_http_responses_total{{{app},code="5xx"}} {reqs * 0.015:.0f}') + latency = 0.04 + 0.01 * (idx % 5) + L.append(f"http_request_duration_seconds{{{app}}} {latency:.4f}") + L.append(f"nginx_http_request_duration_seconds{{{app}}} {latency * 0.8:.4f}") + for le, frac in (("0.05", 0.4), ("0.1", 0.8), ("+Inf", 1.0)): + L.append( + f'http_request_duration_seconds_bucket{{{app},le="{le}"}} {reqs * frac:.0f}' + ) + L.append( + f'nginx_http_request_duration_seconds_bucket{{{app},le="{le}"}} {reqs * 0.8 * frac:.0f}' + ) + + # ---- Cluster autoscaler (Grafana 3831) ------------------------------ + L.append("# HELP cluster_autoscaler_nodes_count Number of nodes in the cluster") + L.append("# TYPE cluster_autoscaler_nodes_count gauge") + L.append("# HELP cluster_autoscaler_cluster_safe_to_autoscale Whether CA considers the cluster safe") + L.append("# TYPE cluster_autoscaler_cluster_safe_to_autoscale gauge") + L.append("# HELP cluster_autoscaler_unschedulable_pods_count Unschedulable pods") + L.append("# TYPE cluster_autoscaler_unschedulable_pods_count gauge") + L.append("# HELP cluster_autoscaler_last_activity Unix time of last CA activity") + L.append("# TYPE cluster_autoscaler_last_activity gauge") + L.append("# HELP cluster_autoscaler_unneeded_nodes_count Nodes marked unneeded") + L.append("# TYPE cluster_autoscaler_unneeded_nodes_count gauge") + L.append("# HELP cluster_autoscaler_evicted_pods_total Pods evicted by CA") + L.append("# TYPE cluster_autoscaler_evicted_pods_total counter") + L.append("# HELP cluster_autoscaler_scaled_up_nodes_total Nodes added by CA") + L.append("# TYPE cluster_autoscaler_scaled_up_nodes_total counter") + L.append("# HELP cluster_autoscaler_scaled_down_nodes_total Nodes removed by CA") + L.append("# TYPE cluster_autoscaler_scaled_down_nodes_total counter") + L.append(f'cluster_autoscaler_nodes_count{{state="ready"}} {len(NODES)}') + L.append('cluster_autoscaler_nodes_count{state="unready"} 0') + L.append('cluster_autoscaler_nodes_count{state="notStarted"} 0') + L.append("cluster_autoscaler_cluster_safe_to_autoscale 1") + L.append("cluster_autoscaler_unschedulable_pods_count 1") + L.append(f"cluster_autoscaler_last_activity{{activity=\"scaleDown\"}} {now - 180:.0f}") + L.append(f"cluster_autoscaler_last_activity{{activity=\"autoscaling\"}} {now - 45:.0f}") + L.append("cluster_autoscaler_unneeded_nodes_count 0") + L.append(f"cluster_autoscaler_evicted_pods_total {int(elapsed / 30)}") + L.append(f"cluster_autoscaler_scaled_up_nodes_total {5 + int(elapsed / 120)}") + L.append("cluster_autoscaler_scaled_down_nodes_total 2") + # ---- node_exporter: filesystem (modern *_bytes names) --------------- L.append("# HELP node_filesystem_size_bytes Filesystem size in bytes") L.append("# TYPE node_filesystem_size_bytes gauge") diff --git a/tests/test_curated_packs.py b/tests/test_curated_packs.py index dc9f64fe..56fd4b4d 100644 --- a/tests/test_curated_packs.py +++ b/tests/test_curated_packs.py @@ -180,7 +180,7 @@ def test_registry_pins_match_community_corpus_when_revision_aligns(): for entry in corpus["dashboards"] } # New packs in this PR. 9628 is pack rev 1 vs corpus rev 8 — no join. - new_pack_ids = {7362, 9628, 14114, 12485, 315, 6417, 741, 8171} + new_pack_ids = {7362, 9628, 14114, 12485, 315, 6417, 741, 8171, 3831, 1471} mismatches = [] for entry in load_curated_registry(): gnet_id = int(entry["gnet_id"]) @@ -5351,3 +5351,532 @@ def test_8171_layout_renames_idle_cpu_to_cpu_busy(): ] _apply_panel_layout_overrides_recursively(panels, resolved.panel_layout_overrides) assert panels[0]["title"] == "CPU Busy" + + +# --------------------------------------------------------------------------- +# Grafana 3831 — Kubernetes Cluster Autoscaler +# --------------------------------------------------------------------------- + + +def _resolve_3831(): + dashboard = { + "gnetId": 3831, + "title": "Kubernetes Cluster Autoscaler (via Prometheus)", + "tags": ["prometheus"], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + return resolved, SchemaResolver(resolved) + + +def _translate_3831(panel, *, section_title=""): + resolved, resolver = _resolve_3831() + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + section_title=section_title, + ) + query = (yaml_panel.get("esql") or {}).get("query") or "" + return result, query, yaml_panel + + +def test_3831_registry_entry_present(): + entry = find_curated_pack(gnet_id=3831, title="", tags=[]) + assert entry is not None + assert entry["name"] == "grafana_3831_kubernetes_autoscaler" + assert entry["gnet_revision"] == 1 + + +def test_3831_finds_by_title_fallback(): + entry = find_curated_pack( + gnet_id=None, + title="Kubernetes Cluster Autoscaler (via Prometheus)", + tags=[], + ) + assert entry is not None + assert entry["gnet_id"] == 3831 + + +def test_3831_classifies_autoscaler_metric_kinds(): + resolved, _ = _resolve_3831() + assert resolved.metric_kinds["cluster_autoscaler_nodes_count"] == "gauge" + assert resolved.metric_kinds["cluster_autoscaler_last_activity"] == "gauge" + assert resolved.metric_kinds["cluster_autoscaler_evicted_pods_total"] == "counter" + assert resolved.metric_kinds["cluster_autoscaler_scaled_up_nodes_total"] == "counter" + assert resolved.metric_kinds["cluster_autoscaler_scaled_down_nodes_total"] == "counter" + + +def test_3831_nodes_available_is_ready_over_total_ratio(): + panel = { + "id": 6, + "type": "singlestat", + "title": "Nodes available", + "format": "percent", + "targets": [ + { + "expr": ( + 'sum(cluster_autoscaler_nodes_count{state="ready"})' + "/sum(cluster_autoscaler_nodes_count)*100" + ), + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4}, + } + result, query, yaml_panel = _translate_3831(panel, section_title="Info") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "cluster_autoscaler_nodes_count" in query + assert 'state == "ready"' in query or 'state` == "ready"' in query + assert "computed_value" in query + assert "* 100" in query + assert yaml_panel.get("esql", {}).get("type") == "gauge" + + +def test_3831_last_scaledown_uses_date_diff_not_promql_time(): + panel = { + "id": 7, + "type": "singlestat", + "title": "Last scaleDown activity", + "format": "s", + "targets": [ + { + "expr": 'sum(time()-cluster_autoscaler_last_activity{activity="scaleDown"})', + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4}, + } + result, query, _ = _translate_3831(panel, section_title="Info") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "DATE_DIFF" in query + assert "scaleDown" in query + assert "PROMQL" not in query + assert "cluster_autoscaler_last_activity" in query + + +def test_3831_pod_activity_names_evicted_and_unscheduled(): + panel = { + "id": 11, + "type": "graph", + "title": "Pod activity", + "targets": [ + { + "expr": "sum(cluster_autoscaler_evicted_pods_total)", + "legendFormat": "evicted pods", + "refId": "A", + }, + { + "expr": "sum(cluster_autoscaler_unschedulable_pods_count)", + "legendFormat": "unscheduled pods", + "refId": "B", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + result, query, _ = _translate_3831(panel, section_title="Activity") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "`evicted pods`" in query or "evicted pods" in query + assert "`unscheduled pods`" in query or "unscheduled pods" in query + assert "LAST_OVER_TIME" in query + assert "RATE(" not in query + + +def test_3831_cluster_direction_subtracts_scale_down(): + panel = { + "id": 13, + "type": "singlestat", + "title": "Cluster direction?", + "targets": [ + { + "expr": ( + "sum(cluster_autoscaler_scaled_up_nodes_total)" + "-sum(cluster_autoscaler_scaled_down_nodes_total)" + ), + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 4, "h": 4}, + } + result, query, _ = _translate_3831(panel) + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "cluster_autoscaler_scaled_up_nodes_total" in query + assert "cluster_autoscaler_scaled_down_nodes_total" in query + assert "scaled_up - scaled_down" in query or "computed_value" in query + assert "PROMQL" not in query + + +def test_3831_layout_renames_kpi_titles(): + resolved, _ = _resolve_3831() + panels = [ + { + "title": "Is cluster safe to scale?", + "esql": {"type": "gauge", "query": "FROM metrics-*"}, + "position": {"x": 16, "y": 0}, + "size": {"w": 8, "h": 8}, + }, + { + "title": "Cluster direction?", + "esql": { + "type": "metric", + "query": "FROM metrics-*", + "primary": {"field": "computed_value"}, + }, + "position": {"x": 32, "y": 0}, + "size": {"w": 16, "h": 12}, + }, + ] + _apply_panel_layout_overrides_recursively(panels, resolved.panel_layout_overrides) + assert panels[0]["title"] == "Safe to autoscale" + assert panels[1]["title"] == "Net scaled nodes" + assert panels[0]["size"]["h"] == 10 + assert panels[1]["size"]["w"] == 12 + assert panels[1]["position"]["y"] == 2 + assert panels[1]["esql"]["primary"]["label"] == " " + + +def test_3831_kpi_row_fills_48_cols(): + resolved, _ = _resolve_3831() + by_title = {item["title_match"]: item for item in resolved.panel_layout_overrides} + xs = [ + by_title[name]["position"]["x"] + for name in ( + "Total nodes", + "Nodes available", + "Is cluster safe to scale?", + "Number of unscheduled pods", + "Last scaleDown activity", + "Last autoscale activity", + ) + ] + assert xs == [0, 8, 16, 24, 32, 40] + + +# --------------------------------------------------------------------------- +# Grafana 1471 — Kubernetes App Metrics +# --------------------------------------------------------------------------- + + +def _resolve_1471(): + dashboard = { + "gnetId": 1471, + "title": "Kubernetes App Metrics", + "tags": [], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + return resolved, SchemaResolver(resolved) + + +def _translate_1471(panel, *, section_title=""): + resolved, resolver = _resolve_1471() + yaml_panel, result = translate_panel( + panel, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + resolver=resolver, + section_title=section_title, + ) + query = (yaml_panel.get("esql") or {}).get("query") or "" + return result, query, yaml_panel + + +def test_1471_registry_entry_present(): + entry = find_curated_pack(gnet_id=1471, title="", tags=[]) + assert entry is not None + assert entry["name"] == "grafana_1471_kubernetes_app_metrics" + assert entry["gnet_revision"] == 1 + + +def test_1471_finds_by_title_fallback(): + entry = find_curated_pack(gnet_id=None, title="Kubernetes App Metrics", tags=[]) + assert entry is not None + assert entry["gnet_id"] == 1471 + + +def test_1471_rewrites_heapster_and_http_labels(): + resolved, _ = _resolve_1471() + assert resolved.label_rewrites["container_name"] == "container" + assert resolved.label_rewrites["pod_name"] == "pod" + assert resolved.label_rewrites["kubernetes_io_hostname"] == "instance" + assert resolved.label_rewrites["kubernetes_namespace"] == "namespace" + assert resolved.source_label_names["container"] == "container_name" + assert resolved.metric_kinds["container_memory_usage_bytes"] == "gauge" + assert resolved.metric_kinds["http_requests_total"] == "counter" + assert "nginx_http_requests_total" in resolved.live_optional_metrics + + +def test_1471_request_rate_keeps_nginx_and_binds_app(): + panel = { + "id": 3, + "type": "graph", + "title": "Request rate", + "targets": [ + { + "expr": ( + 'sum(irate(http_requests_total{app="$container", handler!="prometheus",' + ' kubernetes_namespace="$namespace"}[30s])) by (kubernetes_namespace,app,code)' + ), + "legendFormat": "native | {{code}}", + "refId": "A", + }, + { + "expr": ( + 'sum(irate(nginx_http_requests_total{app="$container",' + ' kubernetes_namespace="$namespace"}[30s])) by (kubernetes_namespace,app,status)' + ), + "legendFormat": "nginx | {{status}}", + "refId": "B", + }, + { + "expr": ( + 'sum(irate(haproxy_backend_http_responses_total{app="$container",' + ' kubernetes_namespace="$namespace"}[30s])) by (app,kubernetes_namespace,code)' + ), + "legendFormat": "haproxy | {{code}}", + "refId": "C", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + result, query, yaml_panel = _translate_1471(panel, section_title="Request rate") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "nginx_http_requests_total" in query + assert "haproxy_backend_http_responses_total" in query + assert "?container" in query + assert "?namespace" in query + assert "kubernetes_namespace" not in query + assert "PROMQL" not in query + # One rate series per Grafana legendFormat ("native | 200"), grouped so + # Lens breakdown is `series` rather than a cartesian Native x code legend. + assert "BY time_bucket" in query + assert "native | " in query + breakdown = (yaml_panel.get("esql") or {}).get("breakdown") or {} + assert breakdown.get("field") == "series" + metrics = (yaml_panel.get("esql") or {}).get("metrics") or [] + assert [m.get("field") for m in metrics] == ["rate"] + + +def test_1471_error_rate_is_named_5xx_ratio(): + panel = { + "id": 15, + "type": "graph", + "title": "Error rate", + "targets": [ + { + "expr": ( + 'sum(irate(haproxy_backend_http_responses_total{app="$container",' + ' kubernetes_namespace="$namespace",code="5xx"}[30s]))' + ' / sum(irate(haproxy_backend_http_responses_total{app="$container",' + ' kubernetes_namespace="$namespace"}[30s]))' + ), + "legendFormat": "haproxy", + "refId": "A", + } + ], + "gridPos": {"x": 12, "y": 0, "w": 12, "h": 7}, + } + result, query, _ = _translate_1471(panel, section_title="Request rate") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "HAProxy" in query + assert "native_5xx" in query or "Native" in query + assert 'code == "5xx"' in query or "5[0-9]+" in query + + +def test_1471_response_time_override_is_percentile_not_histogram(): + panel = { + "id": 5, + "type": "graph", + "title": "Response time percentiles", + "targets": [ + { + "expr": ( + "histogram_quantile(0.99, sum(rate(" + 'http_request_duration_seconds_bucket{app="$container",' + ' kubernetes_namespace="$namespace"}[30s])) by (app,kubernetes_namespace,le))' + ), + "legendFormat": "native | 0.99", + "refId": "A", + } + ], + "gridPos": {"x": 0, "y": 0, "w": 24, "h": 7}, + } + result, query, yaml_panel = _translate_1471(panel, section_title="Response time") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert result.status != "not_feasible" + assert "PERCENTILE" in query + assert "native_p99" in query + assert "PROMQL" not in query + assert yaml_panel.get("esql", {}).get("type") == "line" + + +def test_1471_number_of_pods_uses_canonical_labels(): + panel = { + "id": 7, + "type": "graph", + "title": "Number of pods", + "targets": [ + { + "expr": ( + 'count(count(container_memory_usage_bytes{container_name="$container",' + ' namespace="$namespace"}) by (pod_name))' + ), + "legendFormat": "pods", + "refId": "A", + }, + { + "expr": ( + 'count(count(container_memory_usage_bytes{container_name="$container",' + ' namespace="$namespace"}) by (kubernetes_io_hostname))' + ), + "legendFormat": "hosts", + "refId": "B", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 24, "h": 7}, + } + result, query, _ = _translate_1471(panel, section_title="Pod count") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "COUNT_DISTINCT" in query + assert "k8s.pod.name" in query + assert "pod_name" not in query + assert "kubernetes_io_hostname" not in query + assert "hosts" in query and "pods" in query + + +def test_1471_per_pod_cpu_groups_by_pod_not_cgroup_id(): + panel = { + "id": 13, + "type": "graph", + "title": "Cpu usage (per pod)", + "targets": [ + { + "expr": ( + 'sum(irate(container_cpu_usage_seconds_total{container_name="$container",' + ' namespace="$namespace"}[30s])) by (id,pod_name)' + ), + "legendFormat": "{{pod_name}}", + "refId": "A", + }, + { + "expr": ( + 'sum(container_spec_cpu_quota{container_name="$container", namespace="$namespace"}' + " / container_spec_cpu_period{container_name=\"$container\", namespace=\"$namespace\"})" + " by (namespace,container_name) / count(container_memory_usage_bytes" + '{container_name="$container", namespace="$namespace"}) by (namespace,container_name)' + ), + "legendFormat": "limit", + "refId": "B", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + result, query, _ = _translate_1471(panel, section_title="Usage per pod") + assert result.status == "migrated_with_warnings" + assert "k8s.pod.name" in query + assert "CPU" in query + assert "BY time_bucket" in query + assert any("limit" in r.lower() or "breakdown" in r.lower() for r in result.reasons) + + +def test_1471_plugin_rewrites_namespace_and_container_populate(): + dashboard = { + "gnetId": 1471, + "title": "Kubernetes App Metrics", + "tags": [], + "templating": { + "list": [ + { + "name": "namespace", + "type": "query", + "query": ( + 'label_values(container_memory_usage_bytes' + '{namespace=~".+",container_name!="POD"},namespace)' + ), + }, + { + "name": "container", + "type": "query", + "query": ( + 'label_values(container_memory_usage_bytes' + '{namespace=~"$namespace",container_name!="POD"},container_name)' + ), + }, + ] + }, + "rows": [ + { + "title": "Pod count", + "panels": [ + { + "id": 7, + "type": "graph", + "title": "Number of pods", + "targets": [ + { + "expr": ( + 'count(count(container_memory_usage_bytes' + '{container_name="$container", namespace="$namespace"})' + " by (pod_name))" + ), + "refId": "A", + } + ], + "span": 12, + } + ], + } + ], + } + resolved = resolve_pack_for_dashboard(dashboard, RulePackConfig()) + result = translate_dashboard( + dashboard, + datasource_index="metrics-*", + esql_index="metrics-*", + rule_pack=resolved, + ) + payload = result.dashboard_ir.to_yaml_dict() + controls = payload.get("controls") or [] + by_name = {c.get("variable_name"): c for c in controls} + assert "namespace" in by_name, controls + assert "container" in by_name, controls + ns_q = str(by_name["namespace"].get("query") or "") + c_q = str(by_name["container"].get("query") or "") + assert "container_name" not in ns_q + assert "container_name" not in c_q + assert "container_memory_usage_bytes" in ns_q + assert "k8s.container.name" in c_q or "container" in c_q + + +def test_1471_memory_total_uses_last_over_time(): + panel = { + "id": 2, + "type": "graph", + "title": "Memory usage (total)", + "targets": [ + { + "expr": ( + 'sum(container_memory_usage_bytes{container_name="$container",' + ' namespace="$namespace"}) by (namespace,container_name)' + ), + "legendFormat": "actual", + "refId": "A", + }, + { + "expr": ( + 'sum(container_spec_memory_limit_bytes{container_name="$container",' + ' namespace="$namespace"}) by (namespace,container_name)' + ), + "legendFormat": "limit", + "refId": "B", + }, + ], + "gridPos": {"x": 0, "y": 0, "w": 12, "h": 7}, + } + result, query, _ = _translate_1471(panel, section_title="Usage total") + assert result.status in {"migrated", "migrated_with_warnings"}, result.reasons + assert "LAST_OVER_TIME" in query + assert "container_memory_usage_bytes" in query + assert "container_spec_memory_limit_bytes" in query + assert "`limit`" in query or "limit" in query