Updates
CI / test (push) Successful in 1m24s
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-18 19:44:32 -04:00
parent 0592351da2
commit 2e1a3e9b06
36 changed files with 6930 additions and 70 deletions
+17
View File
@@ -27,8 +27,25 @@ PEARDATA_DEFAULT_ROLE=viewer
# PEARDATA_SWARM=0
# PEARDATA_DOCKER=0
# PEARDATA_DOCKER_SOCKET=/var/run/docker.sock
# PEARDATA_CGROUPS=1
# PEARDATA_CGROUPS_MAX=32
# PEARDATA_SENSORS=1
# PEARDATA_DISK_PARTITIONS=0
# PEARDATA_PROCESSES=0
# PEARDATA_PROCESSES_TOP=8
# PEARDATA_MYSQL=0
# PEARDATA_APACHE=0
# PEARDATA_MEMCACHED=0
# PEARDATA_MONGODB=0
# PEARDATA_RABBITMQ=0
# PEARDATA_PROMETHEUS=0
# PEARDATA_PROMETHEUS_URLS=
# PEARDATA_STATSD=0
# PEARDATA_SMART=0
# PEARDATA_NVIDIA=0
# PEARDATA_IPMI=0
# PEARDATA_EBPF=0
# PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
# PEARDATA_PARENT=0
# PEARDATA_PARENT_PEERS=
# PEARDATA_PARENT_PEERS_FILE=
+7 -1
View File
@@ -693,9 +693,15 @@ async function populateExploreCharts() {
id.startsWith('redis.') ||
id.startsWith('postgres.') ||
id.startsWith('peardock.') ||
id.startsWith('cgroup.') ||
id.startsWith('sensors.') ||
id.startsWith('disk_await.') ||
id.startsWith('mem.') ||
id.startsWith('ip.tcp') ||
id === 'system.io' ||
id === 'mem.available' ||
id === 'system.load'
id === 'system.load' ||
id === 'system.softnet_stat'
)
const options = prefer.length ? prefer : ids.slice(0, 40)
const prev = exploreChartId
+27 -2
View File
@@ -68,10 +68,35 @@ Permissions: directory `0700`. Do **not** commit `data/` or `.env`.
|----------|---------|-------------|
| `PEARDATA_HYPERDB` | on | `0` / `off` disables HyperDB |
| `PEARDATA_SWARM` | off | `1` enables Hyperswarm Corestore replication |
| `PEARDATA_DOCKER` | off | `1` enables container collector (cgroup + optional Docker socket) |
| `PEARDATA_DOCKER` | off | `1` enables Docker Engine collector (socket + per-container cpu/mem) |
| `PEARDATA_DOCKER_SOCKET` | `/var/run/docker.sock` | Unix socket for container name enrichment |
| `PEARDATA_PROCESSES` | off | `1` enables process top-N CPU/RSS charts (Linux `/proc`) |
| `PEARDATA_CGROUPS` | on (Linux) | `0` disables cgroup v2 CPU/mem/IO charts; `1` forces on |
| `PEARDATA_CGROUPS_MAX` | `32` | Max cgroup instances to chart |
| `PEARDATA_SENSORS` | on (Linux) | `0` disables hwmon/thermal charts |
| `PEARDATA_DISK_PARTITIONS` | off | `1` charts partition devices (higher cardinality) |
| `PEARDATA_IRQ_MAX` | `24` | Max per-IRQ interrupt charts |
| `PEARDATA_PROCESSES` | off | `1` enables process top CPU/RSS/IO/threads |
| `PEARDATA_PROCESSES_TOP` | `8` | How many processes to keep in top charts (max 32) |
| `PEARDATA_MYSQL` | off | MySQL/MariaDB TCP probe (+ optional stats URL) |
| `PEARDATA_MYSQL_URL` | `127.0.0.1:3306` | MySQL host:port |
| `PEARDATA_MYSQL_STATS_URL` | — | Optional HTTP key=value stats |
| `PEARDATA_APACHE` | off | Apache `server-status?auto` |
| `PEARDATA_APACHE_URL` | `http://127.0.0.1/server-status?auto` | |
| `PEARDATA_MEMCACHED` | off | Memcached `stats` |
| `PEARDATA_MEMCACHED_URL` | `127.0.0.1:11211` | |
| `PEARDATA_MONGODB` | off | MongoDB TCP probe |
| `PEARDATA_MONGODB_URL` | `127.0.0.1:27017` | |
| `PEARDATA_RABBITMQ` | off | RabbitMQ management API |
| `PEARDATA_RABBITMQ_URL` | `http://guest:[email protected]:15672` | |
| `PEARDATA_PROMETHEUS` | off | Generic Prometheus text scrape |
| `PEARDATA_PROMETHEUS_URLS` | — | Comma-separated scrape URLs |
| `PEARDATA_STATSD` | off | StatsD UDP ingest on localhost |
| `PEARDATA_STATSD_PORT` | `8125` | |
| `PEARDATA_SMART` | off | `smartctl` disk SMART |
| `PEARDATA_NVIDIA` | off | `nvidia-smi` GPU metrics |
| `PEARDATA_IPMI` | off | `ipmitool sensor` |
| `PEARDATA_EBPF` | off | Ingest eBPF helper NDJSON |
| `PEARDATA_EBPF_PATH` | `/run/peardata/ebpf.ndjson` | Helper output path |
| `PEARDATA_PARENT` | off | `1` enables parent peer fleet aggregator |
| `PEARDATA_PARENT_PEERS` | empty | Comma-separated child agent public keys (64 hex) |
| `PEARDATA_PARENT_PEERS_FILE` | — | Optional file with one child pubkey per line |
+10 -3
View File
@@ -37,7 +37,12 @@ Canonical catalog lives in `shared/metrics.js` (`STATIC_CHART_DEFS` + runtime in
| Disk aggregate | `system.io` | KiB/s (`in` / `out`) |
| Network aggregate | `system.net`, `system.ip`, `system.ipv6` | kilobits/s |
| TCP / IPv4 | `ip.tcppackets`, `ip.tcperrors`, `ip.tcpopens`, `ip.tcpsock`, `ipv4.packets`, `ipv4.errors`, `ipv4.udppackets`, `ipv4.udperrors` | packets/s, connections |
| PSI pressure | `system.cpu_some_pressure`, `system.memory_some_pressure`, `system.io_some_pressure` | % (avg10/60/300) |
| PSI pressure | `system.*_{some,full}_pressure` | % (avg10/60/300) |
| Memory deep | `mem.zswap`, `mem.hugepages`, `mem.thp*`, `mem.reclaiming`, `mem.oom_kill`, `mem.numa`, `mem.ksm*` | MiB / events/s |
| TCP advanced | `ip.tcpsyncookies`, `ip.tcp_*_queue`, `ip.tcpconnaborts`, `ip.tcpofo`, `ip.tcpreorders`, `ip.tcphandshake`, `ip.tcpmemorypressures` | events/s |
| Softnet / ICMP | `system.softnet_stat`, `ipv4.icmp`, `ipv4.icmp_errors` | events/s |
See also `shared/metrics-deep.js` and gaps in [MISSING_METRICS.md](./MISSING_METRICS.md).
`system.cpu` dimensions: `guest_nice`, `guest`, `steal`, `softirq`, `irq`, `user`, `system`, `nice`, `iowait`, `idle`.
@@ -46,9 +51,11 @@ Canonical catalog lives in `shared/metrics.js` (`STATIC_CHART_DEFS` + runtime in
| Context | Chart id pattern | Notes |
|---------|------------------|-------|
| `cpu.cpu` | `cpu.cpu{N}` | Per-core utilization (same dims as `system.cpu`) |
| `disk.io` / `disk.ops` / `disk.util` | `disk_io.{dev}`, `disk_ops.{dev}`, `disk_util.{dev}` | Per-disk from `/proc/diskstats` |
| `disk.io` / `ops` / `util` / `await` / `avgsz` / `qops` / `busy` / `iotime` / `discard` | `disk_*.{dev}` | Per-disk from `/proc/diskstats` |
| `disk.space` / `disk.inodes` | `disk_space.{mount}`, `disk_inodes.{mount}` | Per-mount via `statfs` |
| `net.net` / `net.packets` / `net.errors` / `net.drops` | `net.{iface}`, … | Per-iface from `/proc/net/dev` (loopback skipped) |
| `net.net` / packets / errors / drops / speed | `net*.{iface}` | `/proc/net/dev` + sysfs speed |
| `cgroup.cpu` / `mem` / `io` | `cgroup.*.{id}` | cgroup v2 (default on Linux) |
| `sensors.temperature` / `thermal` | `sensors.*` | hwmon + thermal zones |
Chart summary objects use fields: `id`, `context`, `units`, `dimensions`, `update_every`, `first_entry`, `last_entry`, …
+88
View File
@@ -0,0 +1,88 @@
# Missing / deferred metrics
Everything from the advanced host-metrics inventory that PearData can collect in-process (or via optional tools / bridges) is implemented. This file tracks **remaining gaps** that need native code, external products, or are non-metric product features.
Last reviewed: 2026-07-18.
---
## Implemented (complete for agent scope)
| Area | Implementation |
|------|----------------|
| CPU / scheduler | `system.cpu`, per-core, cpufreq, throttle, **cpuidle C-states**, softirqs, per-IRQ |
| Memory | Full meminfo/vmstat pack including zswap, THP matrix, reclaiming, KSM (+savings/ratios), NUMA nodes, pagetype, CMA/directmap, EDAC; debugfs zswap/extfrag when readable |
| Disk | io/ops/util/await/avgsz/qops/busy/iotime/discard/**svctm/merged/flush**; optional partitions (`PEARDATA_DISK_PARTITIONS=1`); MD RAID (`mdstat`); SMART (`PEARDATA_SMART=1` + smartctl) |
| Network | iface + speed/duplex/MTU/queue, softnet, ICMP/ICMPmsg, IPv6, TCP advanced, **conntrack** |
| PSI | some + full |
| Processes | top CPU/RSS/IO/threads (`PEARDATA_PROCESSES=1`) |
| cgroups | CPU/mem/IO + mem detail + throttle + pressure (default on Linux) |
| Sensors | hwmon temp/fan/voltage/power + thermal zones; IPMI via ipmitool; NVIDIA via nvidia-smi; RAPL powercap |
| Apps | nginx, redis, postgres, mysql, apache, memcached, rabbitmq, mongodb, prometheus scrape, statsd ingest |
| Self | `peardata.cpu` / `mem` / `charts` |
| eBPF | **Bridge** (`PEARDATA_EBPF=1` + helper NDJSON file) — see below |
Catalog: `shared/metrics.js` + `metrics-deep.js` + `metrics-more.js`.
---
## Still deferred
### Native eBPF programs (in-process)
Loading CO-RE eBPF from the Node/Bare agent is **not** supported. Use the bridge:
```bash
PEARDATA_EBPF=1
PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
```
A privileged helper should append lines like:
```json
{"chart":"ebpf.cachestat","values":{"hits":12,"misses":3},"ts":1710000000000}
```
Until a first-party helper ships, charts such as cachestat / FD leaks / exec-trace appear only when an external helper feeds the bridge.
### Active probes / specialized FS
| Item | Notes |
|------|-------|
| ioping latency probes | Active I/O; opt-in probe not shipped |
| ext4/xfs internal debugfs deep dive | Partial via debugfs when permitted |
| BCache / DM-Cache / ZFS pool collectors | Use `PEARDATA_PROMETHEUS_URLS` or custom plugin |
| LVM detailed | Not dedicated; mdstat covers MD |
| Kafka / NATS / CoreDNS / Unbound / PowerDNS | Use Prometheus scrape (`PEARDATA_PROMETHEUS=1`) |
| libvirt / Xen / KVM guest balloon beyond vmstat | Partial via `mem.balloon` |
| Rich K8s label enrichment | cgroup discovery only; scrape kube-state-metrics via Prometheus |
| Live socket connection viewer | Interactive function, not a timeseries chart |
| Windows Perflib / macOS deep sysctl | Linux-first; OS fallback CPU/RAM/load |
| Full DynCFG / 400+ alert templates / auto-correlation UI | Product features, not collectors |
| ML on every metric | Threshold + optional z-score |
### Privileges
| Need | Metrics affected |
|------|------------------|
| root / `CAP_DAC_READ_SEARCH` | debugfs zswap pool, extfrag |
| `smartctl`, `nvidia-smi`, `ipmitool` | SMART / GPU / IPMI collectors |
| ptrace/DAC for other users `/proc/PID/io` | process I/O may be incomplete |
---
## Env quick reference (new collectors)
| Variable | Default | Purpose |
|----------|---------|---------|
| `PEARDATA_CGROUPS` | on (Linux) | cgroup v2 charts |
| `PEARDATA_SENSORS` | on (Linux) | hwmon/thermal |
| `PEARDATA_DISK_PARTITIONS` | off | Include partition devices |
| `PEARDATA_IRQ_MAX` | `24` | Per-IRQ chart limit |
| `PEARDATA_MYSQL` / `APACHE` / `MEMCACHED` / `MONGODB` / `RABBITMQ` | off | App collectors |
| `PEARDATA_PROMETHEUS` + `PEARDATA_PROMETHEUS_URLS` | off | Generic scrape |
| `PEARDATA_STATSD` | off | UDP StatsD ingest |
| `PEARDATA_SMART` / `NVIDIA` / `IPMI` | off | Tool-backed hardware |
| `PEARDATA_EBPF` + `PEARDATA_EBPF_PATH` | off | eBPF helper bridge |
See [CONFIGURATION.md](./CONFIGURATION.md).
+2
View File
@@ -8,6 +8,8 @@ Bind all interfaces (careful): `PEARDATA_REST_HOST=0.0.0.0`.
Core query/metadata paths are implemented for scripts, Grafana, and Prometheus scrapers.
Compatibility matrix vs upstream agent OpenAPI: [SWAGGER_PLAN.md](./SWAGGER_PLAN.md).
## Quick examples
```bash
+365
View File
@@ -0,0 +1,365 @@
# Swagger / metrics compatibility plan
Plan and verification against the upstream agent OpenAPI document:
- Source: https://raw.githubusercontent.com/netdata/netdata/master/src/web/api/netdata-swagger.json
- Spec: OpenAPI 3.0.0 — **68 paths / 72 operations** (GET/POST/PUT)
- PearData router: `server/rest/routes.js`
- Chart catalog: `shared/metrics.js` + collectors under `server/services/`
This document answers two questions:
1. Which **REST operations** from the swagger does PearData implement?
2. Which **metric charts / dimensions** (the data those APIs serve) are collected?
Verified: **2026-07-18** (static analysis of routes + collector emit sites; unit tests `73/73`).
---
## Summary
| Surface | Upstream | PearData | Status |
|---------|----------|----------|--------|
| OpenAPI operations | 72 | **38 path matches** + PearData-only extras | Core metrics path covered; cloud/auth/WebRTC out of scope |
| Host static charts | (collector-defined; not enumerated in swagger) | **35 / 35** defined & emitted | Complete |
| Instance charts (cpu/disk/net/mount) | same | Patterns implemented in collector | Complete on Linux |
| Opt-in collectors | app plugins | docker / processes / nginx / redis / postgres / fleet / peardock | Complete when env-enabled |
| Data query (`/api/v*/data`) | v1v3 | v1v3 | Implemented (MVP grouping) |
| allmetrics export | shell / prometheus / json | shell / prometheus / json | Implemented (`prometheus_all_hosts` not separate) |
| Contexts | v1v3 | **v2 + v3** (v1 missing) | Partial |
| Weights / search | v1v3 | v2 + v3 MVP | Partial |
| Alerts | rich v1v3 | core read paths | Partial |
| Cloud / claim / ACLK / bearer / WebRTC | many | none | Intentionally out of scope |
**Verdict:** All **core host metrics** required for agent-style dashboards and scrapers are implemented. REST **metric read** endpoints (`charts`, `data`, `allmetrics`, `contexts` v2/v3, badges, health) are implemented. Gaps are mostly **legacy v1 aliases**, **deep alert/config APIs**, **function execution over HTTP**, and **cloud/auth** endpoints that PearData replaces with P2P + HyperDHT.
---
## Part A — Metric charts (what scrapers/UI need)
Swagger describes HTTP shapes; the metric *inventory* lives in collectors. PearDatas target catalog (agent-compatible context IDs) is below.
### A1. Host-wide static charts — verified implemented
All IDs are in `STATIC_CHART_DEFS` and referenced by `server/services/collector.js` emit paths.
| Chart ID | Dimensions (key) | Units | Status |
|----------|------------------|-------|--------|
| `system.cpu` | guest_nice, guest, steal, softirq, irq, user, system, nice, iowait, idle | percentage | **done** |
| `system.intr` | interrupts | interrupts/s | **done** |
| `system.ctxt` | switches | context switches/s | **done** |
| `system.forks` | started | processes/s | **done** |
| `system.processes` | running, blocked | processes | **done** |
| `system.active_processes` | active | processes | **done** |
| `system.load` | load1, load5, load15 | load | **done** |
| `system.uptime` | uptime | seconds | **done** |
| `system.entropy` | entropy | entropy | **done** |
| `system.ram` | free, used, cached, buffers | MiB | **done** |
| `mem.available` | avail | MiB | **done** |
| `mem.swap` | free, used | MiB | **done** |
| `mem.swap_cached` | cached | MiB | **done** |
| `mem.kernel` | slab, kernel_stack, page_tables, vmalloc_used | MiB | **done** |
| `mem.slab` | reclaimable, unreclaimable | MiB | **done** |
| `mem.writeback` | dirty, writeback | MiB | **done** |
| `mem.committed` | Committed_AS | MiB | **done** |
| `mem.swapio` | in, out | KiB/s | **done** |
| `system.pgpgio` | in, out | KiB/s | **done** |
| `system.pgfaults` | minor, major | faults/s | **done** |
| `system.io` | in, out | KiB/s | **done** |
| `system.net` | received, sent | kilobits/s | **done** |
| `system.ip` | received, sent | kilobits/s | **done** |
| `system.ipv6` | received, sent | kilobits/s | **done** |
| `ip.tcppackets` | received, sent | packets/s | **done** |
| `ip.tcperrors` | InErrs, InCsumErrors, RetransSegs | packets/s | **done** |
| `ip.tcpopens` | active, passive | connections/s | **done** |
| `ip.tcpsock` | connections | connections | **done** |
| `ipv4.packets` | received, sent, forwarded, delivered | packets/s | **done** |
| `ipv4.errors` | InDiscards, OutDiscards, InHdrErrors, OutNoRoutes | packets/s | **done** |
| `ipv4.udppackets` | received, sent | packets/s | **done** |
| `ipv4.udperrors` | RcvbufErrors, SndbufErrors, InErrors, NoPorts | packets/s | **done** |
| `system.cpu_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
| `system.memory_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
| `system.io_some_pressure` | some10, some60, some300 | percentage | **done** (Linux PSI) |
**Score: 35 / 35 static host charts implemented.**
### A2. Instance charts — verified implemented (runtime)
| Pattern | Context | Dimensions | Status |
|---------|---------|------------|--------|
| `cpu.cpu{N}` | `cpu.cpu` | same as `system.cpu` | **done** |
| `disk_io.{dev}` | `disk.io` | reads, writes | **done** |
| `disk_ops.{dev}` | `disk.ops` | reads, writes | **done** |
| `disk_util.{dev}` | `disk.util` | utilization | **done** |
| `net.{iface}` | `net.net` | received, sent | **done** |
| `net_packets.{iface}` | `net.packets` | received, sent, multicast | **done** |
| `net_errors.{iface}` | `net.errors` | inbound, outbound | **done** |
| `net_drops.{iface}` | `net.drops` | inbound, outbound | **done** |
| `disk_space.{mount}` | `disk.space` | avail, used, reserved_for_root | **done** |
| `disk_inodes.{mount}` | `disk.inodes` | avail, used, reserved_for_root | **done** |
### A3. Opt-in / plugin charts — verified implemented
| Env gate | Charts | Status |
|----------|--------|--------|
| `PEARDATA_DOCKER=1` | `docker.containers`, `docker.cpu.*`, `docker.mem.*` | **done** |
| `PEARDATA_PROCESSES=1` | `processes.top_cpu`, `processes.top_rss` | **done** |
| `PEARDATA_NGINX=1` | `nginx.connections`, `nginx.requests` | **done** |
| `PEARDATA_REDIS=1` | `redis.memory`, `redis.clients`, `redis.stats` | **done** |
| `PEARDATA_POSTGRES=1` | `postgres.up`, `postgres.stats` | **done** |
| `PEARDATA_PARENT=1` | `fleet.cpu`, `fleet.ram`, `fleet.children` | **done** |
| `PEARDATA_PEARDOCK=1` | `peardock.*` (remapped) | **done** |
### A4. Metric gaps (not required by swagger paths, but common agent charts)
These appear in many agent installs but are **not** in PearData yet. Track as optional collector work:
| Family | Examples | Priority |
|--------|----------|----------|
| cgroup / systemd services | `cgroup.*`, `systemd.service.*` | P2 |
| apps.plugin | `apps.cpu`, `apps.mem`, `apps.vmem`, … | P2 |
| disk SMART / mdstat | `disk_await.*`, `md.health` | P3 |
| wireless / wifi | `wireless.*` | P3 |
| sensors / hwmon | `sensors.*` | P3 |
| eBPF / softnet | `softnet.*`, `ebpf.*` | P3 |
| Windows deep collectors | beyond `os` fallback CPU/RAM/load | P2 (roadmap) |
---
## Part B — OpenAPI endpoint matrix
Legend:
| Code | Meaning |
|------|---------|
| **done** | Path handled in `routes.js` with useful behavior |
| **partial** | Path exists but MVP / stub vs full upstream semantics |
| **alias-miss** | Upstream has path; PearData covers same capability on another version |
| **skip** | Out of scope (cloud, proprietary auth, deprecated) |
| **todo** | In scope for PearData; not implemented |
### B1. Charts & contexts (metrics catalog)
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/charts` | **done** | Full chart map |
| GET | `/api/v1/chart` | **done** | Single chart |
| GET | `/api/v1/contexts` | **alias-miss** | Use `/api/v2\|v3/contexts` |
| GET | `/api/v1/context` | **alias-miss** | Use `/api/v2\|v3/context` |
| GET | `/api/v2/contexts` | **done** | |
| GET | `/api/v3/contexts` | **done** | Preferred |
| GET | `/api/v3/context` | **done** | |
| GET | `/api/v2/q` | **partial** | Chart id/title/family search |
| GET | `/api/v3/q` | **partial** | Same |
### B2. Data & export (core metrics APIs)
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/data` | **done** | chart/context, after/before/points/group/tier/format |
| GET | `/api/v2/data` | **partial** | No full `group_by` / multi-node aggregation |
| GET | `/api/v3/data` | **partial** | Preferred; HyperDB warm fallback supported |
| GET | `/api/v1/allmetrics` | **done** | `json` \| `prometheus` \| `shell` |
| GET | `/api/v2/allmetrics` | **done** | |
| GET | `/api/v3/allmetrics` | **done** | Preferred; `prometheus_all_hosts` → treat as prometheus |
| GET | `/api/v1/badge.svg` | **partial** | Basic SVG; limited styling params |
| GET | `/api/v3/badge.svg` | **partial** | Same |
| GET | `/api/v1/variable` | **alias-miss** | Use `/api/v3/variable` |
| GET | `/api/v3/variable` | **partial** | Alert/variable lookup MVP |
### B3. Weights / scoring
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/weights` | **alias-miss** | Use v2/v3 |
| GET | `/api/v1/metric_correlations` | **skip** | Deprecated upstream |
| GET | `/api/v2/weights` | **partial** | Health-derived scores, not full MC/Anomaly Weight API |
| GET | `/api/v3/weights` | **partial** | Same |
### B4. Nodes / info / health
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/info` | **done** | |
| GET | `/api/v2/info` | **done** | |
| GET | `/api/v3/info` | **done** | Includes HyperDB metadata |
| GET | `/api/v2/nodes` | **done** | Single node; parent expands children |
| GET | `/api/v3/nodes` | **done** | |
| GET | `/api/v2/node_instances` | **alias-miss** | Use `/api/v3/node_instances` |
| GET | `/api/v3/node_instances` | **done** | |
| GET | `/api/v2/versions` | **alias-miss** | Use `/api/v3/versions` |
| GET | `/api/v3/versions` | **done** | |
| GET | `/api/v3/stream_path` | **partial** | Local + parent children hops |
| GET | `/api/v3/stream_info` | **todo** | Streaming stats (map to parent/P2P later) |
| GET | `/health`, `/api/v1/health`, `/api/v3/health` | **done** | PearData extension (not all in swagger) |
### B5. Alerts
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/alarms` | **done** | |
| GET | `/api/v1/alarms_values` | **todo** | |
| GET | `/api/v1/alarm_log` | **todo** | Transitions ≈ `/api/v3/alert_transitions` |
| GET | `/api/v1/alarm_count` | **todo** | Derivable from alerts list |
| GET | `/api/v1/alarm_variables` | **partial** | |
| GET | `/api/v2/alerts` | **done** | |
| GET | `/api/v3/alerts` | **done** | Preferred |
| GET | `/api/v2/alert_transitions` | **alias-miss** | Use v3 |
| GET | `/api/v3/alert_transitions` | **partial** | Recent anomaly events |
| GET | `/api/v2/alert_config` | **alias-miss** | Use v3 |
| GET | `/api/v3/alert_config` | **partial** | List configs |
### B6. Functions / config / settings
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v1/functions` | **alias-miss** | Use v2/v3 |
| GET | `/api/v1/function` | **skip** / **todo** | Execute via P2P `runJob` |
| GET | `/api/v2/functions` | **partial** | List only |
| GET | `/api/v3/functions` | **partial** | List only |
| GET/POST | `/api/v3/function` | **todo** | Prefer authenticated P2P |
| GET | `/api/v2/progress` | **skip** | |
| GET | `/api/v3/progress` | **todo** | Only if HTTP functions land |
| GET | `/api/v3/settings` | **partial** | Read-only runtime knobs |
| PUT | `/api/v3/settings` | **todo** | Not wired (GET-only today) |
| GET/POST | `/api/v1/config` | **skip** | DynCFG; use env + P2P |
| GET/POST | `/api/v3/config` | **partial** | Aliases settings GET; no DynCFG tree |
### B7. Auth / cloud / realtime — out of scope
| Op | Path | PearData | Notes |
|----|------|----------|-------|
| GET | `/api/v3/me` | **partial** | Anonymous REST identity note |
| GET | `/api/v*/bearer_*` | **skip** | Future optional bearer |
| GET | `/api/v*/claim` | **skip** | Cloud claiming N/A |
| GET | `/api/v1/aclk` | **skip** | Cloud link N/A |
| GET | `/api/v1/registry` | **skip** | Deprecated |
| GET | `/api/v1/manage/health` | **skip** | Use P2P alert ops |
| GET | `/api/v1/dbengine_stats` | **skip** | Use `/api/v3/db` (HyperDB) |
| GET | `/api/v1/ml_info` | **skip** | Deprecated; anomaly via engine |
| POST | `/api/v*/rtc_offer` | **skip** | WebRTC N/A; use HyperDHT / tunnel |
### B8. PearData-only REST (not in swagger)
| Op | Path | Purpose |
|----|------|---------|
| GET | `/api/v3/fleet` | Parent fleet health |
| GET | `/api/v3/export` | Snapshot / prometheus write |
| GET | `/api/v3/tunnel` | HyperDHT REST tunnel status |
| GET | `/api/v3/db` | HyperDB keys + collections |
| GET | `/`, `/api` | Service index |
---
## Part C — Implementation depth checklist (metrics path)
| Capability | Required by swagger consumers | PearData |
|------------|-------------------------------|----------|
| List charts with dimensions | yes | **done** |
| Query time-series by chart | yes | **done** |
| Query by context | yes | **done** |
| Relative `after=-60` | yes | **done** |
| `points` + downsample | yes | **done** |
| `group=average\|min\|max\|sum` | yes | **done** |
| Multi-format data (`json`, csv, array) | yes | **done** |
| Prometheus scrape (`allmetrics`) | yes | **done** |
| Badge SVG | yes | **partial** |
| Context search (`/q`) | yes | **partial** |
| Weights / anomaly scoring API | yes | **partial** (health MVP) |
| v2/v3 `group_by` across nodes | yes (v2+) | **todo** |
| HTTP function execute | yes | **todo** (P2P today) |
| Full DynCFG | yes | **skip** (env + jobs) |
---
## Part D — Work plan (priority)
### P0 — already satisfied for metrics consumers
- Host chart catalog + Linux collectors
- `/api/v1/charts`, `/api/v*/data`, `/api/v*/allmetrics`
- `/api/v3/contexts`, health, nodes, badges (basic)
### P1 — close swagger gaps that matter for tooling
1. Add **v1 aliases**: `/api/v1/contexts`, `/api/v1/context`, `/api/v1/weights`, `/api/v1/variable` → existing handlers
2. Add **v2 aliases**: `alert_transitions`, `alert_config`, `node_instances`, `versions`
3. Extend `allmetrics`: accept `format=prometheus_all_hosts` as alias of `prometheus`; honor `filter`, `prefix`
4. Document response-field parity for Grafana/Prometheus dashboards in `REST-API.md`
### P2 — deepen metrics APIs
1. Richer `/api/v3/weights` (per-chart anomaly scores already in engine)
2. `/api/v1/alarm_count`, `/api/v1/alarms_values`, `/api/v1/alarm_log` thin wrappers
3. Optional PUT `/api/v3/settings` (localhost-only)
4. HTTP `function` bridge to `runJob` with strict localhost/admin gate
5. `/api/v3/stream_info` from parent collector stats
### P3 — optional collectors (not swagger-listed)
- cgroup / apps.plugin-style process trees
- sensors, SMART, md
- Windows collector depth
### Explicit non-goals
- Cloud claim / ACLK / registry
- WebRTC offers
- Full DynCFG tree parity
- Bearer auth (until someone binds REST publicly)
---
## Part E — How to re-verify
```bash
# 1) Pull upstream OpenAPI
curl -fsSL \
https://raw.githubusercontent.com/netdata/netdata/master/src/web/api/netdata-swagger.json \
-o /tmp/agent-swagger.json
# 2) Diff path presence vs PearData router
node -e "
import { readFileSync } from 'fs'
const sw = JSON.parse(readFileSync('/tmp/agent-swagger.json','utf8'))
const routes = readFileSync('server/rest/routes.js','utf8')
let miss = 0
for (const [p, ops] of Object.entries(sw.paths)) {
for (const m of Object.keys(ops)) {
if (!['get','post','put'].includes(m)) continue
const hit = routes.includes(\"'\"+p+\"'\") || routes.includes('\"'+p+'\"')
if (!hit) { console.log('MISSING', m.toUpperCase(), p); miss++ }
}
}
console.log('missing ops:', miss)
"
# 3) Confirm every static chart is emitted
node -e "
import { readFileSync } from 'fs'
import { STATIC_CHART_DEFS } from './shared/metrics.js'
const c = readFileSync('server/services/collector.js','utf8')
const missing = STATIC_CHART_DEFS.filter(d => !c.includes(d.id)).map(d => d.id)
console.log(missing.length ? missing : 'all static charts referenced')
"
# 4) Live smoke (agent running on :19999)
curl -s http://127.0.0.1:19999/api/v1/charts | jq '.charts | keys | length'
curl -s 'http://127.0.0.1:19999/api/v3/data?chart=system.cpu&after=-60&points=30' | jq '.labels,.points'
curl -s 'http://127.0.0.1:19999/api/v3/allmetrics?format=prometheus' | head
```
---
## Part F — Quick status board
| Area | Implemented? |
|------|----------------|
| All 35 static host metric charts | **YES** |
| Per-CPU / disk / net / mount instance charts | **YES** |
| Opt-in docker/nginx/redis/postgres/processes/fleet | **YES** (env-gated) |
| Swagger core metrics REST (`charts`/`data`/`allmetrics`/`contexts` v3) | **YES** |
| Full swagger operation parity (72 ops) | **NO** (~38 matched; rest alias/skip/todo) |
| Cloud / WebRTC / DynCFG | **N/A by design** |
**Bottom line:** Every host metric chart PearData defines is collected. Every swagger endpoint required to *discover and scrape those metrics* is implemented on v1/v2/v3 as appropriate. Remaining work is API alias completeness, deeper alert/weights/function semantics, and optional extra collectors — not missing core system metrics.
+205
View File
@@ -33,6 +33,66 @@ import {
getPearDockCollector,
isPearDockEnabled,
} from './services/collectors/peardock.js'
import {
getCgroupsCollector,
isCgroupsEnabled,
} from './services/collectors/cgroups.js'
import {
getSensorsCollector,
isSensorsEnabled,
} from './services/collectors/sensors.js'
import {
getMysqlCollector,
isMysqlEnabled,
} from './services/collectors/mysql.js'
import {
getApacheCollector,
isApacheEnabled,
} from './services/collectors/apache.js'
import {
getMemcachedCollector,
isMemcachedEnabled,
} from './services/collectors/memcached.js'
import {
getPrometheusCollector,
isPrometheusEnabled,
} from './services/collectors/prometheus.js'
import {
getMdstatCollector,
isMdstatEnabled,
} from './services/collectors/mdstat.js'
import {
getSmartCollector,
isSmartEnabled,
} from './services/collectors/smart.js'
import {
getNvidiaCollector,
isNvidiaEnabled,
} from './services/collectors/nvidia.js'
import {
getIpmiCollector,
isIpmiEnabled,
} from './services/collectors/ipmi.js'
import {
getSelfMonitorCollector,
isSelfMonitorEnabled,
} from './services/collectors/self-monitor.js'
import {
getStatsdCollector,
isStatsdEnabled,
} from './services/collectors/statsd.js'
import {
getRabbitmqCollector,
isRabbitmqEnabled,
} from './services/collectors/rabbitmq.js'
import {
getMongodbCollector,
isMongodbEnabled,
} from './services/collectors/mongodb.js'
import {
getEbpfBridgeCollector,
isEbpfBridgeEnabled,
} from './services/collectors/ebpf-bridge.js'
import { notifyAnomaly } from './services/notify.js'
import { getStore } from './services/store.js'
import { getAnomalyEngine } from './services/anomaly.js'
@@ -151,6 +211,121 @@ export function startPipeline() {
peardock.start()
}
let cgroups = null
if (isCgroupsEnabled()) {
cgroups = getCgroupsCollector()
cgroups.on('samples', (batch) => ingestBatch(store, anomalies, batch))
cgroups.start()
}
let sensors = null
if (isSensorsEnabled()) {
sensors = getSensorsCollector()
sensors.on('samples', (batch) => ingestBatch(store, anomalies, batch))
sensors.start()
}
let mysql = null
if (isMysqlEnabled()) {
mysql = getMysqlCollector()
mysql.on('samples', (batch) => ingestBatch(store, anomalies, batch))
mysql.on('error', (err) => log.warn('MySQL collector error', { error: err.message }))
mysql.start()
}
let apache = null
if (isApacheEnabled()) {
apache = getApacheCollector()
apache.on('samples', (batch) => ingestBatch(store, anomalies, batch))
apache.on('error', (err) => log.warn('Apache collector error', { error: err.message }))
apache.start()
}
let memcached = null
if (isMemcachedEnabled()) {
memcached = getMemcachedCollector()
memcached.on('samples', (batch) => ingestBatch(store, anomalies, batch))
memcached.on('error', (err) => log.warn('Memcached collector error', { error: err.message }))
memcached.start()
}
let prometheus = null
if (isPrometheusEnabled()) {
prometheus = getPrometheusCollector()
prometheus.on('samples', (batch) => ingestBatch(store, anomalies, batch))
prometheus.on('error', (err) => log.warn('Prometheus collector error', { error: err.message }))
prometheus.start()
}
let mdstat = null
if (isMdstatEnabled()) {
mdstat = getMdstatCollector()
mdstat.on('samples', (batch) => ingestBatch(store, anomalies, batch))
mdstat.start()
}
let smart = null
if (isSmartEnabled()) {
smart = getSmartCollector()
smart.on('samples', (batch) => ingestBatch(store, anomalies, batch))
smart.on('error', (err) => log.warn('SMART collector error', { error: err.message }))
smart.start()
}
let nvidia = null
if (isNvidiaEnabled()) {
nvidia = getNvidiaCollector()
nvidia.on('samples', (batch) => ingestBatch(store, anomalies, batch))
nvidia.on('error', (err) => log.warn('NVIDIA collector error', { error: err.message }))
nvidia.start()
}
let ipmi = null
if (isIpmiEnabled()) {
ipmi = getIpmiCollector()
ipmi.on('samples', (batch) => ingestBatch(store, anomalies, batch))
ipmi.on('error', (err) => log.warn('IPMI collector error', { error: err.message }))
ipmi.start()
}
let selfMonitor = null
if (isSelfMonitorEnabled()) {
selfMonitor = getSelfMonitorCollector()
selfMonitor.on('samples', (batch) => ingestBatch(store, anomalies, batch))
selfMonitor.on('error', (err) => log.warn('Self-monitor error', { error: err.message }))
selfMonitor.start()
}
let statsd = null
if (isStatsdEnabled()) {
statsd = getStatsdCollector()
statsd.on('samples', (batch) => ingestBatch(store, anomalies, batch))
statsd.start()
}
let rabbitmq = null
if (isRabbitmqEnabled()) {
rabbitmq = getRabbitmqCollector()
rabbitmq.on('samples', (batch) => ingestBatch(store, anomalies, batch))
rabbitmq.on('error', (err) => log.warn('RabbitMQ collector error', { error: err.message }))
rabbitmq.start()
}
let mongodb = null
if (isMongodbEnabled()) {
mongodb = getMongodbCollector()
mongodb.on('samples', (batch) => ingestBatch(store, anomalies, batch))
mongodb.on('error', (err) => log.warn('MongoDB collector error', { error: err.message }))
mongodb.start()
}
let ebpf = null
if (isEbpfBridgeEnabled()) {
ebpf = getEbpfBridgeCollector()
ebpf.on('samples', (batch) => ingestBatch(store, anomalies, batch))
ebpf.start()
}
log.info('Metrics pipeline started', {
hyperdb: Boolean(getDb()),
docker: Boolean(docker),
@@ -160,6 +335,21 @@ export function startPipeline() {
redis: Boolean(redis),
postgres: Boolean(postgres),
peardock: Boolean(peardock),
cgroups: Boolean(cgroups),
sensors: Boolean(sensors),
mysql: Boolean(mysql),
apache: Boolean(apache),
memcached: Boolean(memcached),
prometheus: Boolean(prometheus),
mdstat: Boolean(mdstat),
smart: Boolean(smart),
nvidia: Boolean(nvidia),
ipmi: Boolean(ipmi),
selfMonitor: Boolean(selfMonitor),
statsd: Boolean(statsd),
rabbitmq: Boolean(rabbitmq),
mongodb: Boolean(mongodb),
ebpf: Boolean(ebpf),
})
return {
collector,
@@ -172,5 +362,20 @@ export function startPipeline() {
redis,
postgres,
peardock,
cgroups,
sensors,
mysql,
apache,
memcached,
prometheus,
mdstat,
smart,
nvidia,
ipmi,
selfMonitor,
statsd,
rabbitmq,
mongodb,
ebpf,
}
}
+226 -29
View File
@@ -16,13 +16,34 @@ import {
makeDiskIoChart,
makeDiskOpsChart,
makeDiskUtilChart,
makeDiskAwaitChart,
makeDiskAvgszChart,
makeDiskQopsChart,
makeDiskBusyChart,
makeDiskIotimeChart,
makeDiskDiscardChart,
makeDiskSvctmChart,
makeDiskMergedChart,
makeDiskFlushChart,
makeNetChart,
makeNetPacketsChart,
makeNetErrorsChart,
makeNetDropsChart,
makeNetSpeedChart,
makeNetDuplexChart,
makeNetMtuChart,
makeNetQueueChart,
makeDiskSpaceChart,
makeDiskInodesChart,
} from '../../shared/metrics.js'
import {
collectCpuSysfs,
collectMemDeep,
collectNetDeep,
parsePressureFull,
ifaceSpeedKbps,
} from './collectors/host-deep.js'
import { collectHostMore } from './collectors/host-more.js'
import logger from '../utils/logger.js'
const log = logger.child('collector')
@@ -65,7 +86,8 @@ function parseMeminfo() {
/** @type {Record<string, number>} */
const out = {}
for (const line of raw.split('\n')) {
const m = line.match(/^(\w+):\s+(\d+)/)
// Include keys like Active(anon)
const m = line.match(/^([\w()]+):\s+(\d+)/)
if (m) out[m[1]] = Number(m[2]) * 1024
}
return out
@@ -216,7 +238,12 @@ function parseNetDev() {
function parseDiskstats() {
const raw = readFile('/proc/diskstats')
/** @type {Record<string, { reads: number, readSectors: number, writes: number, writeSectors: number, ioMs: number }>} */
/** @type {Record<string, {
* reads: number, readSectors: number, readMs: number,
* writes: number, writeSectors: number, writeMs: number,
* ioMs: number, ioInFlight: number,
* discards: number, discardSectors: number
* }>} */
const disks = {}
if (!raw) return disks
for (const line of raw.split('\n')) {
@@ -224,15 +251,28 @@ function parseDiskstats() {
if (p.length < 14) continue
const name = p[2]
if (/^(loop|ram|fd|sr)/.test(name)) continue
const includeParts =
process.env.PEARDATA_DISK_PARTITIONS === '1' ||
process.env.PEARDATA_DISK_PARTITIONS === 'on'
if (!includeParts) {
if (/^nvme\d+n\d+p\d+$/.test(name)) continue
if (/^(sd|vd|xvd|hd)[a-z]+\d+$/.test(name)) continue
if (/^mmcblk\d+p\d+$/.test(name)) continue
}
disks[name] = {
reads: Number(p[3]) || 0,
readMerged: Number(p[4]) || 0,
readSectors: Number(p[5]) || 0,
readMs: Number(p[6]) || 0,
writes: Number(p[7]) || 0,
writeMerged: Number(p[8]) || 0,
writeSectors: Number(p[9]) || 0,
writeMs: Number(p[10]) || 0,
ioInFlight: Number(p[11]) || 0,
ioMs: Number(p[12]) || 0,
discards: p.length > 14 ? Number(p[14]) || 0 : 0,
discardSectors: p.length > 16 ? Number(p[16]) || 0 : 0,
flushes: p.length > 18 ? Number(p[18]) || 0 : 0,
}
}
return disks
@@ -252,7 +292,7 @@ function parseVmstat() {
function parseSnmp() {
const raw = readFile('/proc/net/snmp')
if (!raw) return { ip: {}, tcp: {}, udp: {} }
if (!raw) return { ip: {}, tcp: {}, udp: {}, icmp: {} }
/** @type {Record<string, Record<string, number>>} */
const tables = {}
const lines = raw.split('\n')
@@ -267,7 +307,12 @@ function parseSnmp() {
}
tables[name.toLowerCase()] = row
}
return { ip: tables.ip || {}, tcp: tables.tcp || {}, udp: tables.udp || {} }
return {
ip: tables.ip || {},
tcp: tables.tcp || {},
udp: tables.udp || {},
icmp: tables.icmp || {},
}
}
function parseSockstat() {
@@ -278,15 +323,7 @@ function parseSockstat() {
}
function parsePressure(kind) {
const raw = readFile(`/proc/pressure/${kind}`)
if (!raw) return null
const some = raw.match(/some avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
if (!some) return null
return {
some10: Number(some[1]),
some60: Number(some[2]),
some300: Number(some[3]),
}
return parsePressureFull(kind)
}
function parseMounts() {
@@ -382,6 +419,8 @@ export class MetricsCollector extends EventEmitter {
this.lastDisk = null
this.lastVm = null
this.lastSnmp = null
/** Deep collector state (freq/throttle/softnet/TcpExt) */
this.deepState = {}
/** @type {Map<string, Record<string, number|null>>} */
this.latest = new Map()
}
@@ -406,16 +445,25 @@ export class MetricsCollector extends EventEmitter {
const ts = Date.now()
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
this.lastTs = ts
const prevVmSnapshot = this.lastVm
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
this._safeCollect('cpu', () => this._collectCpuAndProcs(batch, ts, dtSec))
this._safeCollect('cpu_sysfs', () => collectCpuSysfs(batch, ts, dtSec, this.deepState))
this._safeCollect('memory', () => this._collectMemory(batch, ts, dtSec))
this._safeCollect('disk', () => this._collectDisk(batch, ts, dtSec))
this._safeCollect('net', () => this._collectNet(batch, ts, dtSec))
this._safeCollect('ip', () => this._collectIp(batch, ts, dtSec))
this._safeCollect('pressure', () => this._collectPressure(batch, ts))
this._safeCollect('diskspace', () => this._collectDiskSpace(batch, ts))
this._safeCollect('host_more', () => {
collectHostMore(batch, ts, dtSec, this.deepState, {
mem: parseMeminfo(),
vm: parseVmstat(),
prevVm: prevVmSnapshot,
})
})
let uptime = 0
try {
@@ -619,6 +667,7 @@ export class MetricsCollector extends EventEmitter {
kernel_stack: bytesToMiB(mem.KernelStack || 0),
page_tables: bytesToMiB(mem.PageTables || 0),
vmalloc_used: bytesToMiB(mem.VmallocUsed || 0),
percpu: bytesToMiB(mem.Percpu || 0),
},
})
const reclaimable = mem.SReclaimable || 0
@@ -639,6 +688,9 @@ export class MetricsCollector extends EventEmitter {
values: {
dirty: bytesToMiB(mem.Dirty || 0),
writeback: bytesToMiB(mem.Writeback || 0),
FuseWriteback: bytesToMiB(mem.WritebackTmp || 0),
NfsWriteback: bytesToMiB(mem.NFS_Unstable || 0),
Bounce: bytesToMiB(mem.Bounce || 0),
},
})
batch.push({
@@ -667,17 +719,16 @@ export class MetricsCollector extends EventEmitter {
})
}
const prevVm = this.lastVm
if (vm) {
const prev = this.lastVm
// pswpin/pswpout are in pages; assume 4KiB
const page = 4
batch.push({
chart: 'mem.swapio',
context: 'mem.swapio',
ts,
values: {
in: prev ? rate(prev.pswpin || 0, vm.pswpin || 0, dtSec) * page : 0,
out: prev ? rate(prev.pswpout || 0, vm.pswpout || 0, dtSec) * page : 0,
in: prevVm ? rate(prevVm.pswpin || 0, vm.pswpin || 0, dtSec) * page : 0,
out: prevVm ? rate(prevVm.pswpout || 0, vm.pswpout || 0, dtSec) * page : 0,
},
})
batch.push({
@@ -685,23 +736,24 @@ export class MetricsCollector extends EventEmitter {
context: 'system.pgpgio',
ts,
values: {
in: prev ? rate(prev.pgpgin || 0, vm.pgpgin || 0, dtSec) : 0,
out: prev ? rate(prev.pgpgout || 0, vm.pgpgout || 0, dtSec) : 0,
in: prevVm ? rate(prevVm.pgpgin || 0, vm.pgpgin || 0, dtSec) : 0,
out: prevVm ? rate(prevVm.pgpgout || 0, vm.pgpgout || 0, dtSec) : 0,
},
})
const minor = (vm.pgfault || 0) - (vm.pgmajfault || 0)
const prevMinor = prev ? (prev.pgfault || 0) - (prev.pgmajfault || 0) : 0
const prevMinor = prevVm ? (prevVm.pgfault || 0) - (prevVm.pgmajfault || 0) : 0
batch.push({
chart: 'system.pgfaults',
context: 'system.pgfaults',
ts,
values: {
minor: prev ? rate(prevMinor, minor, dtSec) : 0,
major: prev ? rate(prev.pgmajfault || 0, vm.pgmajfault || 0, dtSec) : 0,
minor: prevVm ? rate(prevMinor, minor, dtSec) : 0,
major: prevVm ? rate(prevVm.pgmajfault || 0, vm.pgmajfault || 0, dtSec) : 0,
},
})
this.lastVm = vm
}
if (mem) collectMemDeep(batch, ts, dtSec, mem, vm, prevVm)
if (vm) this.lastVm = vm
}
_collectDisk(batch, ts, dtSec) {
@@ -711,9 +763,19 @@ export class MetricsCollector extends EventEmitter {
const prevAll = this.lastDisk || {}
for (const [name, d] of Object.entries(disks)) {
if (name === '__agg') continue
registerChart(makeDiskIoChart(name))
registerChart(makeDiskOpsChart(name))
registerChart(makeDiskUtilChart(name))
registerChart(makeDiskAwaitChart(name))
registerChart(makeDiskAvgszChart(name))
registerChart(makeDiskQopsChart(name))
registerChart(makeDiskBusyChart(name))
registerChart(makeDiskIotimeChart(name))
if (d.discards != null) registerChart(makeDiskDiscardChart(name))
registerChart(makeDiskSvctmChart(name))
registerChart(makeDiskMergedChart(name))
registerChart(makeDiskFlushChart(name))
const prev = prevAll[name]
const readBytes = d.readSectors * 512
const writeBytes = d.writeSectors * 512
@@ -721,6 +783,12 @@ export class MetricsCollector extends EventEmitter {
aggWrite += writeBytes
const readKiB = prev ? rate(prev.readBytes, readBytes, dtSec) / 1024 : 0
const writeKiB = prev ? rate(prev.writeBytes, writeBytes, dtSec) / 1024 : 0
const dReads = prev ? Math.max(0, d.reads - prev.reads) : 0
const dWrites = prev ? Math.max(0, d.writes - prev.writes) : 0
const dReadMs = prev ? Math.max(0, d.readMs - prev.readMs) : 0
const dWriteMs = prev ? Math.max(0, d.writeMs - prev.writeMs) : 0
const dReadSec = prev ? Math.max(0, d.readSectors - prev.readSectors) : 0
const dWriteSec = prev ? Math.max(0, d.writeSectors - prev.writeSectors) : 0
batch.push({
chart: `disk_io.${name}`,
context: 'disk.io',
@@ -736,7 +804,6 @@ export class MetricsCollector extends EventEmitter {
writes: prev ? rate(prev.writes, d.writes, dtSec) : 0,
},
})
// ioMs is cumulative ms spent doing I/O
const util = prev ? Math.min(100, rate(prev.ioMs, d.ioMs, dtSec) / 10) : 0
batch.push({
chart: `disk_util.${name}`,
@@ -744,6 +811,81 @@ export class MetricsCollector extends EventEmitter {
ts,
values: { utilization: util },
})
batch.push({
chart: `disk_await.${name}`,
context: 'disk.await',
ts,
values: {
reads: dReads > 0 ? dReadMs / dReads : 0,
writes: dWrites > 0 ? dWriteMs / dWrites : 0,
},
})
batch.push({
chart: `disk_avgsz.${name}`,
context: 'disk.avgsz',
ts,
values: {
reads: dReads > 0 ? (dReadSec * 512) / dReads / 1024 : 0,
writes: dWrites > 0 ? (dWriteSec * 512) / dWrites / 1024 : 0,
},
})
batch.push({
chart: `disk_qops.${name}`,
context: 'disk.qops',
ts,
values: { operations: d.ioInFlight || 0 },
})
batch.push({
chart: `disk_busy.${name}`,
context: 'disk.busy',
ts,
values: { busy: prev ? rate(prev.ioMs, d.ioMs, dtSec) : 0 },
})
batch.push({
chart: `disk_iotime.${name}`,
context: 'disk.iotime',
ts,
values: {
reads: prev ? rate(prev.readMs, d.readMs, dtSec) : 0,
writes: prev ? rate(prev.writeMs, d.writeMs, dtSec) : 0,
},
})
if (d.discards != null) {
batch.push({
chart: `disk_discard.${name}`,
context: 'disk.discard',
ts,
values: {
operations: prev ? rate(prev.discards || 0, d.discards, dtSec) : 0,
sectors: prev ? rate(prev.discardSectors || 0, d.discardSectors, dtSec) : 0,
},
})
}
const ops = dReads + dWrites
const busyDelta = prev ? Math.max(0, d.ioMs - prev.ioMs) : 0
batch.push({
chart: `disk_svctm.${name}`,
context: 'disk.svctm',
ts,
values: { svctm: ops > 0 ? busyDelta / ops : 0 },
})
batch.push({
chart: `disk_merged.${name}`,
context: 'disk.merged',
ts,
values: {
reads: prev ? rate(prev.readMerged || 0, d.readMerged || 0, dtSec) : 0,
writes: prev ? rate(prev.writeMerged || 0, d.writeMerged || 0, dtSec) : 0,
},
})
batch.push({
chart: `disk_flush.${name}`,
context: 'disk.flush',
ts,
values: {
operations: prev ? rate(prev.flushes || 0, d.flushes || 0, dtSec) : 0,
},
})
disks[name] = { ...d, readBytes, writeBytes }
}
@@ -768,10 +910,15 @@ export class MetricsCollector extends EventEmitter {
const prevAll = this.lastNet || {}
for (const [name, n] of Object.entries(ifaces)) {
if (name === '__agg') continue
registerChart(makeNetChart(name))
registerChart(makeNetPacketsChart(name))
registerChart(makeNetErrorsChart(name))
registerChart(makeNetDropsChart(name))
registerChart(makeNetSpeedChart(name))
registerChart(makeNetDuplexChart(name))
registerChart(makeNetMtuChart(name))
registerChart(makeNetQueueChart(name))
const prev = prevAll[name]
rx += n.rxBytes
tx += n.txBytes
@@ -812,6 +959,41 @@ export class MetricsCollector extends EventEmitter {
outbound: prev ? rate(prev.txDrop, n.txDrop, dtSec) : 0,
},
})
const speed = ifaceSpeedKbps(name)
if (speed != null) {
batch.push({
chart: `net_speed.${name}`,
context: 'net.speed',
ts,
values: { speed },
})
}
const duplex = readFile(`/sys/class/net/${name}/duplex`)
if (duplex) {
const d = duplex.trim().toLowerCase()
batch.push({
chart: `net_duplex.${name}`,
context: 'net.duplex',
ts,
values: { full: d === 'full' ? 1 : 0, half: d === 'half' ? 1 : 0 },
})
}
const mtu = Number(readFile(`/sys/class/net/${name}/mtu`) || 0)
if (mtu > 0) {
batch.push({
chart: `net_mtu.${name}`,
context: 'net.mtu',
ts,
values: { mtu },
})
}
const qlen = Number(readFile(`/sys/class/net/${name}/tx_queue_len`) || 0)
batch.push({
chart: `net_queue.${name}`,
context: 'net.queue_length',
ts,
values: { tx_queue_len: qlen },
})
}
const prevAgg = this.lastNet?.__agg
@@ -972,17 +1154,32 @@ export class MetricsCollector extends EventEmitter {
udpInErrors: udp.InErrors || 0,
udpNoPorts: udp.NoPorts || 0,
}
collectNetDeep(batch, ts, dtSec, this.deepState, snmp)
}
_collectPressure(batch, ts) {
for (const [kind, chart] of [
['cpu', 'system.cpu_some_pressure'],
['memory', 'system.memory_some_pressure'],
['io', 'system.io_some_pressure'],
for (const [kind, someChart, fullChart] of [
['cpu', 'system.cpu_some_pressure', 'system.cpu_full_pressure'],
['memory', 'system.memory_some_pressure', 'system.memory_full_pressure'],
['io', 'system.io_some_pressure', 'system.io_full_pressure'],
]) {
const p = parsePressure(kind)
if (!p) continue
batch.push({ chart, context: chart, ts, values: p })
batch.push({
chart: someChart,
context: someChart,
ts,
values: { some10: p.some10, some60: p.some60, some300: p.some300 },
})
if (p.full10 != null) {
batch.push({
chart: fullChart,
context: fullChart,
ts,
values: { full10: p.full10, full60: p.full60, full300: p.full300 },
})
}
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Apache mod_status collector (service plugin).
*
* Enable: PEARDATA_APACHE=1
* URL: PEARDATA_APACHE_URL=http://127.0.0.1/server-status?auto
*
* Charts: apache.workers, apache.accesses, apache.traffic
*/
import http from 'http'
import https from 'https'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('apache')
const CHART_WORKERS = {
id: 'apache.workers',
name: 'apache.workers',
context: 'apache.workers',
title: 'Apache workers',
units: 'workers',
family: 'apache',
chartType: 'line',
priority: 8400,
plugin: 'apache',
dimensions: [
{ id: 'BusyWorkers', name: 'BusyWorkers', algorithm: 'absolute' },
{ id: 'IdleWorkers', name: 'IdleWorkers', algorithm: 'absolute' },
],
}
const CHART_ACCESSES = {
id: 'apache.accesses',
name: 'apache.accesses',
context: 'apache.accesses',
title: 'Apache total accesses',
units: 'accesses/s',
family: 'apache',
chartType: 'line',
priority: 8410,
plugin: 'apache',
dimensions: [{ id: 'Total_Accesses', name: 'Total Accesses', algorithm: 'absolute' }],
}
const CHART_TRAFFIC = {
id: 'apache.traffic',
name: 'apache.traffic',
context: 'apache.traffic',
title: 'Apache traffic',
units: 'KiB/s',
family: 'apache',
chartType: 'line',
priority: 8420,
plugin: 'apache',
dimensions: [{ id: 'Total_kBytes', name: 'Total kBytes', algorithm: 'absolute' }],
}
export function isApacheEnabled() {
const v = process.env.PEARDATA_APACHE
return v === '1' || v === 'on' || v === 'true'
}
/**
* Parse Apache server-status?auto body.
* @param {string} body
* @returns {{ BusyWorkers: number, IdleWorkers: number, TotalAccesses: number, TotalKBytes: number }|null}
*/
export function parseApacheServerStatus(body) {
const text = String(body || '')
const busy = text.match(/BusyWorkers:\s*(\d+)/i)
const idle = text.match(/IdleWorkers:\s*(\d+)/i)
const accesses = text.match(/Total Accesses:\s*(\d+)/i)
const kbytes = text.match(/Total kBytes:\s*(\d+)/i)
if (!busy || !idle) return null
return {
BusyWorkers: Number(busy[1]),
IdleWorkers: Number(idle[1]),
TotalAccesses: accesses ? Number(accesses[1]) : 0,
TotalKBytes: kbytes ? Number(kbytes[1]) : 0,
}
}
function fetchText(url, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const mod = String(url).startsWith('https') ? https : http
const req = mod.get(url, (res) => {
let body = ''
res.on('data', (c) => {
body += c
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`))
return
}
resolve(body)
})
})
req.setTimeout(timeoutMs, () => {
req.destroy()
reject(new Error('timeout'))
})
req.on('error', reject)
})
}
export class ApacheCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'apache', intervalMs: opts.intervalMs })
this.url =
opts.url || process.env.PEARDATA_APACHE_URL || 'http://127.0.0.1/server-status?auto'
/** @type {{ accesses: number, kbytes: number, wallMs: number }|null} */
this._prev = null
}
isEnabled() {
return isApacheEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_WORKERS)
registerChart(CHART_ACCESSES)
registerChart(CHART_TRAFFIC)
log.info('Apache collector started', { url: this.url })
super.start()
}
async collect() {
const body = await fetchText(this.url)
const parsed = parseApacheServerStatus(body)
if (!parsed) throw new Error('unrecognized server-status body')
const ts = Date.now()
let accessRate = 0
let trafficRate = 0
if (this._prev && ts > this._prev.wallMs) {
const dt = (ts - this._prev.wallMs) / 1000
if (dt > 0) {
accessRate = Math.max(0, (parsed.TotalAccesses - this._prev.accesses) / dt)
trafficRate = Math.max(0, (parsed.TotalKBytes - this._prev.kbytes) / dt)
}
}
this._prev = {
accesses: parsed.TotalAccesses,
kbytes: parsed.TotalKBytes,
wallMs: ts,
}
return [
{
chart: 'apache.workers',
context: 'apache.workers',
ts,
values: {
BusyWorkers: parsed.BusyWorkers,
IdleWorkers: parsed.IdleWorkers,
},
},
{
chart: 'apache.accesses',
context: 'apache.accesses',
ts,
values: { Total_Accesses: accessRate },
},
{
chart: 'apache.traffic',
context: 'apache.traffic',
ts,
values: { Total_kBytes: trafficRate },
},
]
}
}
/** @type {ApacheCollector|null} */
let singleton = null
export function getApacheCollector() {
if (!singleton) singleton = new ApacheCollector()
return singleton
}
+143
View File
@@ -0,0 +1,143 @@
/**
* bcache sysfs stats collector.
*
* Enable: PEARDATA_BCACHE=1 or auto when /sys/fs/bcache exists
* Disable: PEARDATA_BCACHE=0
*
* Charts: bcache.hit_ratio, bcache.dirty
*/
import fs from 'fs'
import path from 'path'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('bcache')
const CHART_HIT = {
id: 'bcache.hit_ratio',
name: 'bcache.hit_ratio',
context: 'bcache.hit_ratio',
title: 'bcache hit ratio',
units: 'percent',
family: 'bcache',
chartType: 'line',
priority: 4320,
plugin: 'bcache',
dimensions: [{ id: 'hit_ratio', name: 'hit_ratio', algorithm: 'absolute' }],
}
const CHART_DIRTY = {
id: 'bcache.dirty',
name: 'bcache.dirty',
context: 'bcache.dirty',
title: 'bcache dirty data',
units: 'bytes',
family: 'bcache',
chartType: 'line',
priority: 4330,
plugin: 'bcache',
dimensions: [{ id: 'dirty', name: 'dirty', algorithm: 'absolute' }],
}
export function isBcacheEnabled() {
const v = process.env.PEARDATA_BCACHE
if (v === '0' || v === 'off' || v === 'false') return false
if (v === '1' || v === 'on' || v === 'true') return true
return fs.existsSync('/sys/fs/bcache')
}
function readNum(p) {
try {
const n = Number(fs.readFileSync(p, 'utf8').trim())
return Number.isFinite(n) ? n : null
} catch {
return null
}
}
/**
* @returns {{ hit_ratio: number|null, dirty: number|null }}
*/
export function collectBcacheStats() {
const root = '/sys/fs/bcache'
if (!fs.existsSync(root)) return { hit_ratio: null, dirty: null }
let hitSum = 0
let hitCount = 0
let dirtySum = 0
let dirtyCount = 0
for (const uuid of fs.readdirSync(root)) {
const base = path.join(root, uuid)
const hit =
readNum(path.join(base, 'stats_total', 'cache_hit_ratio')) ??
readNum(path.join(base, 'cache_hit_ratio'))
if (hit != null) {
hitSum += hit
hitCount++
}
const dirty =
readNum(path.join(base, 'dirty_data')) ?? readNum(path.join(base, 'stats_total', 'dirty'))
if (dirty != null) {
dirtySum += dirty
dirtyCount++
}
}
return {
hit_ratio: hitCount ? hitSum / hitCount : null,
dirty: dirtyCount ? dirtySum : null,
}
}
export class BcacheCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'bcache', intervalMs: opts.intervalMs })
}
isEnabled() {
return isBcacheEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_HIT)
registerChart(CHART_DIRTY)
log.info('bcache collector started')
super.start()
}
async collect() {
const stats = collectBcacheStats()
if (stats.hit_ratio == null && stats.dirty == null) return []
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
if (stats.hit_ratio != null) {
batch.push({
chart: 'bcache.hit_ratio',
context: 'bcache.hit_ratio',
ts,
values: { hit_ratio: stats.hit_ratio },
})
}
if (stats.dirty != null) {
batch.push({
chart: 'bcache.dirty',
context: 'bcache.dirty',
ts,
values: { dirty: stats.dirty },
})
}
return batch
}
}
/** @type {BcacheCollector|null} */
let singleton = null
export function getBcacheCollector() {
if (!singleton) singleton = new BcacheCollector()
return singleton
}
+344
View File
@@ -0,0 +1,344 @@
/**
* cgroup v2 collector — per-container / slice CPU, memory, I/O.
*
* Enable: PEARDATA_CGROUPS=1 (default on Linux when unset)
* Disable: PEARDATA_CGROUPS=0
* Limit: PEARDATA_CGROUPS_MAX=32
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { EventEmitter } from 'events'
import {
SAMPLE_INTERVAL_MS,
registerChart,
makeCgroupCpuChart,
makeCgroupMemChart,
makeCgroupIoChart,
makeCgroupMemDetailChart,
makeCgroupThrottleChart,
} from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('cgroups')
export function isCgroupsEnabled() {
const v = process.env.PEARDATA_CGROUPS
if (v === '0' || v === 'off' || v === 'false') return false
if (v === '1' || v === 'on' || v === 'true') return true
return os.platform() === 'linux'
}
function maxCgroups() {
const n = Number(process.env.PEARDATA_CGROUPS_MAX)
return Number.isFinite(n) && n > 0 ? Math.min(128, Math.floor(n)) : 32
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function cgroupRoot() {
if (fs.existsSync('/sys/fs/cgroup/cgroup.controllers')) return '/sys/fs/cgroup'
return null
}
/**
* Discover interesting leaf cgroups (docker, podman, systemd system.slice children).
* @returns {Array<{ id: string, title: string, path: string }>}
*/
export function discoverCgroups() {
const root = cgroupRoot()
if (!root) return []
/** @type {Array<{ id: string, title: string, path: string }>} */
const found = []
const max = maxCgroups()
const candidates = [
path.join(root, 'system.slice'),
path.join(root, 'user.slice'),
path.join(root, 'docker'),
path.join(root, 'kubepods.slice'),
path.join(root, 'kubepods'),
]
function walk(dir, depth) {
if (found.length >= max || depth > 4) return
let ents
try {
ents = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const ent of ents) {
if (!ent.isDirectory()) continue
if (ent.name === 'init.scope' || ent.name.startsWith('.')) continue
const full = path.join(dir, ent.name)
const rel = full.slice(root.length + 1)
const interesting =
/docker|containerd|podman|libpod|kubepods|crio|\.service$|\.scope$/.test(rel)
const hasCpu = fs.existsSync(path.join(full, 'cpu.stat'))
const hasMem = fs.existsSync(path.join(full, 'memory.current'))
if (interesting && (hasCpu || hasMem)) {
const id = rel.replace(/[^\w.+-]+/g, '_').slice(0, 96)
found.push({ id, title: ent.name.replace(/\.(service|scope)$/, ''), path: full })
if (found.length >= max) return
}
walk(full, depth + 1)
if (found.length >= max) return
}
}
for (const c of candidates) {
if (fs.existsSync(c)) walk(c, 0)
}
// Also sample a few top-level slices if still empty
if (!found.length) {
try {
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
if (!ent.isDirectory()) continue
if (!ent.name.endsWith('.slice') && ent.name !== 'docker') continue
const full = path.join(root, ent.name)
found.push({ id: ent.name, title: ent.name, path: full })
if (found.length >= Math.min(8, max)) break
}
} catch {
// ignore
}
}
return found
}
function parseCpuStat(dir) {
const raw = readFile(path.join(dir, 'cpu.stat'))
if (!raw) return null
/** @type {Record<string, number>} */
const o = {}
for (const line of raw.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k) o[k] = Number(v) || 0
}
return o
}
function parseIoStat(dir) {
const raw = readFile(path.join(dir, 'io.stat'))
if (!raw) return { rbytes: 0, wbytes: 0 }
let rbytes = 0
let wbytes = 0
for (const line of raw.split('\n')) {
if (!line.trim()) continue
const parts = line.trim().split(/\s+/)
for (const p of parts.slice(1)) {
const [k, v] = p.split('=')
if (k === 'rbytes') rbytes += Number(v) || 0
if (k === 'wbytes') wbytes += Number(v) || 0
}
}
return { rbytes, wbytes }
}
function parseMemoryStat(dir) {
const raw = readFile(path.join(dir, 'memory.stat'))
if (!raw) return null
/** @type {Record<string, number>} */
const o = {}
for (const line of raw.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k) o[k] = Number(v) || 0
}
return o
}
function parsePressureSome10(dir, kind) {
const raw = readFile(path.join(dir, `${kind}.pressure`))
if (!raw) return null
const m = raw.match(/some avg10=([\d.]+)/)
return m ? Number(m[1]) : null
}
function makeCgroupPressureChart(id, title, kind) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.pressure.${kind}.${safe}`,
name: `cgroup.pressure.${kind}.${safe}`,
context: 'cgroup.pressure',
title: `Cgroup ${kind} pressure ${title || safe}`,
units: 'percentage',
family: title || safe,
chartType: 'line',
priority: 6120,
plugin: 'cgroups',
dimensions: [{ id: 'some10', name: 'some10', algorithm: 'absolute' }],
}
}
export class CgroupsCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs = opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.timer = null
this.running = false
/** @type {Map<string, { usage: number, user: number, system: number, rbytes: number, wbytes: number, throttledUsec: number }>} */
this.prev = new Map()
this.lastTs = 0
}
start() {
if (this.running) return
if (!cgroupRoot()) {
log.info('cgroup v2 not available — collector idle')
return
}
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('Cgroups collector started', { max: maxCgroups() })
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
}
_tick() {
const ts = Date.now()
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
this.lastTs = ts
const ncpu = os.cpus().length || 1
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
const batch = []
for (const cg of discoverCgroups()) {
const cpuDef = makeCgroupCpuChart(cg.id, cg.title)
const memDef = makeCgroupMemChart(cg.id, cg.title)
const ioDef = makeCgroupIoChart(cg.id, cg.title)
const memDetailDef = makeCgroupMemDetailChart(cg.id, cg.title)
const throttleDef = makeCgroupThrottleChart(cg.id, cg.title)
registerChart(cpuDef)
registerChart(memDef)
registerChart(ioDef)
registerChart(memDetailDef)
registerChart(throttleDef)
const cpu = parseCpuStat(cg.path)
let usageUsec = cpu?.usage_usec ?? 0
let userUsec = cpu?.user_usec ?? 0
let systemUsec = cpu?.system_usec ?? 0
const throttledUsec = cpu?.throttled_usec ?? 0
const memCur = Number(readFile(path.join(cg.path, 'memory.current')) || 0)
const memMaxRaw = readFile(path.join(cg.path, 'memory.max'))
const memMax =
memMaxRaw && memMaxRaw.trim() !== 'max' ? Number(memMaxRaw.trim()) || 0 : 0
const io = parseIoStat(cg.path)
const prev = this.prev.get(cg.id)
let userPct = 0
let sysPct = 0
if (prev && dtSec > 0) {
const dUser = Math.max(0, userUsec - prev.user)
const dSys = Math.max(0, systemUsec - prev.system)
const dUsage = Math.max(0, usageUsec - prev.usage)
if (userUsec || systemUsec) {
userPct = (dUser / (dtSec * 1e6 * ncpu)) * 100
sysPct = (dSys / (dtSec * 1e6 * ncpu)) * 100
} else {
userPct = (dUsage / (dtSec * 1e6 * ncpu)) * 100
}
}
batch.push({
chart: cpuDef.id,
context: 'cgroup.cpu',
ts,
values: { user: userPct, system: sysPct },
})
batch.push({
chart: memDef.id,
context: 'cgroup.mem',
ts,
values: {
usage: memCur / (1024 * 1024),
limit: memMax / (1024 * 1024),
},
})
batch.push({
chart: ioDef.id,
context: 'cgroup.io',
ts,
values: {
read: prev && dtSec > 0 ? Math.max(0, io.rbytes - prev.rbytes) / dtSec / 1024 : 0,
write: prev && dtSec > 0 ? Math.max(0, io.wbytes - prev.wbytes) / dtSec / 1024 : 0,
},
})
const memStat = parseMemoryStat(cg.path)
if (memStat) {
batch.push({
chart: memDetailDef.id,
context: 'cgroup.mem_detail',
ts,
values: {
anon: (memStat.anon || 0) / (1024 * 1024),
file: (memStat.file || 0) / (1024 * 1024),
kernel: (memStat.kernel || 0) / (1024 * 1024),
sock: (memStat.sock || 0) / (1024 * 1024),
},
})
}
let throttlePct = 0
if (prev && dtSec > 0) {
const dThrottled = Math.max(0, throttledUsec - prev.throttledUsec)
throttlePct = Math.min(100, (dThrottled / (dtSec * 1e6)) * 100)
}
batch.push({
chart: throttleDef.id,
context: 'cgroup.cpu_throttle',
ts,
values: { throttled: throttlePct },
})
for (const kind of ['cpu', 'memory', 'io']) {
const some10 = parsePressureSome10(cg.path, kind)
if (some10 == null) continue
const pressureDef = makeCgroupPressureChart(cg.id, cg.title, kind)
registerChart(pressureDef)
batch.push({
chart: pressureDef.id,
context: 'cgroup.pressure',
ts,
values: { some10 },
})
}
this.prev.set(cg.id, {
usage: usageUsec,
user: userUsec,
system: systemUsec,
rbytes: io.rbytes,
wbytes: io.wbytes,
throttledUsec,
})
}
if (batch.length) this.emit('samples', batch)
}
}
/** @type {CgroupsCollector|null} */
let singleton = null
export function getCgroupsCollector() {
if (!singleton) singleton = new CgroupsCollector()
return singleton
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Device-mapper cache stats (dm-cache / dm-cache target).
*
* Auto when dm cache devices are present.
* Disable: PEARDATA_DMCACHE=0
*
* Charts: dmcache.stats
*/
import fs from 'fs'
import path from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('dmcache')
const execFileAsync = promisify(execFile)
const CHART_STATS = {
id: 'dmcache.stats',
name: 'dmcache.stats',
context: 'dmcache.stats',
title: 'dm-cache stats',
units: 'count',
family: 'dmcache',
chartType: 'line',
priority: 4340,
plugin: 'dmcache',
dimensions: [
{ id: 'hits', name: 'hits', algorithm: 'absolute' },
{ id: 'misses', name: 'misses', algorithm: 'absolute' },
],
}
export function isDmcacheEnabled() {
const v = process.env.PEARDATA_DMCACHE
if (v === '0' || v === 'off' || v === 'false') return false
if (v === '1' || v === 'on' || v === 'true') return true
return discoverDmCacheDevices().length > 0
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
/**
* @returns {string[]}
*/
export function discoverDmCacheDevices() {
/** @type {string[]} */
const found = []
const blockRoot = '/sys/block'
if (!fs.existsSync(blockRoot)) return found
for (const name of fs.readdirSync(blockRoot)) {
if (!name.startsWith('dm-')) continue
const dmName = readFile(path.join(blockRoot, name, 'dm', 'name'))
if (!dmName) continue
const lower = dmName.toLowerCase()
if (lower.includes('cache') || lower.includes('dmcache')) {
found.push(name)
}
}
return found
}
/**
* @param {string} output
* @returns {{ hits: number|null, misses: number|null }}
*/
export function parseDmsetupStatus(output) {
let hits = null
let misses = null
for (const line of String(output || '').split('\n')) {
const h = line.match(/(\d+)\s+hits?/i)
const m = line.match(/(\d+)\s+miss(?:es)?/i)
if (h) hits = (hits ?? 0) + Number(h[1])
if (m) misses = (misses ?? 0) + Number(m[1])
}
return { hits, misses }
}
/**
* @returns {Promise<{ hits: number|null, misses: number|null }>}
*/
export async function collectDmcacheStats() {
const devices = discoverDmCacheDevices()
if (!devices.length) return { hits: null, misses: null }
try {
const { stdout } = await execFileAsync('dmsetup', ['status'], { timeout: 3000 })
const parsed = parseDmsetupStatus(stdout)
if (parsed.hits != null || parsed.misses != null) return parsed
} catch {
// fall through to sysfs
}
let hits = null
let misses = null
for (const dev of devices) {
const statsDir = path.join('/sys/block', dev, 'dm', 'stats')
const raw = readFile(statsDir) || readFile(path.join('/sys/block', dev, 'stat'))
if (!raw) continue
const h = raw.match(/hits?\s+(\d+)/i)
const m = raw.match(/miss(?:es)?\s+(\d+)/i)
if (h) hits = (hits ?? 0) + Number(h[1])
if (m) misses = (misses ?? 0) + Number(m[1])
}
return { hits, misses }
}
export class DmcacheCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'dmcache', intervalMs: opts.intervalMs })
}
isEnabled() {
return isDmcacheEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_STATS)
log.info('dm-cache collector started')
super.start()
}
async collect() {
const stats = await collectDmcacheStats()
if (stats.hits == null && stats.misses == null) return []
const ts = Date.now()
return [
{
chart: 'dmcache.stats',
context: 'dmcache.stats',
ts,
values: { hits: stats.hits, misses: stats.misses },
},
]
}
}
/** @type {DmcacheCollector|null} */
let singleton = null
export function getDmcacheCollector() {
if (!singleton) singleton = new DmcacheCollector()
return singleton
}
+129
View File
@@ -0,0 +1,129 @@
/**
* eBPF metrics bridge ingests samples from an external helper.
*
* The agent cannot load eBPF programs in pure JS. Instead, a privileged helper
* can write JSON lines to a file or unix socket path:
*
* PEARDATA_EBPF=1
* PEARDATA_EBPF_PATH=/run/peardata/ebpf.ndjson
*
* Each line: {"chart":"ebpf.cachestat","values":{"hits":1,"misses":2},"ts":...}
*
* Charts are registered dynamically under context ebpf.*.
*/
import fs from 'fs'
import { EventEmitter } from 'events'
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('ebpf-bridge')
export function isEbpfBridgeEnabled() {
const v = process.env.PEARDATA_EBPF
return v === '1' || v === 'on' || v === 'true'
}
function bridgePath() {
return process.env.PEARDATA_EBPF_PATH || '/run/peardata/ebpf.ndjson'
}
export class EbpfBridgeCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs =
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.timer = null
this.running = false
this.offset = 0
}
start() {
if (this.running) return
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('eBPF bridge started', { path: bridgePath() })
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
}
_tick() {
const p = bridgePath()
let st
try {
st = fs.statSync(p)
} catch {
return
}
if (st.size < this.offset) this.offset = 0
if (st.size === this.offset) return
let fd
try {
fd = fs.openSync(p, 'r')
const len = st.size - this.offset
const buf = Buffer.alloc(Math.min(len, 256 * 1024))
const n = fs.readSync(fd, buf, 0, buf.length, this.offset)
this.offset += n
const text = buf.slice(0, n).toString('utf8')
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
const batch = []
for (const line of text.split('\n')) {
if (!line.trim()) continue
let row
try {
row = JSON.parse(line)
} catch {
continue
}
if (!row?.chart || !row.values) continue
const chart = String(row.chart).startsWith('ebpf.')
? String(row.chart)
: `ebpf.${String(row.chart)}`
const dims = Object.keys(row.values).map((id) => ({
id,
name: id,
algorithm: 'absolute',
}))
registerChart({
id: chart,
name: chart,
context: chart.split('.').slice(0, 2).join('.') || 'ebpf.metric',
title: chart,
units: row.units || 'events',
family: 'ebpf',
chartType: 'line',
priority: 9500,
plugin: 'ebpf-bridge',
dimensions: dims,
})
batch.push({
chart,
context: chart,
ts: row.ts || ts,
values: row.values,
})
}
if (batch.length) this.emit('samples', batch)
} catch (err) {
log.warn('eBPF bridge read failed', { error: err.message })
} finally {
try {
if (fd != null) fs.closeSync(fd)
} catch {
// ignore
}
}
}
}
let singleton = null
export function getEbpfBridgeCollector() {
if (!singleton) singleton = new EbpfBridgeCollector()
return singleton
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Filesystem lifetime read/write stats (ext4, xfs).
*
* Enable: default on Linux when unset
* Disable: PEARDATA_FS_STATS=0
*
* Charts: fs.ext4.{dev}, fs.xfs.{dev}
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('fs-stats')
export function isFsStatsEnabled() {
const v = process.env.PEARDATA_FS_STATS
if (v === '0' || v === 'off' || v === 'false') return false
if (v === '1' || v === 'on' || v === 'true') return true
return os.platform() === 'linux'
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function readNum(p) {
const raw = readFile(p)
if (raw == null) return null
const n = Number(String(raw).trim())
return Number.isFinite(n) ? n : null
}
/**
* @param {string} name
*/
function sanitizeDev(name) {
return String(name)
.replace(/[^a-zA-Z0-9_.-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64)
}
/**
* @param {'ext4'|'xfs'} fsType
* @param {string} dev
*/
function makeFsChart(fsType, dev) {
const safe = sanitizeDev(dev)
const id = `fs.${fsType}.${safe}`
return {
id,
name: id,
context: `fs.${fsType}`,
title: `${fsType.toUpperCase()} ${dev}`,
units: 'kB',
family: dev,
chartType: 'line',
priority: fsType === 'ext4' ? 4300 : 4310,
plugin: 'fs-stats',
dimensions: [
{ id: 'write_kb', name: 'write_kb', algorithm: 'absolute' },
{ id: 'read_kb', name: 'read_kb', algorithm: 'absolute' },
],
}
}
/**
* @returns {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>}
*/
export function collectFsStatsData() {
/** @type {Array<{ fsType: 'ext4'|'xfs', dev: string, write_kb: number|null, read_kb: number|null }>} */
const out = []
const ext4Root = '/sys/fs/ext4'
if (fs.existsSync(ext4Root)) {
for (const dev of fs.readdirSync(ext4Root)) {
const base = path.join(ext4Root, dev)
let write_kb = readNum(path.join(base, 'lifetime_write_kbytes'))
let read_kb = readNum(path.join(base, 'lifetime_read_kbytes'))
if (write_kb == null && read_kb == null) {
const statsPath = path.join('/proc/fs/ext4', dev, 'stats')
const raw = readFile(statsPath)
if (raw) {
const nums = raw.trim().split(/\s+/).map(Number)
if (nums.length >= 2) {
write_kb = Number.isFinite(nums[0]) ? nums[0] : null
read_kb = Number.isFinite(nums[1]) ? nums[1] : null
}
}
}
if (write_kb != null || read_kb != null) {
out.push({ fsType: 'ext4', dev, write_kb, read_kb })
}
}
}
const xfsRoot = '/sys/fs/xfs'
if (fs.existsSync(xfsRoot)) {
for (const dev of fs.readdirSync(xfsRoot)) {
const statsDir = path.join(xfsRoot, dev, 'stats')
if (!fs.existsSync(statsDir)) continue
let write_kb = null
let read_kb = null
const statsFile = path.join(statsDir, 'stats')
const raw = readFile(statsFile)
if (raw) {
for (const line of raw.split('\n')) {
const w = line.match(/write\s+(\d+)/i)
const r = line.match(/read\s+(\d+)/i)
if (w) write_kb = Number(w[1])
if (r) read_kb = Number(r[1])
}
}
write_kb = write_kb ?? readNum(path.join(statsDir, 'write'))
read_kb = read_kb ?? readNum(path.join(statsDir, 'read'))
if (write_kb != null || read_kb != null) {
out.push({ fsType: 'xfs', dev, write_kb, read_kb })
}
}
}
return out
}
export class FsStatsCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'fs-stats', intervalMs: opts.intervalMs })
}
isEnabled() {
return isFsStatsEnabled()
}
start() {
if (!this.isEnabled()) return
log.info('Filesystem stats collector started')
super.start()
}
async collect() {
const rows = collectFsStatsData()
if (!rows.length) return []
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
for (const row of rows) {
const def = makeFsChart(row.fsType, row.dev)
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { write_kb: row.write_kb, read_kb: row.read_kb },
})
}
return batch
}
}
/** @type {FsStatsCollector|null} */
let singleton = null
export function getFsStatsCollector() {
if (!singleton) singleton = new FsStatsCollector()
return singleton
}
+560
View File
@@ -0,0 +1,560 @@
/**
* Deep host metric helpers CPU freq/throttle, KSM, softnet, TcpExt, PSI full.
* Called from the main MetricsCollector (Linux /proc + /sys).
*/
import fs from 'fs'
import os from 'os'
import path from 'path'
import { registerChart } from '../../../shared/metrics.js'
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function rate(prev, cur, dtSec) {
if (dtSec <= 0 || cur < prev) return 0
return (cur - prev) / dtSec
}
function bytesToMiB(n) {
return n / (1024 * 1024)
}
function pageKiB() {
try {
return (os.constants?.dlopen ? 4 : 4) // pages typically 4 KiB
} catch {
return 4
}
}
/** @returns {Record<string, number>} */
export function parseNetstatTables() {
const raw = readFile('/proc/net/netstat')
/** @type {Record<string, Record<string, number>>} */
const tables = {}
if (!raw) return { TcpExt: {}, IpExt: {} }
const lines = raw.split('\n')
for (let i = 0; i + 1 < lines.length; i += 2) {
const header = lines[i].trim().split(/\s+/)
const values = lines[i + 1].trim().split(/\s+/)
const name = header[0].replace(':', '')
/** @type {Record<string, number>} */
const row = {}
for (let j = 1; j < header.length; j++) row[header[j]] = Number(values[j]) || 0
tables[name] = row
}
return { TcpExt: tables.TcpExt || {}, IpExt: tables.IpExt || {} }
}
/** @returns {{ processed: number, dropped: number, squeezed: number, received_rps: number, flow_limit: number }} */
export function parseSoftnet() {
const raw = readFile('/proc/net/softnet_stat')
const out = { processed: 0, dropped: 0, squeezed: 0, received_rps: 0, flow_limit: 0 }
if (!raw) return out
for (const line of raw.split('\n')) {
const cols = line.trim().split(/\s+/)
if (cols.length < 3) continue
out.processed += parseInt(cols[0], 16) || 0
out.dropped += parseInt(cols[1], 16) || 0
out.squeezed += parseInt(cols[2], 16) || 0
if (cols[9]) out.received_rps += parseInt(cols[9], 16) || 0
if (cols[10]) out.flow_limit += parseInt(cols[10], 16) || 0
}
return out
}
/**
* @param {string} kind
* @returns {{ some10:number, some60:number, some300:number, full10?:number, full60?:number, full300?:number }|null}
*/
export function parsePressureFull(kind) {
const raw = readFile(`/proc/pressure/${kind}`)
if (!raw) return null
const some = raw.match(/some avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
if (!some) return null
const full = raw.match(/full avg10=([\d.]+) avg60=([\d.]+) avg300=([\d.]+)/)
/** @type {any} */
const out = {
some10: Number(some[1]),
some60: Number(some[2]),
some300: Number(some[3]),
}
if (full) {
out.full10 = Number(full[1])
out.full60 = Number(full[2])
out.full300 = Number(full[3])
}
return out
}
/** @returns {Record<string, number>|null} */
export function readKsm() {
const base = '/sys/kernel/mm/ksm'
const keys = ['pages_shared', 'pages_sharing', 'pages_unshared', 'pages_volatile']
/** @type {Record<string, number>} */
const out = {}
let any = false
for (const k of keys) {
const raw = readFile(path.join(base, k))
if (raw == null) continue
out[k] = Number(raw.trim()) || 0
any = true
}
return any ? out : null
}
/**
* Per-core MHz + throttle counters.
* @param {Array<{ chart: string, context: string, ts: number, values: object }>} batch
* @param {number} ts
* @param {number} dtSec
* @param {{ lastThrottle?: Record<string, { core: number, pkg: number }> }} state
*/
export function collectCpuSysfs(batch, ts, dtSec, state) {
if (os.platform() !== 'linux') return
const cpuRoot = '/sys/devices/system/cpu'
let dirs
try {
dirs = fs.readdirSync(cpuRoot).filter((d) => /^cpu\d+$/.test(d))
} catch {
return
}
/** @type {Record<string, number>} */
const freqs = {}
const throttle = state.lastThrottle || {}
/** @type {Record<string, { core: number, pkg: number }>} */
const nextThrottle = {}
/** @type {Record<string, number>} */
const coreThrottleRates = {}
/** @type {Record<string, number>} */
const pkgThrottleRates = {}
for (const d of dirs) {
const id = d.slice(3)
const freqRaw =
readFile(path.join(cpuRoot, d, 'cpufreq/scaling_cur_freq')) ||
readFile(path.join(cpuRoot, d, 'cpufreq/cpuinfo_cur_freq'))
if (freqRaw != null) {
const khz = Number(freqRaw.trim()) || 0
freqs[`cpu${id}`] = khz / 1000
}
const coreRaw = readFile(path.join(cpuRoot, d, 'thermal_throttle/core_throttle_count'))
const pkgRaw = readFile(path.join(cpuRoot, d, 'thermal_throttle/package_throttle_count'))
if (coreRaw != null || pkgRaw != null) {
const core = Number(coreRaw?.trim()) || 0
const pkg = Number(pkgRaw?.trim()) || 0
nextThrottle[id] = { core, pkg }
const prev = throttle[id]
if (prev) {
coreThrottleRates[`cpu${id}`] = rate(prev.core, core, dtSec)
pkgThrottleRates[`cpu${id}`] = rate(prev.pkg, pkg, dtSec)
}
}
}
if (Object.keys(freqs).length) {
registerChart({
id: 'cpu.cpufreq',
name: 'cpu.cpufreq',
context: 'cpu.cpufreq',
title: 'CPU frequency',
units: 'MHz',
family: 'cpufreq',
chartType: 'line',
priority: 180,
plugin: 'proc',
dimensions: Object.keys(freqs).map((id) => ({
id,
name: id,
algorithm: 'absolute',
})),
})
batch.push({ chart: 'cpu.cpufreq', context: 'cpu.cpufreq', ts, values: freqs })
}
if (Object.keys(coreThrottleRates).length || Object.keys(nextThrottle).length) {
registerChart({
id: 'cpu.core_throttling',
name: 'cpu.core_throttling',
context: 'cpu.core_throttling',
title: 'CPU core thermal throttling',
units: 'events/s',
family: 'throttling',
chartType: 'line',
priority: 185,
plugin: 'proc',
dimensions: Object.keys(nextThrottle).map((id) => ({
id: `cpu${id}`,
name: `cpu${id}`,
algorithm: 'incremental',
})),
})
registerChart({
id: 'cpu.package_throttling',
name: 'cpu.package_throttling',
context: 'cpu.package_throttling',
title: 'CPU package thermal throttling',
units: 'events/s',
family: 'throttling',
chartType: 'line',
priority: 186,
plugin: 'proc',
dimensions: Object.keys(nextThrottle).map((id) => ({
id: `cpu${id}`,
name: `cpu${id}`,
algorithm: 'incremental',
})),
})
if (Object.keys(coreThrottleRates).length) {
batch.push({
chart: 'cpu.core_throttling',
context: 'cpu.core_throttling',
ts,
values: coreThrottleRates,
})
batch.push({
chart: 'cpu.package_throttling',
context: 'cpu.package_throttling',
ts,
values: pkgThrottleRates,
})
}
}
state.lastThrottle = nextThrottle
}
/**
* @param {Record<string, number>} mem bytes from parseMeminfo
* @param {Record<string, number>|null} vm
* @param {Array} batch
* @param {number} ts
* @param {number} dtSec
* @param {Record<string, number>|null} prevVm
*/
export function collectMemDeep(batch, ts, dtSec, mem, vm, prevVm) {
if (!mem) return prevVm
const kib = (k) => bytesToMiB(mem[k] || 0)
if (mem.Zswap != null || mem.Zswapped != null) {
batch.push({
chart: 'mem.zswap',
context: 'mem.zswap',
ts,
values: { in_ram: kib('Zswap'), on_disk: kib('Zswapped') },
})
}
if (mem.HardwareCorrupted != null) {
batch.push({
chart: 'mem.hwcorrupt',
context: 'mem.hwcorrupt',
ts,
values: { HardwareCorrupted: kib('HardwareCorrupted') },
})
}
// HugePages_* in meminfo are page counts (not KiB) — re-parse
const rawMem = readFile('/proc/meminfo')
let hugeTotal = 0
let hugeFree = 0
let hugeRsvd = 0
let hugeSurp = 0
let hugeSizeKiB = 2048
if (rawMem) {
for (const line of rawMem.split('\n')) {
const m = line.match(/^([\w()]+):\s+(\d+)/)
if (!m) continue
if (m[1] === 'HugePages_Total') hugeTotal = Number(m[2])
if (m[1] === 'HugePages_Free') hugeFree = Number(m[2])
if (m[1] === 'HugePages_Rsvd') hugeRsvd = Number(m[2])
if (m[1] === 'HugePages_Surp') hugeSurp = Number(m[2])
if (m[1] === 'Hugepagesize') hugeSizeKiB = Number(m[2])
}
}
if (hugeTotal > 0) {
const scale = hugeSizeKiB / 1024 // MiB per page
const used = Math.max(0, hugeTotal - hugeFree - hugeRsvd) * scale
batch.push({
chart: 'mem.hugepages',
context: 'mem.hugepages',
ts,
values: {
free: hugeFree * scale,
used,
surplus: hugeSurp * scale,
reserved: hugeRsvd * scale,
},
})
}
batch.push({
chart: 'mem.thp',
context: 'mem.thp',
ts,
values: { anonymous: kib('AnonHugePages'), shmem: kib('ShmemHugePages') },
})
batch.push({
chart: 'mem.thp_details',
context: 'mem.thp_details',
ts,
values: {
ShmemPmdMapped: kib('ShmemPmdMapped'),
FileHugePages: kib('FileHugePages'),
FilePmdMapped: kib('FilePmdMapped'),
},
})
batch.push({
chart: 'mem.reclaiming',
context: 'mem.reclaiming',
ts,
values: {
Active: kib('Active'),
Inactive: kib('Inactive'),
Active_anon: kib('Active(anon)'),
Inactive_anon: kib('Inactive(anon)'),
Active_file: kib('Active(file)'),
Inactive_file: kib('Inactive(file)'),
Unevictable: kib('Unevictable'),
Mlocked: kib('Mlocked'),
},
})
const ksm = readKsm()
if (ksm) {
const pageBytes = 4096
batch.push({
chart: 'mem.ksm',
context: 'mem.ksm',
ts,
values: {
shared: bytesToMiB((ksm.pages_shared || 0) * pageBytes),
sharing: bytesToMiB((ksm.pages_sharing || 0) * pageBytes),
unshared: bytesToMiB((ksm.pages_unshared || 0) * pageBytes),
volatile: bytesToMiB((ksm.pages_volatile || 0) * pageBytes),
},
})
}
if (vm) {
const page = pageKiB()
const r = (key) => (prevVm ? rate(prevVm[key] || 0, vm[key] || 0, dtSec) : 0)
batch.push({
chart: 'mem.oom_kill',
context: 'mem.oom_kill',
ts,
values: { kills: r('oom_kill') },
})
batch.push({
chart: 'mem.balloon',
context: 'mem.balloon',
ts,
values: {
inflate: r('balloon_inflate') * page,
deflate: r('balloon_deflate') * page,
migrate: r('balloon_migrate') * page,
},
})
batch.push({
chart: 'mem.zswapio',
context: 'mem.zswapio',
ts,
values: { in: r('zswpin') * page, out: r('zswpout') * page },
})
batch.push({
chart: 'mem.numa',
context: 'mem.numa',
ts,
values: {
local: r('numa_hit') || r('numa_local'),
foreign: r('numa_foreign'),
interleave: r('numa_interleave'),
other: r('numa_other'),
pte_updates: r('numa_pte_updates'),
pages_migrated: r('numa_pages_migrated'),
},
})
batch.push({
chart: 'mem.thp_faults',
context: 'mem.thp_faults',
ts,
values: {
alloc: r('thp_fault_alloc'),
fallback: r('thp_fault_fallback'),
fallback_charge: r('thp_fault_fallback_charge'),
},
})
batch.push({
chart: 'mem.thp_split',
context: 'mem.thp_split',
ts,
values: {
split: r('thp_split_page') || r('thp_split'),
failed: r('thp_split_page_failed'),
split_pmd: r('thp_split_pmd'),
},
})
batch.push({
chart: 'mem.ksm_cow',
context: 'mem.ksm_cow',
ts,
values: {
swapin: r('ksm_swpin_copy') * page,
write: r('cow_ksm') * page,
},
})
}
return vm
}
/**
* @param {Array} batch
* @param {number} ts
* @param {number} dtSec
* @param {{ lastSoftnet?: object, lastTcpExt?: object, lastIcmp?: object }} state
* @param {{ tcp: object, icmp?: object }} snmp
*/
export function collectNetDeep(batch, ts, dtSec, state, snmp) {
const soft = parseSoftnet()
const prevS = state.lastSoftnet
batch.push({
chart: 'system.softnet_stat',
context: 'system.softnet_stat',
ts,
values: {
processed: prevS ? rate(prevS.processed, soft.processed, dtSec) : 0,
dropped: prevS ? rate(prevS.dropped, soft.dropped, dtSec) : 0,
squeezed: prevS ? rate(prevS.squeezed, soft.squeezed, dtSec) : 0,
received_rps: prevS ? rate(prevS.received_rps, soft.received_rps, dtSec) : 0,
flow_limit: prevS ? rate(prevS.flow_limit, soft.flow_limit, dtSec) : 0,
},
})
state.lastSoftnet = soft
const icmp = snmp.icmp || {}
const prevI = state.lastIcmp
if (icmp.InMsgs != null || icmp.OutMsgs != null) {
batch.push({
chart: 'ipv4.icmp',
context: 'ipv4.icmp',
ts,
values: {
received: prevI ? rate(prevI.InMsgs || 0, icmp.InMsgs || 0, dtSec) : 0,
sent: prevI ? rate(prevI.OutMsgs || 0, icmp.OutMsgs || 0, dtSec) : 0,
},
})
batch.push({
chart: 'ipv4.icmp_errors',
context: 'ipv4.icmp_errors',
ts,
values: {
InErrors: prevI ? rate(prevI.InErrors || 0, icmp.InErrors || 0, dtSec) : 0,
OutErrors: prevI ? rate(prevI.OutErrors || 0, icmp.OutErrors || 0, dtSec) : 0,
InCsumErrors: prevI ? rate(prevI.InCsumErrors || 0, icmp.InCsumErrors || 0, dtSec) : 0,
},
})
state.lastIcmp = icmp
}
const { TcpExt } = parseNetstatTables()
const prev = state.lastTcpExt
const tcp = snmp.tcp || {}
const r = (key) => (prev ? rate(prev[key] || 0, TcpExt[key] || 0, dtSec) : 0)
const rt = (key) => (prev ? rate(prev[`tcp_${key}`] || 0, tcp[key] || 0, dtSec) : 0)
batch.push({
chart: 'ip.tcpsyncookies',
context: 'ip.tcpsyncookies',
ts,
values: {
received: r('SyncookiesRecv'),
sent: r('SyncookiesSent'),
failed: r('SyncookiesFailed'),
},
})
batch.push({
chart: 'ip.tcp_syn_queue',
context: 'ip.tcp_syn_queue',
ts,
values: { drops: r('TCPReqQFullDrop'), cookies: r('TCPReqQFullDoCookies') },
})
batch.push({
chart: 'ip.tcp_accept_queue',
context: 'ip.tcp_accept_queue',
ts,
values: { overflows: r('ListenOverflows'), drops: r('ListenDrops') },
})
batch.push({
chart: 'ip.tcpconnaborts',
context: 'ip.tcpconnaborts',
ts,
values: {
baddata: r('TCPAbortOnData'),
userclosed: r('TCPAbortOnClose'),
nomemory: r('TCPAbortOnMemory'),
timeout: r('TCPAbortOnTimeout'),
linger: r('TCPAbortOnLinger'),
failed: r('TCPAbortFailed'),
},
})
batch.push({
chart: 'ip.tcpofo',
context: 'ip.tcpofo',
ts,
values: {
inqueue: r('TCPOFOQueue'),
dropped: r('TCPOFODrop'),
merged: r('TCPOFOMerge'),
pruned: r('OfoPruned'),
},
})
batch.push({
chart: 'ip.tcpreorders',
context: 'ip.tcpreorders',
ts,
values: {
timestamp: r('TCPTSReorder'),
sack: r('TCPSACKReorder'),
fack: r('TCPFACKReorder'),
reno: r('TCPRenoReorder'),
},
})
batch.push({
chart: 'ip.tcphandshake',
context: 'ip.tcphandshake',
ts,
values: {
EstabResets: rt('EstabResets'),
OutRsts: rt('OutRsts'),
AttemptFails: rt('AttemptFails'),
SynRetrans: r('TCPSynRetrans'),
},
})
batch.push({
chart: 'ip.tcpmemorypressures',
context: 'ip.tcpmemorypressures',
ts,
values: { pressures: r('TCPMemoryPressures') },
})
state.lastTcpExt = {
...TcpExt,
tcp_EstabResets: tcp.EstabResets || 0,
tcp_OutRsts: tcp.OutRsts || 0,
tcp_AttemptFails: tcp.AttemptFails || 0,
}
}
export function ifaceSpeedKbps(iface) {
const raw = readFile(`/sys/class/net/${iface}/speed`)
if (raw == null) return null
const mbps = Number(raw.trim())
if (!Number.isFinite(mbps) || mbps <= 0) return null
return mbps * 1000
}
+636
View File
@@ -0,0 +1,636 @@
/**
* Additional host collectors: softirqs, IRQ, cpuidle, NUMA nodes, pagetype,
* conntrack, inotify, icmpmsg, ipv6, powercap, edac, debugfs extras.
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import {
registerChart,
makeIrqChart,
makeCpuIdleChart,
makeNumaNodeMemChart,
} from '../../../shared/metrics.js'
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function rate(prev, cur, dtSec) {
if (dtSec <= 0 || cur < prev) return 0
return (cur - prev) / dtSec
}
function bytesToMiB(n) {
return n / (1024 * 1024)
}
/** @returns {Record<string, number>} */
export function parseSoftirqs() {
const raw = readFile('/proc/softirqs')
/** @type {Record<string, number>} */
const out = {}
if (!raw) return out
const lines = raw.trim().split('\n')
for (const line of lines.slice(1)) {
const p = line.trim().split(/\s+/)
const name = p[0]?.replace(':', '')
if (!name) continue
let sum = 0
for (let i = 1; i < p.length; i++) sum += Number(p[i]) || 0
out[name] = sum
}
return out
}
/** @returns {Array<{ irq: string, name: string, count: number }>} */
export function parseInterrupts() {
const raw = readFile('/proc/interrupts')
if (!raw) return []
const lines = raw.trim().split('\n')
const out = []
for (const line of lines.slice(1)) {
const m = line.match(/^\s*(\d+):\s+(.+)$/)
if (!m) continue
const irq = m[1]
const rest = m[2].trim().split(/\s+/)
// last non-numeric tokens are name
let count = 0
let i = 0
while (i < rest.length && /^\d+$/.test(rest[i])) {
count += Number(rest[i]) || 0
i++
}
const name = rest.slice(i).join(' ').slice(0, 48) || irq
out.push({ irq, name, count })
}
// keep top IRQs by count to limit cardinality
out.sort((a, b) => b.count - a.count)
return out.slice(0, Number(process.env.PEARDATA_IRQ_MAX) || 24)
}
export function parseSnmp6() {
const raw = readFile('/proc/net/snmp6')
/** @type {Record<string, number>} */
const out = {}
if (!raw) return out
for (const line of raw.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k) out[k] = Number(v) || 0
}
return out
}
export function parseIcmpMsg() {
const raw = readFile('/proc/net/snmp')
if (!raw) return {}
const lines = raw.split('\n')
for (let i = 0; i + 1 < lines.length; i += 2) {
if (!lines[i].startsWith('IcmpMsg:')) continue
const header = lines[i].trim().split(/\s+/)
const values = lines[i + 1].trim().split(/\s+/)
/** @type {Record<string, number>} */
const row = {}
for (let j = 1; j < header.length; j++) row[header[j]] = Number(values[j]) || 0
return row
}
return {}
}
export function parseConntrack(state) {
const entries = Number(readFile('/proc/sys/net/netfilter/nf_conntrack_count') || 0)
const max = Number(readFile('/proc/sys/net/netfilter/nf_conntrack_max') || 0)
const raw = readFile('/proc/net/stat/nf_conntrack')
/** @type {Record<string, number>} */
const sums = {
searched: 0,
found: 0,
new: 0,
invalid: 0,
drop: 0,
insert_failed: 0,
}
if (raw) {
const lines = raw.trim().split('\n')
const header = lines[0]?.trim().split(/\s+/) || []
const idx = (name) => header.indexOf(name)
for (const line of lines.slice(1)) {
const cols = line.trim().split(/\s+/)
const get = (name) => {
const i = idx(name)
return i >= 0 ? parseInt(cols[i], 16) || 0 : 0
}
sums.searched += get('searched')
sums.found += get('found')
sums.new += get('new')
sums.invalid += get('invalid')
sums.drop += get('drop')
sums.insert_failed += get('insert_failed')
}
}
return { entries, max, ...sums }
}
export function parsePagetypeGlobal() {
const raw = readFile('/proc/pagetypeinfo')
/** @type {Record<string, number>} */
const orders = {}
for (let i = 0; i <= 10; i++) orders[`order${i}`] = 0
if (!raw) return orders
for (const line of raw.split('\n')) {
if (!line.includes('Node') || !line.includes('free')) continue
const nums = line.match(
/\s(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$/
)
if (!nums) continue
for (let i = 0; i <= 10; i++) orders[`order${i}`] += Number(nums[i + 1]) || 0
}
return orders
}
export function collectNumaNodes(batch, ts) {
const root = '/sys/devices/system/node'
let nodes
try {
nodes = fs.readdirSync(root).filter((d) => /^node\d+$/.test(d))
} catch {
return
}
if (nodes.length < 1) return
for (const n of nodes) {
const id = n.slice(4)
const meminfo = readFile(path.join(root, n, 'meminfo'))
if (!meminfo) continue
/** @type {Record<string, number>} */
const vals = {}
for (const line of meminfo.split('\n')) {
const m = line.match(/Node \d+ (\w+):\s+(\d+)/)
if (m) vals[m[1]] = (Number(m[2]) || 0) * 1024
}
const def = makeNumaNodeMemChart(id)
registerChart(def)
const total = vals.MemTotal || 0
const free = vals.MemFree || 0
batch.push({
chart: def.id,
context: def.context,
ts,
values: {
MemTotal: bytesToMiB(total),
MemFree: bytesToMiB(free),
MemUsed: bytesToMiB(Math.max(0, total - free)),
},
})
}
}
/**
* C-state residency % from cpuidle time counters.
* @param {Array} batch
* @param {number} ts
* @param {number} dtSec
* @param {{ lastIdle?: Record<string, Record<string, number>> }} state
*/
export function collectCpuidle(batch, ts, dtSec, state) {
if (os.platform() !== 'linux') return
const cpuRoot = '/sys/devices/system/cpu'
let dirs
try {
dirs = fs.readdirSync(cpuRoot).filter((d) => /^cpu\d+$/.test(d))
} catch {
return
}
const prevAll = state.lastIdle || {}
/** @type {Record<string, Record<string, number>>} */
const nextAll = {}
for (const d of dirs) {
const coreId = d.slice(3)
const idleDir = path.join(cpuRoot, d, 'cpuidle')
let states
try {
states = fs.readdirSync(idleDir).filter((s) => /^state\d+$/.test(s))
} catch {
continue
}
/** @type {Record<string, number>} */
const times = {}
for (const s of states) {
const name = (readFile(path.join(idleDir, s, 'name')) || s).trim().toLowerCase().replace(/\s+/g, '_')
const t = Number(readFile(path.join(idleDir, s, 'time')) || 0) // µs
times[name || s] = t
}
nextAll[coreId] = times
const prev = prevAll[coreId]
if (!prev || dtSec <= 0) continue
/** @type {Record<string, number>} */
const deltas = {}
let sum = 0
for (const [k, v] of Object.entries(times)) {
const dlt = Math.max(0, v - (prev[k] || 0))
deltas[k] = dlt
sum += dlt
}
// active ≈ wall time idle (µs); wall ≈ dtSec * 1e6
const wall = dtSec * 1e6
const active = Math.max(0, wall - sum)
sum += active
if (sum <= 0) continue
/** @type {Record<string, number>} */
const pcts = { active: (active / sum) * 100 }
for (const [k, v] of Object.entries(deltas)) pcts[k] = (v / sum) * 100
const def = makeCpuIdleChart(coreId)
def.dimensions = Object.keys(pcts).map((id) => ({
id,
name: id,
algorithm: 'absolute',
}))
registerChart(def)
batch.push({
chart: def.id,
context: 'cpuidle.cpu_cstate_residency_time',
ts,
values: pcts,
})
}
state.lastIdle = nextAll
}
export function collectPowercap(batch, ts, state) {
const base = '/sys/class/powercap'
let ents
try {
ents = fs.readdirSync(base).filter((e) => e.includes('intel-rapl'))
} catch {
return
}
let energy = 0
for (const e of ents) {
if (e.includes(':')) continue // only package-level intel-rapl:0
const raw = readFile(path.join(base, e, 'energy_uj'))
if (raw == null) continue
energy += Number(raw.trim()) || 0
}
// also try intel-rapl:0 specifically
const pkg = readFile(path.join(base, 'intel-rapl:0', 'energy_uj'))
if (pkg != null) energy = Number(pkg.trim()) || energy
const prev = state.lastEnergyUj
const prevTs = state.lastEnergyTs
if (prev != null && prevTs && ts > prevTs) {
const dt = (ts - prevTs) / 1000
const watts = Math.max(0, (energy - prev) / 1e6 / dt)
batch.push({
chart: 'system.power',
context: 'system.power',
ts,
values: { package: watts },
})
}
state.lastEnergyUj = energy
state.lastEnergyTs = ts
}
export function collectEdac(batch, ts) {
const root = '/sys/devices/system/edac/mc'
let mcs
try {
mcs = fs.readdirSync(root).filter((d) => d.startsWith('mc'))
} catch {
return
}
let ce = 0
let ue = 0
for (const mc of mcs) {
ce += Number(readFile(path.join(root, mc, 'ce_count')) || 0)
ue += Number(readFile(path.join(root, mc, 'ue_count')) || 0)
}
batch.push({
chart: 'mem.edac',
context: 'mem.edac',
ts,
values: { corrected: ce, uncorrected: ue },
})
}
export function collectDebugfsExtras(batch, ts) {
// zswap pool (optional)
const pool = readFile('/sys/kernel/debug/zswap/pool_total_size')
const stored = readFile('/sys/kernel/debug/zswap/stored_pages')
if (pool != null || stored != null) {
registerChart({
id: 'mem.zswap_pool',
name: 'mem.zswap_pool',
context: 'mem.zswap_pool',
title: 'Zswap pool (debugfs)',
units: 'MiB',
family: 'zswap',
chartType: 'line',
priority: 2485,
plugin: 'debugfs',
dimensions: [
{ id: 'pool', name: 'pool', algorithm: 'absolute' },
{ id: 'stored', name: 'stored', algorithm: 'absolute' },
],
})
batch.push({
chart: 'mem.zswap_pool',
context: 'mem.zswap_pool',
ts,
values: {
pool: bytesToMiB(Number(pool || 0)),
stored: bytesToMiB((Number(stored || 0) || 0) * 4096),
},
})
}
// extfrag index — sample first node/zone order0 as signal
const frag = readFile('/sys/kernel/debug/extfrag/extfrag_index')
if (frag) {
const nums = frag.match(/-?[\d.]+/g)
if (nums?.length) {
registerChart({
id: 'mem.fragmentation_index',
name: 'mem.fragmentation_index',
context: 'mem.fragmentation_index',
title: 'Memory fragmentation index (debugfs)',
units: 'index',
family: 'fragmentation',
chartType: 'line',
priority: 277,
plugin: 'debugfs',
dimensions: [{ id: 'index', name: 'index', algorithm: 'absolute' }],
})
batch.push({
chart: 'mem.fragmentation_index',
context: 'mem.fragmentation_index',
ts,
values: { index: Number(nums[0]) || 0 },
})
}
}
}
/**
* @param {Record<string, number>} mem
*/
export function collectMemExtras(batch, ts, mem) {
if (!mem) return
const mib = (k) => bytesToMiB(mem[k] || 0)
if (mem.HighTotal != null || mem.LowTotal != null) {
batch.push({
chart: 'mem.high_low',
context: 'mem.high_low',
ts,
values: { high: mib('HighTotal'), low: mib('LowTotal') },
})
}
if (mem.CmaTotal != null) {
batch.push({
chart: 'mem.cma',
context: 'mem.cma',
ts,
values: { total: mib('CmaTotal'), free: mib('CmaFree') },
})
}
batch.push({
chart: 'mem.directmaps',
context: 'mem.directmaps',
ts,
values: {
'4k': mib('DirectMap4k'),
'2M': mib('DirectMap2M') || mib('DirectMap4M'),
'1G': mib('DirectMap1G'),
},
})
}
export function collectThpMore(batch, ts, dtSec, vm, prevVm) {
if (!vm) return
const r = (k) => (prevVm ? rate(prevVm[k] || 0, vm[k] || 0, dtSec) : 0)
batch.push({
chart: 'mem.thp_file',
context: 'mem.thp_file',
ts,
values: {
alloc: r('thp_file_alloc'),
fallback: r('thp_file_fallback'),
mapped: r('thp_file_mapped'),
},
})
batch.push({
chart: 'mem.thp_zero',
context: 'mem.thp_zero',
ts,
values: { alloc: r('thp_zero_page_alloc'), failed: r('thp_zero_page_alloc_failed') },
})
batch.push({
chart: 'mem.thp_collapse',
context: 'mem.thp_collapse',
ts,
values: { alloc: r('thp_collapse_alloc'), failed: r('thp_collapse_alloc_failed') },
})
batch.push({
chart: 'mem.thp_swapout',
context: 'mem.thp_swapout',
ts,
values: { swapout: r('thp_swpout'), fallback: r('thp_swpout_fallback') },
})
batch.push({
chart: 'mem.thp_compact',
context: 'mem.thp_compact',
ts,
values: {
success: r('compact_success'),
fail: r('compact_fail'),
stall: r('compact_stall'),
},
})
}
export function collectKsmExtras(batch, ts) {
const keys = ['pages_shared', 'pages_sharing', 'pages_unshared', 'pages_volatile']
/** @type {Record<string, number>} */
const ksm = {}
for (const k of keys) {
const raw = readFile(path.join('/sys/kernel/mm/ksm', k))
if (raw == null) return
ksm[k] = Number(raw.trim()) || 0
}
const page = 4096
const offered =
((ksm.pages_sharing || 0) +
(ksm.pages_shared || 0) +
(ksm.pages_unshared || 0) +
(ksm.pages_volatile || 0)) *
page
const saved = (ksm.pages_sharing || 0) * page
batch.push({
chart: 'mem.ksm_savings',
context: 'mem.ksm_savings',
ts,
values: { offered: bytesToMiB(offered), saved: bytesToMiB(saved) },
})
batch.push({
chart: 'mem.ksm_ratios',
context: 'mem.ksm_ratios',
ts,
values: { savings: offered > 0 ? (saved / offered) * 100 : 0 },
})
}
/**
* Master tick for host-more extras.
*/
export function collectHostMore(batch, ts, dtSec, state, { mem, vm, prevVm } = {}) {
// softirqs
const soft = parseSoftirqs()
const prevSoft = state.lastSoftirqs
if (Object.keys(soft).length) {
/** @type {Record<string, number>} */
const values = {}
for (const k of Object.keys(soft)) {
values[k] = prevSoft ? rate(prevSoft[k] || 0, soft[k] || 0, dtSec) : 0
}
batch.push({ chart: 'system.softirqs', context: 'system.softirqs', ts, values })
state.lastSoftirqs = soft
}
// IRQs
const irqs = parseInterrupts()
const prevIrq = state.lastIrqs || {}
/** @type {Record<string, number>} */
const nextIrq = {}
for (const { irq, name, count } of irqs) {
nextIrq[irq] = count
const def = makeIrqChart(irq, name)
registerChart(def)
batch.push({
chart: def.id,
context: 'system.intr_irq',
ts,
values: {
interrupts: prevIrq[irq] != null ? rate(prevIrq[irq], count, dtSec) : 0,
},
})
}
state.lastIrqs = nextIrq
// icmpmsg
const icmpMsg = parseIcmpMsg()
const prevMsg = state.lastIcmpMsg
if (Object.keys(icmpMsg).length) {
const pick = (k) => (prevMsg ? rate(prevMsg[k] || 0, icmpMsg[k] || 0, dtSec) : 0)
batch.push({
chart: 'ipv4.icmpmsg',
context: 'ipv4.icmpmsg',
ts,
values: {
InEchoes: pick('InType8') || pick('InEchoes'),
OutEchoes: pick('OutType8') || pick('OutEchoes'),
InDestUnreachs: pick('InType3') || pick('InDestUnreachs'),
OutDestUnreachs: pick('OutType3') || pick('OutDestUnreachs'),
InTimeExcds: pick('InType11') || pick('InTimeExcds'),
OutTimeExcds: pick('OutType11') || pick('OutTimeExcds'),
InRedirects: pick('InType5') || pick('InRedirects'),
OutRedirects: pick('OutType5') || pick('OutRedirects'),
},
})
state.lastIcmpMsg = icmpMsg
}
// ipv6
const s6 = parseSnmp6()
const prev6 = state.lastSnmp6
if (Object.keys(s6).length) {
const r = (k) => (prev6 ? rate(prev6[k] || 0, s6[k] || 0, dtSec) : 0)
batch.push({
chart: 'ipv6.packets',
context: 'ipv6.packets',
ts,
values: {
received: r('Ip6InReceives'),
sent: r('Ip6OutRequests'),
forwarded: r('Ip6OutForwDatagrams') || r('Ip6InForwDatagrams'),
delivered: r('Ip6InDelivers'),
},
})
batch.push({
chart: 'ipv6.errors',
context: 'ipv6.errors',
ts,
values: {
InHdrErrors: r('Ip6InHdrErrors'),
InAddrErrors: r('Ip6InAddrErrors'),
InDiscards: r('Ip6InDiscards'),
OutDiscards: r('Ip6OutDiscards'),
InTruncatedPkts: r('Ip6InTruncatedPkts'),
},
})
batch.push({
chart: 'ipv6.icmp',
context: 'ipv6.icmp',
ts,
values: {
received: r('Icmp6InMsgs'),
sent: r('Icmp6OutMsgs'),
InErrors: r('Icmp6InErrors'),
OutErrors: r('Icmp6OutErrors'),
},
})
state.lastSnmp6 = s6
}
// conntrack
const ct = parseConntrack()
const prevCt = state.lastCt
batch.push({
chart: 'net.conntrack',
context: 'net.conntrack',
ts,
values: {
entries: ct.entries,
max: ct.max,
searched: prevCt ? rate(prevCt.searched, ct.searched, dtSec) : 0,
found: prevCt ? rate(prevCt.found, ct.found, dtSec) : 0,
new: prevCt ? rate(prevCt.new, ct.new, dtSec) : 0,
invalid: prevCt ? rate(prevCt.invalid, ct.invalid, dtSec) : 0,
drop: prevCt ? rate(prevCt.drop, ct.drop, dtSec) : 0,
insert_failed: prevCt ? rate(prevCt.insert_failed, ct.insert_failed, dtSec) : 0,
},
})
state.lastCt = ct
// inotify limits
batch.push({
chart: 'system.inotify',
context: 'system.inotify',
ts,
values: {
max_user_watches: Number(readFile('/proc/sys/fs/inotify/max_user_watches') || 0),
max_user_instances: Number(readFile('/proc/sys/fs/inotify/max_user_instances') || 0),
},
})
batch.push({
chart: 'mem.pagetype_global',
context: 'mem.pagetype_global',
ts,
values: parsePagetypeGlobal(),
})
collectNumaNodes(batch, ts)
collectCpuidle(batch, ts, dtSec, state)
collectPowercap(batch, ts, state)
collectEdac(batch, ts)
collectDebugfsExtras(batch, ts)
if (mem) collectMemExtras(batch, ts, mem)
collectThpMore(batch, ts, dtSec, vm, prevVm)
collectKsmExtras(batch, ts)
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Disk I/O latency probe via ioping or fsync fallback.
*
* Enable: PEARDATA_IOPING=1
* Path: PEARDATA_IOPING_PATH=/ (default)
*
* Charts: ioping.latency
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('ioping')
const execFileAsync = promisify(execFile)
const CHART_LATENCY = {
id: 'ioping.latency',
name: 'ioping.latency',
context: 'ioping.latency',
title: 'I/O ping latency',
units: 'ms',
family: 'ioping',
chartType: 'line',
priority: 4200,
plugin: 'ioping',
dimensions: [{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' }],
}
export function isIopingEnabled() {
const v = process.env.PEARDATA_IOPING
return v === '1' || v === 'on' || v === 'true'
}
function targetPath() {
return process.env.PEARDATA_IOPING_PATH || '/'
}
/**
* @param {string} output
* @returns {number|null}
*/
export function parseIopingOutput(output) {
const m = String(output || '').match(/([\d.]+)\s*ms/i)
if (m) return Number(m[1])
const n = Number(String(output || '').trim().split(/\s+/).pop())
return Number.isFinite(n) ? n : null
}
/**
* @param {string} target
* @returns {Promise<number|null>}
*/
export async function probeIoping(target) {
try {
const { stdout } = await execFileAsync('ioping', ['-c', '1', '-q', target], {
timeout: 10000,
})
return parseIopingOutput(stdout)
} catch {
return null
}
}
/**
* @param {string} target
* @returns {number|null}
*/
export function probeFsyncFallback(target) {
const dir = fs.existsSync(target) && fs.statSync(target).isDirectory() ? target : os.tmpdir()
const file = path.join(dir, `.peardata-ioping-${process.pid}`)
const started = Date.now()
try {
fs.writeFileSync(file, `${Date.now()}\n`)
const fd = fs.openSync(file, 'r+')
fs.fsyncSync(fd)
fs.closeSync(fd)
return Date.now() - started
} catch {
return null
} finally {
try {
fs.unlinkSync(file)
} catch {
// ignore
}
}
}
export class IopingCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'ioping', intervalMs: opts.intervalMs })
this.target = opts.path || targetPath()
}
isEnabled() {
return isIopingEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_LATENCY)
log.info('Ioping collector started', { path: this.target })
super.start()
}
async collect() {
const ts = Date.now()
let latency = await probeIoping(this.target)
if (latency == null) latency = probeFsyncFallback(this.target)
if (latency == null) return []
return [
{
chart: 'ioping.latency',
context: 'ioping.latency',
ts,
values: { latency_ms: latency },
},
]
}
}
/** @type {IopingCollector|null} */
let singleton = null
export function getIopingCollector() {
if (!singleton) singleton = new IopingCollector()
return singleton
}
+143
View File
@@ -0,0 +1,143 @@
/**
* IPMI sensor collector via ipmitool.
*
* Enable: PEARDATA_IPMI=1
*
* Charts: sensors.ipmi.temp.*, sensors.ipmi.fan.*
*/
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('ipmi')
const execFileAsync = promisify(execFile)
export function isIpmiEnabled() {
const v = process.env.PEARDATA_IPMI
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} name
*/
function sanitizeSensor(name) {
return String(name)
.replace(/[^a-zA-Z0-9_.-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64)
}
/**
* @param {'temp'|'fan'} kind
* @param {string} name
*/
function makeIpmiChart(kind, name) {
const safe = sanitizeSensor(name)
const id = kind === 'temp' ? `sensors.ipmi.temp.${safe}` : `sensors.ipmi.fan.${safe}`
return {
id,
name: id,
context: kind === 'temp' ? 'sensors.ipmi.temperature' : 'sensors.ipmi.fan',
title: kind === 'temp' ? `IPMI temperature ${name}` : `IPMI fan ${name}`,
units: kind === 'temp' ? 'Celsius' : 'RPM',
family: 'ipmi',
chartType: 'line',
priority: kind === 'temp' ? 7020 : 7030,
plugin: 'ipmi',
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
}
}
/**
* Parse `ipmitool sensor` output lines.
* @param {string} stdout
* @returns {Array<{ name: string, kind: 'temp'|'fan', value: number }>}
*/
export function parseIpmitoolSensor(stdout) {
/** @type {Array<{ name: string, kind: 'temp'|'fan', value: number }>} */
const out = []
for (const line of String(stdout || '').split('\n')) {
const t = line.trim()
if (!t) continue
const parts = t.split('|').map((s) => s.trim())
if (parts.length < 4) continue
const name = parts[0]
const reading = parts[1]
const type = parts[2]?.toLowerCase() || ''
const lowerName = name.toLowerCase()
const isTemp =
type.includes('degrees') ||
type.includes('temperature') ||
/temp|degrees c/i.test(lowerName)
const isFan = type.includes('rpm') || /fan|rpm/i.test(lowerName)
if (!isTemp && !isFan) continue
const val = Number(String(reading).replace(/[^\d.-]/g, ''))
if (!Number.isFinite(val)) continue
out.push({ name, kind: isFan ? 'fan' : 'temp', value: val })
}
return out
}
export async function readIpmitoolSensors(timeoutMs = 5000) {
const { stdout } = await execFileAsync('ipmitool', ['sensor'], {
timeout: timeoutMs,
encoding: 'utf8',
})
return parseIpmitoolSensor(stdout)
}
export class IpmiCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'ipmi', intervalMs: opts.intervalMs })
/** @type {boolean|null} */
this._available = null
}
isEnabled() {
return isIpmiEnabled()
}
start() {
if (!this.isEnabled()) return
log.info('IPMI collector started')
super.start()
}
async collect() {
let sensors
try {
sensors = await readIpmitoolSensors()
this._available = true
} catch (err) {
if (this._available !== false) {
log.info('ipmitool unavailable — collector idle', { error: err.message })
this._available = false
}
return []
}
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
for (const s of sensors) {
const def = makeIpmiChart(s.kind, s.name)
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { value: s.value },
})
}
return batch
}
}
/** @type {IpmiCollector|null} */
let singleton = null
export function getIpmiCollector() {
if (!singleton) singleton = new IpmiCollector()
return singleton
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Linux MD RAID /proc/mdstat helper + collector.
*
* Always-on when /proc/mdstat has active arrays.
*
* Charts: md.health, md.array.{name}
*/
import fs from 'fs'
import os from 'os'
import { EventEmitter } from 'events'
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('mdstat')
const CHART_HEALTH = {
id: 'md.health',
name: 'md.health',
context: 'md.health',
title: 'MD RAID arrays',
units: 'arrays',
family: 'md',
chartType: 'line',
priority: 4500,
plugin: 'mdstat',
dimensions: [
{ id: 'active', name: 'active', algorithm: 'absolute' },
{ id: 'degraded', name: 'degraded', algorithm: 'absolute' },
{ id: 'recovering', name: 'recovering', algorithm: 'absolute' },
],
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
/**
* @returns {boolean}
*/
export function isMdstatUseful() {
if (os.platform() !== 'linux') return false
const raw = readFile('/proc/mdstat')
if (!raw) return false
return /^md\d+\s*:/m.test(raw)
}
export function isMdstatEnabled() {
return isMdstatUseful()
}
/**
* @param {string} name
*/
function makeMdArrayChart(name) {
const safe = String(name).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
return {
id: `md.array.${safe}`,
name: `md.array.${safe}`,
context: 'md.array',
title: `MD array ${name}`,
units: 'disks',
family: name,
chartType: 'line',
priority: 4510,
plugin: 'mdstat',
dimensions: [
{ id: 'active', name: 'active', algorithm: 'absolute' },
{ id: 'total', name: 'total', algorithm: 'absolute' },
],
}
}
/**
* @param {string} raw
* @returns {{ health: { active: number, degraded: number, recovering: number }, arrays: Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }> }}
*/
export function parseMdstat(raw) {
const text = String(raw || '')
/** @type {Array<{ name: string, active: number, total: number, degraded: boolean, recovering: boolean }>} */
const arrays = []
const lines = text.split('\n')
let i = 0
while (i < lines.length) {
const hdr = lines[i].match(/^(md\d+)\s*:\s*(.*)$/)
if (!hdr) {
i++
continue
}
const name = hdr[1]
const hdrRest = hdr[2] || ''
const detail = lines[i + 1] || ''
const block = `${hdrRest} ${detail}`.toLowerCase()
const bracket = detail.match(/\[(\d+)\/(\d+)\]/)
const active = bracket ? Number(bracket[1]) : 0
const total = bracket ? Number(bracket[2]) : 0
const diskState = detail.match(/\[([U_.]+)\]/)
const degraded =
/degraded|faulty|F/i.test(block) ||
(diskState ? /[_F]/.test(diskState[1]) : active < total && total > 0)
const recovering = /recovery|resync|reshape|check|repair/i.test(block)
arrays.push({ name, active, total, degraded, recovering })
i += 2
}
return {
health: {
active: arrays.length,
degraded: arrays.filter((a) => a.degraded).length,
recovering: arrays.filter((a) => a.recovering).length,
},
arrays,
}
}
/**
* Append mdstat samples to an existing batch.
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
* @param {number} ts
*/
export function collectMdstat(batch, ts) {
if (!isMdstatUseful()) return
const raw = readFile('/proc/mdstat')
if (!raw) return
const parsed = parseMdstat(raw)
registerChart(CHART_HEALTH)
batch.push({
chart: 'md.health',
context: 'md.health',
ts,
values: parsed.health,
})
for (const arr of parsed.arrays) {
const def = makeMdArrayChart(arr.name)
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { active: arr.active, total: arr.total },
})
}
}
export class MdstatCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs =
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.timer = null
this.running = false
}
start() {
if (this.running || !isMdstatUseful()) return
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('Mdstat collector started')
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
}
_tick() {
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
collectMdstat(batch, ts)
if (batch.length) this.emit('samples', batch)
}
}
/** @type {MdstatCollector|null} */
let singleton = null
export function getMdstatCollector() {
if (!singleton) singleton = new MdstatCollector()
return singleton
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Memcached stats collector (service plugin).
*
* Enable: PEARDATA_MEMCACHED=1
* Addr: PEARDATA_MEMCACHED_URL=127.0.0.1:11211
*
* Charts: memcached.ops, memcached.memory
*/
import net from 'net'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('memcached')
const CHART_OPS = {
id: 'memcached.ops',
name: 'memcached.ops',
context: 'memcached.ops',
title: 'Memcached operations',
units: 'ops/s',
family: 'memcached',
chartType: 'line',
priority: 8500,
plugin: 'memcached',
dimensions: [
{ id: 'get_hits', name: 'get_hits', algorithm: 'absolute' },
{ id: 'get_misses', name: 'get_misses', algorithm: 'absolute' },
{ id: 'cmd_get', name: 'cmd_get', algorithm: 'absolute' },
{ id: 'cmd_set', name: 'cmd_set', algorithm: 'absolute' },
],
}
const CHART_MEMORY = {
id: 'memcached.memory',
name: 'memcached.memory',
context: 'memcached.memory',
title: 'Memcached memory',
units: 'bytes',
family: 'memcached',
chartType: 'area',
priority: 8510,
plugin: 'memcached',
dimensions: [
{ id: 'bytes', name: 'bytes', algorithm: 'absolute' },
{ id: 'limit_maxbytes', name: 'limit_maxbytes', algorithm: 'absolute' },
],
}
export function isMemcachedEnabled() {
const v = process.env.PEARDATA_MEMCACHED
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} urlOrHost
* @returns {{ host: string, port: number }}
*/
export function parseMemcachedAddr(urlOrHost) {
const raw = String(urlOrHost || '127.0.0.1:11211').trim()
if (raw.includes('://')) {
try {
const u = new URL(raw)
return {
host: u.hostname || '127.0.0.1',
port: Number(u.port) || 11211,
}
} catch {
// fall through
}
}
const [host, port] = raw.split(':')
return { host: host || '127.0.0.1', port: Number(port) || 11211 }
}
/**
* @param {string} body
* @returns {Record<string, number>}
*/
export function parseMemcachedStats(body) {
/** @type {Record<string, number>} */
const out = {}
for (const line of String(body || '').split(/\r?\n/)) {
const m = line.match(/^STAT\s+(\S+)\s+(\S+)/)
if (!m) continue
const n = Number(m[2])
if (Number.isFinite(n)) out[m[1]] = n
}
return out
}
/**
* @param {{ host: string, port: number }} addr
* @param {number} [timeoutMs]
* @returns {Promise<string>}
*/
export function fetchMemcachedStats(addr, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: addr.host, port: addr.port })
let buf = ''
let settled = false
const done = (err, data) => {
if (settled) return
settled = true
clearTimeout(timer)
try {
socket.destroy()
} catch {
// ignore
}
if (err) reject(err)
else resolve(data)
}
const timer = setTimeout(() => done(new Error('memcached timeout')), timeoutMs)
socket.on('connect', () => {
socket.write('stats\r\n')
})
socket.on('data', (chunk) => {
buf += chunk.toString('utf8')
if (buf.includes('END')) done(null, buf)
})
socket.on('error', (err) => done(err))
socket.on('end', () => {
if (buf) done(null, buf)
else done(new Error('memcached closed with no data'))
})
})
}
export class MemcachedCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'memcached', intervalMs: opts.intervalMs })
this.addr = parseMemcachedAddr(
opts.url || process.env.PEARDATA_MEMCACHED_URL || '127.0.0.1:11211'
)
/** @type {{ get_hits: number, get_misses: number, cmd_get: number, cmd_set: number, wallMs: number }|null} */
this._prev = null
}
isEnabled() {
return isMemcachedEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_OPS)
registerChart(CHART_MEMORY)
log.info('Memcached collector started', this.addr)
super.start()
}
async collect() {
const raw = await fetchMemcachedStats(this.addr)
const stats = parseMemcachedStats(raw)
const ts = Date.now()
const cur = {
get_hits: Number(stats.get_hits) || 0,
get_misses: Number(stats.get_misses) || 0,
cmd_get: Number(stats.cmd_get) || 0,
cmd_set: Number(stats.cmd_set) || 0,
}
/** @type {Record<string, number>} */
const rates = { get_hits: 0, get_misses: 0, cmd_get: 0, cmd_set: 0 }
if (this._prev && ts > this._prev.wallMs) {
const dt = (ts - this._prev.wallMs) / 1000
if (dt > 0) {
for (const k of Object.keys(rates)) {
rates[k] = Math.max(0, (cur[k] - this._prev[k]) / dt)
}
}
}
this._prev = { ...cur, wallMs: ts }
return [
{
chart: 'memcached.ops',
context: 'memcached.ops',
ts,
values: rates,
},
{
chart: 'memcached.memory',
context: 'memcached.memory',
ts,
values: {
bytes: Number(stats.bytes) || 0,
limit_maxbytes: Number(stats.limit_maxbytes) || 0,
},
},
]
}
}
/** @type {MemcachedCollector|null} */
let singleton = null
export function getMemcachedCollector() {
if (!singleton) singleton = new MemcachedCollector()
return singleton
}
+111
View File
@@ -0,0 +1,111 @@
/**
* MongoDB TCP probe (+ optional serverStatus HTTP sidecar).
* Enable: PEARDATA_MONGODB=1
* URL: PEARDATA_MONGODB_URL=127.0.0.1:27017
*/
import net from 'net'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
export function isMongodbEnabled() {
const v = process.env.PEARDATA_MONGODB
return v === '1' || v === 'on' || v === 'true'
}
function endpoint() {
const raw = process.env.PEARDATA_MONGODB_URL || '127.0.0.1:27017'
const u = raw.replace(/^mongodb:\/\//, '')
const [host, port] = u.split(':')
return { host: host || '127.0.0.1', port: Number(port) || 27017 }
}
function probeTcp(host, port, timeoutMs = 1500) {
return new Promise((resolve) => {
const start = Date.now()
const sock = net.connect({ host, port }, () => {
const ms = Date.now() - start
sock.end()
resolve({ up: 1, latency_ms: ms })
})
sock.setTimeout(timeoutMs)
sock.on('timeout', () => {
sock.destroy()
resolve({ up: 0, latency_ms: timeoutMs })
})
sock.on('error', () => resolve({ up: 0, latency_ms: timeoutMs }))
})
}
export class MongodbCollector extends CollectorPlugin {
constructor() {
super({ name: 'mongodb' })
}
isEnabled() {
return isMongodbEnabled()
}
async collect() {
const ts = Date.now()
const { host, port } = endpoint()
const probe = await probeTcp(host, port)
registerChart({
id: 'mongodb.up',
name: 'mongodb.up',
context: 'mongodb.up',
title: 'MongoDB up',
units: 'boolean',
family: 'mongodb',
chartType: 'line',
priority: 8700,
plugin: 'mongodb',
dimensions: [
{ id: 'up', name: 'up', algorithm: 'absolute' },
{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' },
],
})
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
const batch = [
{ chart: 'mongodb.up', context: 'mongodb.up', ts, values: probe },
]
const statsUrl = process.env.PEARDATA_MONGODB_STATS_URL
if (statsUrl) {
try {
const res = await fetch(statsUrl, { signal: AbortSignal.timeout(2000) })
const text = await res.text()
/** @type {Record<string, number>} */
const vals = {}
for (const line of text.split('\n')) {
const [k, v] = line.split(/[=\s]+/)
if (k && v != null && Number.isFinite(Number(v))) vals[k] = Number(v)
}
registerChart({
id: 'mongodb.stats',
name: 'mongodb.stats',
context: 'mongodb.stats',
title: 'MongoDB stats',
units: 'value',
family: 'mongodb',
chartType: 'line',
priority: 8710,
plugin: 'mongodb',
dimensions: Object.keys(vals).map((id) => ({
id,
name: id,
algorithm: 'absolute',
})),
})
batch.push({ chart: 'mongodb.stats', context: 'mongodb.stats', ts, values: vals })
} catch {
// ignore
}
}
return batch
}
}
let singleton = null
export function getMongodbCollector() {
if (!singleton) singleton = new MongodbCollector()
return singleton
}
+208
View File
@@ -0,0 +1,208 @@
/**
* MySQL collector (service plugin).
*
* Enable: PEARDATA_MYSQL=1
* Addr: PEARDATA_MYSQL_URL=127.0.0.1:3306
* Optional HTTP stats (key=value): PEARDATA_MYSQL_STATS_URL
*
* Charts: mysql.up, mysql.stats
*/
import net from 'net'
import http from 'http'
import https from 'https'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('mysql')
const CHART_UP = {
id: 'mysql.up',
name: 'mysql.up',
context: 'mysql.up',
title: 'MySQL availability',
units: 'boolean',
family: 'mysql',
chartType: 'line',
priority: 8300,
plugin: 'mysql',
dimensions: [
{ id: 'up', name: 'up', algorithm: 'absolute' },
{ id: 'latency_ms', name: 'latency_ms', algorithm: 'absolute' },
],
}
const CHART_STATS = {
id: 'mysql.stats',
name: 'mysql.stats',
context: 'mysql.stats',
title: 'MySQL stats',
units: 'count',
family: 'mysql',
chartType: 'line',
priority: 8310,
plugin: 'mysql',
dimensions: [
{ id: 'questions', name: 'questions', algorithm: 'absolute' },
{ id: 'threads_connected', name: 'threads_connected', algorithm: 'absolute' },
{ id: 'slow_queries', name: 'slow_queries', algorithm: 'absolute' },
],
}
export function isMysqlEnabled() {
const v = process.env.PEARDATA_MYSQL
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} urlOrHost
* @returns {{ host: string, port: number }}
*/
export function parseMysqlAddr(urlOrHost) {
const raw = String(urlOrHost || '127.0.0.1:3306').trim()
if (raw.includes('://')) {
try {
const u = new URL(raw)
return {
host: u.hostname || '127.0.0.1',
port: Number(u.port) || 3306,
}
} catch {
// fall through
}
}
const [host, port] = raw.split(':')
return { host: host || '127.0.0.1', port: Number(port) || 3306 }
}
/**
* @param {string} body
* @returns {Record<string, number>}
*/
export function parseMysqlStats(body) {
/** @type {Record<string, number>} */
const out = {}
for (const line of String(body || '').split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
const m = t.match(/^([a-zA-Z0-9_]+)\s*[=:]\s*([0-9.]+)/)
if (m) out[m[1]] = Number(m[2])
}
return out
}
/**
* @param {{ host: string, port: number }} addr
* @param {number} [timeoutMs]
* @returns {Promise<{ up: number, latency_ms: number }>}
*/
export function probeMysqlTcp(addr, timeoutMs = 3000) {
return new Promise((resolve) => {
const started = Date.now()
const socket = net.createConnection({ host: addr.host, port: addr.port })
let settled = false
const finish = (up) => {
if (settled) return
settled = true
clearTimeout(timer)
try {
socket.destroy()
} catch {
// ignore
}
resolve({ up, latency_ms: Date.now() - started })
}
const timer = setTimeout(() => finish(0), timeoutMs)
socket.on('connect', () => finish(1))
socket.on('error', () => finish(0))
})
}
function fetchText(url, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const mod = String(url).startsWith('https') ? https : http
const req = mod.get(url, (res) => {
let body = ''
res.on('data', (c) => {
body += c
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`))
return
}
resolve(body)
})
})
req.setTimeout(timeoutMs, () => {
req.destroy()
reject(new Error('timeout'))
})
req.on('error', reject)
})
}
export class MysqlCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'mysql', intervalMs: opts.intervalMs })
this.addr = parseMysqlAddr(opts.url || process.env.PEARDATA_MYSQL_URL || '127.0.0.1:3306')
this.statsUrl = opts.statsUrl || process.env.PEARDATA_MYSQL_STATS_URL || ''
}
isEnabled() {
return isMysqlEnabled()
}
start() {
if (!this.isEnabled()) return
registerChart(CHART_UP)
if (this.statsUrl) registerChart(CHART_STATS)
log.info('MySQL collector started', {
addr: this.addr,
statsUrl: this.statsUrl || null,
})
super.start()
}
async collect() {
const ts = Date.now()
const probe = await probeMysqlTcp(this.addr)
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = [
{
chart: 'mysql.up',
context: 'mysql.up',
ts,
values: { up: probe.up, latency_ms: probe.latency_ms },
},
]
if (this.statsUrl) {
try {
const body = await fetchText(this.statsUrl)
const s = parseMysqlStats(body)
registerChart(CHART_STATS)
batch.push({
chart: 'mysql.stats',
context: 'mysql.stats',
ts,
values: {
questions: s.questions ?? s.Queries ?? null,
threads_connected: s.threads_connected ?? s.Threads_connected ?? null,
slow_queries: s.slow_queries ?? s.Slow_queries ?? null,
},
})
} catch (err) {
log.warn('MySQL stats URL failed', { error: err.message })
}
}
return batch
}
}
/** @type {MysqlCollector|null} */
let singleton = null
export function getMysqlCollector() {
if (!singleton) singleton = new MysqlCollector()
return singleton
}
+166
View File
@@ -0,0 +1,166 @@
/**
* NVIDIA GPU metrics via nvidia-smi.
*
* Enable: PEARDATA_NVIDIA=1
*
* Charts: gpu.util.{i}, gpu.mem.{i}, gpu.temp.{i}, gpu.power.{i}
*/
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('nvidia')
const execFileAsync = promisify(execFile)
const QUERY =
'index,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw'
export function isNvidiaEnabled() {
const v = process.env.PEARDATA_NVIDIA
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {number} index
* @param {'util'|'mem'|'temp'|'power'} kind
*/
function makeGpuChart(index, kind) {
const id = `gpu.${kind}.${index}`
const titles = {
util: `GPU ${index} utilization`,
mem: `GPU ${index} memory`,
temp: `GPU ${index} temperature`,
power: `GPU ${index} power`,
}
const units = {
util: 'percentage',
mem: 'MiB',
temp: 'Celsius',
power: 'Watts',
}
const contexts = {
util: 'gpu.utilization',
mem: 'gpu.memory',
temp: 'gpu.temperature',
power: 'gpu.power',
}
const dims =
kind === 'mem'
? [
{ id: 'used', name: 'used', algorithm: 'absolute' },
{ id: 'total', name: 'total', algorithm: 'absolute' },
]
: [{ id: 'value', name: 'value', algorithm: 'absolute' }]
return {
id,
name: id,
context: contexts[kind],
title: titles[kind],
units: units[kind],
family: `gpu${index}`,
chartType: kind === 'mem' ? 'area' : 'line',
priority: 7200 + index,
plugin: 'nvidia',
dimensions: dims,
}
}
/**
* @param {string} stdout
* @returns {Array<{ index: number, util: number, memUsed: number, memTotal: number, temp: number, power: number }>}
*/
export function parseNvidiaSmiCsv(stdout) {
/** @type {Array<{ index: number, util: number, memUsed: number, memTotal: number, temp: number, power: number }>} */
const out = []
for (const line of String(stdout || '').split('\n')) {
const t = line.trim()
if (!t) continue
const parts = t.split(',').map((s) => s.trim())
if (parts.length < 6) continue
out.push({
index: Number(parts[0]) || out.length,
util: Number(parts[1]) || 0,
memUsed: Number(parts[2]) || 0,
memTotal: Number(parts[3]) || 0,
temp: Number(parts[4]) || 0,
power: Number(parts[5]) || 0,
})
}
return out
}
export async function queryNvidiaGpus(timeoutMs = 5000) {
const { stdout } = await execFileAsync(
'nvidia-smi',
[`--query-gpu=${QUERY}`, '--format=csv,noheader,nounits'],
{ timeout: timeoutMs, encoding: 'utf8' }
)
return parseNvidiaSmiCsv(stdout)
}
export class NvidiaCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'nvidia', intervalMs: opts.intervalMs })
/** @type {boolean|null} */
this._available = null
}
isEnabled() {
return isNvidiaEnabled()
}
start() {
if (!this.isEnabled()) return
log.info('NVIDIA collector started')
super.start()
}
async collect() {
let gpus
try {
gpus = await queryNvidiaGpus()
this._available = true
} catch (err) {
if (this._available !== false) {
log.info('nvidia-smi unavailable — collector idle', { error: err.message })
this._available = false
}
return []
}
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
for (const g of gpus) {
const utilDef = makeGpuChart(g.index, 'util')
const memDef = makeGpuChart(g.index, 'mem')
const tempDef = makeGpuChart(g.index, 'temp')
const powerDef = makeGpuChart(g.index, 'power')
registerChart(utilDef)
registerChart(memDef)
registerChart(tempDef)
registerChart(powerDef)
batch.push(
{ chart: utilDef.id, context: utilDef.context, ts, values: { value: g.util } },
{
chart: memDef.id,
context: memDef.context,
ts,
values: { used: g.memUsed, total: g.memTotal },
},
{ chart: tempDef.id, context: tempDef.context, ts, values: { value: g.temp } },
{ chart: powerDef.id, context: powerDef.context, ts, values: { value: g.power } }
)
}
return batch
}
}
/** @type {NvidiaCollector|null} */
let singleton = null
export function getNvidiaCollector() {
if (!singleton) singleton = new NvidiaCollector()
return singleton
}
+126 -10
View File
@@ -7,6 +7,8 @@
* Emits:
* processes.top_cpu % of one host CPU for top processes (by name)
* processes.top_rss RSS MiB for top processes (by RSS)
* processes.top_io read+write byte rates for top processes (by I/O)
* processes.top_threads thread count for top processes (by threads)
*
* Linux /proc only; no-op elsewhere.
*/
@@ -46,7 +48,7 @@ function sanitizeDim(name) {
}
/**
* @returns {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number }>|null}
* @returns {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>|null}
*/
export function listProcStats() {
if (os.platform() !== 'linux') return null
@@ -56,7 +58,7 @@ export function listProcStats() {
} catch {
return null
}
/** @type {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number }>} */
/** @type {Array<{ pid: number, name: string, utime: number, stime: number, rssPages: number, threads: number, readBytes: number|null, writeBytes: number|null }>} */
const out = []
for (const ent of dirs) {
if (!/^\d+$/.test(ent)) continue
@@ -77,12 +79,37 @@ export function listProcStats() {
const stime = Number(rest[12])
const rssPages = Number(rest[21])
if (!Number.isFinite(utime) || !Number.isFinite(stime)) continue
let threads = 0
try {
const status = fs.readFileSync(path.join('/proc', ent, 'status'), 'utf8')
const m = status.match(/^Threads:\s*(\d+)/m)
if (m) threads = Number(m[1]) || 0
} catch {
// ignore
}
let readBytes = null
let writeBytes = null
try {
const ioRaw = fs.readFileSync(path.join('/proc', ent, 'io'), 'utf8')
const rb = ioRaw.match(/^read_bytes:\s*(\d+)/m)
const wb = ioRaw.match(/^write_bytes:\s*(\d+)/m)
if (rb) readBytes = Number(rb[1]) || 0
if (wb) writeBytes = Number(wb[1]) || 0
} catch {
// unreadable for this pid
}
out.push({
pid,
name: sanitizeDim(name),
utime,
stime,
rssPages: Number.isFinite(rssPages) ? rssPages : 0,
threads,
readBytes,
writeBytes,
})
}
return out
@@ -98,7 +125,7 @@ function pageSize() {
/**
* @param {string[]} names
* @param {'cpu'|'rss'} kind
* @param {'cpu'|'rss'|'io'|'threads'} kind
*/
function registerTopChart(names, kind) {
const dims = [...new Set(names)].slice(0, topN()).map((id) => ({
@@ -109,9 +136,10 @@ function registerTopChart(names, kind) {
if (!dims.length) {
dims.push({ id: '_idle', name: '_idle', algorithm: 'absolute' })
}
const def =
kind === 'cpu'
? {
/** @type {import('../../../shared/metrics.js').ChartDef} */
let def
if (kind === 'cpu') {
def = {
id: 'processes.top_cpu',
name: 'processes.top_cpu',
context: 'processes.top_cpu',
@@ -123,7 +151,8 @@ function registerTopChart(names, kind) {
plugin: 'processes',
dimensions: dims,
}
: {
} else if (kind === 'rss') {
def = {
id: 'processes.top_rss',
name: 'processes.top_rss',
context: 'processes.top_rss',
@@ -135,6 +164,33 @@ function registerTopChart(names, kind) {
plugin: 'processes',
dimensions: dims,
}
} else if (kind === 'io') {
def = {
id: 'processes.top_io',
name: 'processes.top_io',
context: 'processes.top_io',
title: 'Top processes I/O',
units: 'KiB/s',
family: 'processes',
chartType: 'stacked',
priority: 6020,
plugin: 'processes',
dimensions: dims,
}
} else {
def = {
id: 'processes.top_threads',
name: 'processes.top_threads',
context: 'processes.top_threads',
title: 'Top processes threads',
units: 'threads',
family: 'processes',
chartType: 'stacked',
priority: 6030,
plugin: 'processes',
dimensions: dims,
}
}
registerChart(def)
return def
}
@@ -144,7 +200,7 @@ export class ProcessCollector extends EventEmitter {
super()
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
this._timer = null
/** @type {Map<number, { ticks: number, wallMs: number, name: string }>|null} */
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>|null} */
this._prev = null
this._pageBytes = 4096
}
@@ -176,6 +232,8 @@ export class ProcessCollector extends EventEmitter {
/** @type {Map<string, number>} */
const cpuByName = new Map()
/** @type {Map<string, number>} */
const ioByName = new Map()
if (this._prev) {
for (const p of procs) {
const prev = this._prev.get(p.pid)
@@ -188,20 +246,41 @@ export class ProcessCollector extends EventEmitter {
const pct = (dTicks / 100 / dSec) * 100
const key = p.name
cpuByName.set(key, (cpuByName.get(key) || 0) + Math.min(100 * ncpu, pct))
if (
p.readBytes != null &&
p.writeBytes != null &&
prev.readBytes != null &&
prev.writeBytes != null
) {
const dBytes =
Math.max(0, p.readBytes - prev.readBytes) +
Math.max(0, p.writeBytes - prev.writeBytes)
const rateKib = dBytes / dSec / 1024
ioByName.set(key, (ioByName.get(key) || 0) + rateKib)
}
}
}
/** @type {Map<number, { ticks: number, wallMs: number, name: string }>} */
/** @type {Map<number, { ticks: number, wallMs: number, name: string, readBytes: number, writeBytes: number }>} */
const next = new Map()
for (const p of procs) {
next.set(p.pid, { ticks: p.utime + p.stime, wallMs, name: p.name })
next.set(p.pid, {
ticks: p.utime + p.stime,
wallMs,
name: p.name,
readBytes: p.readBytes ?? 0,
writeBytes: p.writeBytes ?? 0,
})
}
this._prev = next
const cpuRanked = [...cpuByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const ioRanked = [...ioByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const rssRanked = [...procs]
.sort((a, b) => b.rssPages - a.rssPages)
.slice(0, limit)
const threadRanked = [...procs].sort((a, b) => b.threads - a.threads).slice(0, limit)
/** @type {Map<string, number>} */
const rssByName = new Map()
@@ -211,6 +290,13 @@ export class ProcessCollector extends EventEmitter {
}
const rssTop = [...rssByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
/** @type {Map<string, number>} */
const threadsByName = new Map()
for (const p of threadRanked) {
threadsByName.set(p.name, (threadsByName.get(p.name) || 0) + p.threads)
}
const threadsTop = [...threadsByName.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit)
const cpuDef = registerTopChart(
cpuRanked.map(([n]) => n),
'cpu'
@@ -219,6 +305,14 @@ export class ProcessCollector extends EventEmitter {
rssTop.map(([n]) => n),
'rss'
)
const ioDef = registerTopChart(
ioRanked.map(([n]) => n),
'io'
)
const threadsDef = registerTopChart(
threadsTop.map(([n]) => n),
'threads'
)
/** @type {Record<string, number|null>} */
const cpuValues = {}
@@ -230,6 +324,16 @@ export class ProcessCollector extends EventEmitter {
for (const d of rssDef.dimensions) rssValues[d.id] = 0
for (const [name, mib] of rssTop) rssValues[name] = mib
/** @type {Record<string, number|null>} */
const ioValues = {}
for (const d of ioDef.dimensions) ioValues[d.id] = 0
for (const [name, rate] of ioRanked) ioValues[name] = rate
/** @type {Record<string, number|null>} */
const threadValues = {}
for (const d of threadsDef.dimensions) threadValues[d.id] = 0
for (const [name, count] of threadsTop) threadValues[name] = count
this.emit('samples', [
{
chart: 'processes.top_cpu',
@@ -243,6 +347,18 @@ export class ProcessCollector extends EventEmitter {
ts,
values: rssValues,
},
{
chart: 'processes.top_io',
context: 'processes.top_io',
ts,
values: ioValues,
},
{
chart: 'processes.top_threads',
context: 'processes.top_threads',
ts,
values: threadValues,
},
])
} catch (err) {
log.warn('Process tick failed', { error: err.message })
+172
View File
@@ -0,0 +1,172 @@
/**
* Prometheus text exposition scraper (service plugin).
*
* Enable: PEARDATA_PROMETHEUS=1
* URLs: PEARDATA_PROMETHEUS_URLS=comma-separated scrape URLs
*
* Charts: prom.{sanitized_metric} one gauge/counter value per metric (max 40)
*/
import http from 'http'
import https from 'https'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('prometheus')
const MAX_METRICS = 40
export function isPrometheusEnabled() {
const v = process.env.PEARDATA_PROMETHEUS
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} raw
* @returns {string[]}
*/
export function parsePrometheusUrls(raw) {
return String(raw || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
/**
* Sanitize metric name for chart id.
* @param {string} name
*/
export function sanitizeMetricName(name) {
return String(name)
.replace(/[^a-zA-Z0-9_.-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 96)
}
/**
* Parse Prometheus text exposition format.
* Skips histogram/summary _bucket lines; returns latest scalar per metric name.
* @param {string} body
* @returns {Record<string, number>}
*/
export function parsePrometheusText(body) {
/** @type {Record<string, number>} */
const out = {}
for (const line of String(body || '').split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
if (t.includes('_bucket{') || t.endsWith('_bucket')) continue
if (t.includes('_sum{') || t.endsWith('_sum')) continue
if (t.includes('_count{') || t.endsWith('_count')) continue
const sp = t.lastIndexOf(' ')
if (sp < 0) continue
let namePart = t.slice(0, sp).trim()
const valStr = t.slice(sp + 1).trim()
const brace = namePart.indexOf('{')
if (brace >= 0) namePart = namePart.slice(0, brace)
if (namePart.endsWith('_bucket') || namePart.endsWith('_sum') || namePart.endsWith('_count')) {
continue
}
const val = Number(valStr)
if (!Number.isFinite(val)) continue
out[namePart] = val
}
return out
}
function makePromChart(metric) {
const safe = sanitizeMetricName(metric)
return {
id: `prom.${safe}`,
name: `prom.${safe}`,
context: 'prometheus.metric',
title: `Prometheus ${metric}`,
units: 'value',
family: 'prometheus',
chartType: 'line',
priority: 8600,
plugin: 'prometheus',
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
}
}
function fetchText(url, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const mod = String(url).startsWith('https') ? https : http
const req = mod.get(url, (res) => {
let body = ''
res.on('data', (c) => {
body += c
})
res.on('end', () => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`))
return
}
resolve(body)
})
})
req.setTimeout(timeoutMs, () => {
req.destroy()
reject(new Error('timeout'))
})
req.on('error', reject)
})
}
export class PrometheusCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'prometheus', intervalMs: opts.intervalMs })
this.urls = parsePrometheusUrls(
opts.urls || process.env.PEARDATA_PROMETHEUS_URLS || ''
)
}
isEnabled() {
return isPrometheusEnabled() && this.urls.length > 0
}
start() {
if (!this.isEnabled()) return
log.info('Prometheus collector started', { urls: this.urls.length })
super.start()
}
async collect() {
/** @type {Record<string, number>} */
const merged = {}
for (const url of this.urls) {
try {
const body = await fetchText(url)
const parsed = parsePrometheusText(body)
Object.assign(merged, parsed)
} catch (err) {
log.warn('Prometheus scrape failed', { url, error: err.message })
}
}
const names = Object.keys(merged).slice(0, MAX_METRICS)
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number> }>} */
const batch = []
for (const name of names) {
const def = makePromChart(name)
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { value: merged[name] },
})
}
return batch
}
}
/** @type {PrometheusCollector|null} */
let singleton = null
export function getPrometheusCollector() {
if (!singleton) singleton = new PrometheusCollector()
return singleton
}
+145
View File
@@ -0,0 +1,145 @@
/**
* RabbitMQ management API collector.
* Enable: PEARDATA_RABBITMQ=1
* URL: PEARDATA_RABBITMQ_URL=http://guest:[email protected]:15672
*/
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
export function isRabbitmqEnabled() {
const v = process.env.PEARDATA_RABBITMQ
return v === '1' || v === 'on' || v === 'true'
}
function baseUrl() {
return (process.env.PEARDATA_RABBITMQ_URL || 'http://guest:[email protected]:15672').replace(
/\/$/,
''
)
}
export class RabbitmqCollector extends CollectorPlugin {
constructor() {
super({ name: 'rabbitmq' })
this._prev = null
this._prevTs = 0
}
isEnabled() {
return isRabbitmqEnabled()
}
async collect() {
const ts = Date.now()
const overview = await fetchJson(`${baseUrl()}/api/overview`)
if (!overview) {
registerChart({
id: 'rabbitmq.up',
name: 'rabbitmq.up',
context: 'rabbitmq.up',
title: 'RabbitMQ up',
units: 'boolean',
family: 'rabbitmq',
chartType: 'line',
priority: 8600,
plugin: 'rabbitmq',
dimensions: [{ id: 'up', name: 'up', algorithm: 'absolute' }],
})
return [{ chart: 'rabbitmq.up', context: 'rabbitmq.up', ts, values: { up: 0 } }]
}
const q = overview.queue_totals || {}
const m = overview.message_stats || {}
const dt = this._prevTs ? (ts - this._prevTs) / 1000 : 0
const prev = this._prev
const rate = (key) => {
const cur = m[key] || 0
if (!prev || dt <= 0) return 0
return Math.max(0, (cur - (prev[key] || 0)) / dt)
}
registerChart({
id: 'rabbitmq.up',
name: 'rabbitmq.up',
context: 'rabbitmq.up',
title: 'RabbitMQ up',
units: 'boolean',
family: 'rabbitmq',
chartType: 'line',
priority: 8600,
plugin: 'rabbitmq',
dimensions: [{ id: 'up', name: 'up', algorithm: 'absolute' }],
})
registerChart({
id: 'rabbitmq.queues',
name: 'rabbitmq.queues',
context: 'rabbitmq.queues',
title: 'RabbitMQ queue messages',
units: 'messages',
family: 'rabbitmq',
chartType: 'line',
priority: 8610,
plugin: 'rabbitmq',
dimensions: [
{ id: 'ready', name: 'ready', algorithm: 'absolute' },
{ id: 'unacked', name: 'unacked', algorithm: 'absolute' },
{ id: 'total', name: 'total', algorithm: 'absolute' },
],
})
registerChart({
id: 'rabbitmq.rates',
name: 'rabbitmq.rates',
context: 'rabbitmq.rates',
title: 'RabbitMQ message rates',
units: 'messages/s',
family: 'rabbitmq',
chartType: 'line',
priority: 8620,
plugin: 'rabbitmq',
dimensions: [
{ id: 'publish', name: 'publish', algorithm: 'incremental' },
{ id: 'deliver', name: 'deliver', algorithm: 'incremental' },
{ id: 'ack', name: 'ack', algorithm: 'incremental' },
],
})
this._prev = { publish: m.publish || 0, deliver: m.deliver_get || 0, ack: m.ack || 0 }
this._prevTs = ts
return [
{ chart: 'rabbitmq.up', context: 'rabbitmq.up', ts, values: { up: 1 } },
{
chart: 'rabbitmq.queues',
context: 'rabbitmq.queues',
ts,
values: {
ready: q.messages_ready || 0,
unacked: q.messages_unacknowledged || 0,
total: q.messages || 0,
},
},
{
chart: 'rabbitmq.rates',
context: 'rabbitmq.rates',
ts,
values: {
publish: rate('publish'),
deliver: rate('deliver_get'),
ack: rate('ack'),
},
},
]
}
}
async function fetchJson(url) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) })
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
let singleton = null
export function getRabbitmqCollector() {
if (!singleton) singleton = new RabbitmqCollector()
return singleton
}
+133
View File
@@ -0,0 +1,133 @@
/**
* PearData process self-monitor (always enabled).
*
* Charts: peardata.cpu, peardata.mem, peardata.charts
*/
import { CollectorPlugin } from './plugin.js'
import { registerChart, getAllChartDefs } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('self-monitor')
const CHART_CPU = {
id: 'peardata.cpu',
name: 'peardata.cpu',
context: 'peardata.cpu',
title: 'PearData CPU usage',
units: 'percentage',
family: 'peardata',
chartType: 'line',
priority: 9000,
plugin: 'self-monitor',
dimensions: [
{ id: 'user', name: 'user', algorithm: 'absolute' },
{ id: 'system', name: 'system', algorithm: 'absolute' },
],
}
const CHART_MEM = {
id: 'peardata.mem',
name: 'peardata.mem',
context: 'peardata.mem',
title: 'PearData memory',
units: 'MiB',
family: 'peardata',
chartType: 'area',
priority: 9010,
plugin: 'self-monitor',
dimensions: [
{ id: 'rss', name: 'rss', algorithm: 'absolute' },
{ id: 'heap', name: 'heap', algorithm: 'absolute' },
],
}
const CHART_CHARTS = {
id: 'peardata.charts',
name: 'peardata.charts',
context: 'peardata.charts',
title: 'Registered chart count',
units: 'charts',
family: 'peardata',
chartType: 'line',
priority: 9020,
plugin: 'self-monitor',
dimensions: [{ id: 'count', name: 'count', algorithm: 'absolute' }],
}
export function isSelfMonitorEnabled() {
return true
}
export class SelfMonitorCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'self-monitor', intervalMs: opts.intervalMs })
/** @type {{ user: number, system: number }|null} */
this._prevCpu = null
/** @type {number|null} */
this._prevWallMs = null
}
isEnabled() {
return isSelfMonitorEnabled()
}
start() {
registerChart(CHART_CPU)
registerChart(CHART_MEM)
registerChart(CHART_CHARTS)
log.info('Self-monitor collector started')
super.start()
}
async collect() {
const ts = Date.now()
const mem = process.memoryUsage()
const cpu = process.cpuUsage()
let userPct = 0
let sysPct = 0
if (this._prevCpu && this._prevWallMs && ts > this._prevWallMs) {
const dtSec = (ts - this._prevWallMs) / 1000
if (dtSec > 0) {
const dUser = cpu.user - this._prevCpu.user
const dSys = cpu.system - this._prevCpu.system
userPct = (dUser / 1e6 / dtSec) * 100
sysPct = (dSys / 1e6 / dtSec) * 100
}
}
this._prevCpu = { user: cpu.user, system: cpu.system }
this._prevWallMs = ts
return [
{
chart: 'peardata.cpu',
context: 'peardata.cpu',
ts,
values: { user: userPct, system: sysPct },
},
{
chart: 'peardata.mem',
context: 'peardata.mem',
ts,
values: {
rss: mem.rss / (1024 * 1024),
heap: mem.heapUsed / (1024 * 1024),
},
},
{
chart: 'peardata.charts',
context: 'peardata.charts',
ts,
values: { count: getAllChartDefs().length },
},
]
}
}
/** @type {SelfMonitorCollector|null} */
let singleton = null
export function getSelfMonitorCollector() {
if (!singleton) singleton = new SelfMonitorCollector()
return singleton
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Hardware sensors via sysfs hwmon + thermal zones.
*
* Enable: PEARDATA_SENSORS=1 (default on Linux when unset)
* Disable: PEARDATA_SENSORS=0
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
import { EventEmitter } from 'events'
import {
SAMPLE_INTERVAL_MS,
registerChart,
makeSensorTempChart,
makeThermalZoneChart,
} from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('sensors')
export function isSensorsEnabled() {
const v = process.env.PEARDATA_SENSORS
if (v === '0' || v === 'off' || v === 'false') return false
if (v === '1' || v === 'on' || v === 'true') return true
return os.platform() === 'linux'
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function makeSensorFanChart(chip, label) {
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
return {
id: `sensors.fan.${id}`,
name: `sensors.fan.${id}`,
context: 'sensors.fan',
title: `Fan ${chip} ${label}`,
units: 'RPM',
family: chip,
chartType: 'line',
priority: 7040,
plugin: 'sensors',
dimensions: [{ id: 'rpm', name: 'rpm', algorithm: 'absolute' }],
}
}
function makeSensorVoltageChart(chip, label) {
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
return {
id: `sensors.voltage.${id}`,
name: `sensors.voltage.${id}`,
context: 'sensors.voltage',
title: `Voltage ${chip} ${label}`,
units: 'mV',
family: chip,
chartType: 'line',
priority: 7050,
plugin: 'sensors',
dimensions: [{ id: 'millivolts', name: 'millivolts', algorithm: 'absolute' }],
}
}
function makeSensorPowerChart(chip, label) {
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
return {
id: `sensors.power.${id}`,
name: `sensors.power.${id}`,
context: 'sensors.power',
title: `Power ${chip} ${label}`,
units: 'microWatts',
family: chip,
chartType: 'line',
priority: 7060,
plugin: 'sensors',
dimensions: [{ id: 'microwatts', name: 'microwatts', algorithm: 'absolute' }],
}
}
/**
* @returns {Array<{ chartId: string, context: string, values: Record<string, number> }>}
*/
export function sampleSensors() {
/** @type {Array<{ chartId: string, context: string, values: Record<string, number> }>} */
const out = []
const hwmonRoot = '/sys/class/hwmon'
try {
const chips = fs.readdirSync(hwmonRoot)
for (const chip of chips) {
const dir = path.join(hwmonRoot, chip)
const name = (readFile(path.join(dir, 'name')) || chip).trim()
let ents
try {
ents = fs.readdirSync(dir)
} catch {
continue
}
for (const ent of ents) {
const m = ent.match(/^temp(\d+)_input$/)
if (!m) continue
const n = m[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const milli = Number(raw.trim())
if (!Number.isFinite(milli)) continue
const label =
(readFile(path.join(dir, `temp${n}_label`)) || `temp${n}`).trim() || `temp${n}`
const def = makeSensorTempChart(name, label)
registerChart(def)
out.push({
chartId: def.id,
context: def.context,
values: { temperature: milli / 1000 },
})
}
const fan = ent.match(/^fan(\d+)_input$/)
if (fan) {
const n = fan[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const rpm = Number(raw.trim())
if (!Number.isFinite(rpm)) continue
const label =
(readFile(path.join(dir, `fan${n}_label`)) || `fan${n}`).trim() || `fan${n}`
const def = makeSensorFanChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { rpm } })
}
const vin = ent.match(/^in(\d+)_input$/)
if (vin) {
const n = vin[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const mv = Number(raw.trim())
if (!Number.isFinite(mv)) continue
const label =
(readFile(path.join(dir, `in${n}_label`)) || `in${n}`).trim() || `in${n}`
const def = makeSensorVoltageChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { millivolts: mv } })
}
const pwr = ent.match(/^power(\d+)_input$/)
if (pwr) {
const n = pwr[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const uw = Number(raw.trim())
if (!Number.isFinite(uw)) continue
const label =
(readFile(path.join(dir, `power${n}_label`)) || `power${n}`).trim() || `power${n}`
const def = makeSensorPowerChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { microwatts: uw } })
}
}
} catch {
// no hwmon
}
const thermalRoot = '/sys/class/thermal'
try {
for (const zone of fs.readdirSync(thermalRoot)) {
if (!zone.startsWith('thermal_zone')) continue
const dir = path.join(thermalRoot, zone)
const type = (readFile(path.join(dir, 'type')) || zone).trim()
const raw = readFile(path.join(dir, 'temp'))
if (raw == null) continue
const milli = Number(raw.trim())
if (!Number.isFinite(milli) || milli === 0) continue
// some platforms report already in C
const celsius = milli > 1000 ? milli / 1000 : milli
const def = makeThermalZoneChart(zone, type)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { temperature: celsius } })
}
} catch {
// no thermal
}
return out
}
export class SensorsCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs =
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
this.timer = null
this.running = false
}
start() {
if (this.running) return
this.running = true
this._tick()
this.timer = setInterval(() => this._tick(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
log.info('Sensors collector started')
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
}
_tick() {
const ts = Date.now()
const samples = sampleSensors()
if (!samples.length) return
const batch = samples.map((s) => ({
chart: s.chartId,
context: s.context,
ts,
values: s.values,
}))
this.emit('samples', batch)
}
}
/** @type {SensorsCollector|null} */
let singleton = null
export function getSensorsCollector() {
if (!singleton) singleton = new SensorsCollector()
return singleton
}
+187
View File
@@ -0,0 +1,187 @@
/**
* SMART disk health collector via smartctl.
*
* Enable: PEARDATA_SMART=1
*
* Charts: smart.temp.{dev}, smart.reallocated.{dev}
*/
import { execFile } from 'child_process'
import { promisify } from 'util'
import { CollectorPlugin } from './plugin.js'
import { registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('smart')
const execFileAsync = promisify(execFile)
const DEVICE_TIMEOUT_MS = 2000
export function isSmartEnabled() {
const v = process.env.PEARDATA_SMART
return v === '1' || v === 'on' || v === 'true'
}
/**
* @param {string} dev
*/
function sanitizeDev(dev) {
return String(dev)
.replace(/[^a-zA-Z0-9_.-]/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64)
}
/**
* @param {string} dev
* @param {'temp'|'reallocated'} kind
*/
function makeSmartChart(dev, kind) {
const safe = sanitizeDev(dev)
const id = kind === 'temp' ? `smart.temp.${safe}` : `smart.reallocated.${safe}`
return {
id,
name: id,
context: kind === 'temp' ? 'smart.temperature' : 'smart.reallocated',
title: kind === 'temp' ? `SMART temperature ${dev}` : `SMART reallocated ${dev}`,
units: kind === 'temp' ? 'Celsius' : 'sectors',
family: safe,
chartType: 'line',
priority: kind === 'temp' ? 7100 : 7110,
plugin: 'smart',
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
}
}
/**
* @param {number} timeoutMs
* @returns {Promise<string[]|null>}
*/
export async function scanSmartDevices(timeoutMs = DEVICE_TIMEOUT_MS) {
try {
const { stdout } = await execFileAsync('smartctl', ['--scan'], {
timeout: timeoutMs,
encoding: 'utf8',
})
/** @type {string[]} */
const devs = []
for (const line of String(stdout || '').split('\n')) {
const parts = line.trim().split(/\s+/)
if (parts[0] && !parts[0].startsWith('#')) devs.push(parts[0])
}
return devs.length ? devs : null
} catch {
return null
}
}
/**
* @param {string} dev
* @returns {Promise<{ temp: number|null, reallocated: number|null }|null>}
*/
export async function readSmartAttributes(dev) {
try {
const { stdout } = await execFileAsync('smartctl', ['-A', '-j', dev], {
timeout: DEVICE_TIMEOUT_MS,
encoding: 'utf8',
})
const json = JSON.parse(stdout)
const attrs = json?.ata_smart_attributes?.table || json?.table || []
let temp = null
let reallocated = null
for (const a of attrs) {
const id = a.id ?? a.attr_id
const val = a.raw?.value ?? a.raw_value ?? a.value
if (id === 194 || a.name === 'Temperature_Celsius') temp = Number(val)
if (id === 5 || a.name === 'Reallocated_Sector_Ct') reallocated = Number(val)
}
if (temp == null && json?.temperature?.current != null) temp = Number(json.temperature.current)
return { temp: Number.isFinite(temp) ? temp : null, reallocated: Number.isFinite(reallocated) ? reallocated : null }
} catch {
try {
const { stdout } = await execFileAsync('smartctl', ['-A', dev], {
timeout: DEVICE_TIMEOUT_MS,
encoding: 'utf8',
})
let temp = null
let reallocated = null
for (const line of String(stdout || '').split('\n')) {
const m = line.match(/^\s*(\d+)\s+(\S+)\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(\d+)/)
if (!m) continue
const id = Number(m[1])
const val = Number(m[3])
if (id === 194) temp = val
if (id === 5) reallocated = val
}
return {
temp: Number.isFinite(temp) ? temp : null,
reallocated: Number.isFinite(reallocated) ? reallocated : null,
}
} catch {
return null
}
}
}
export class SmartCollector extends CollectorPlugin {
constructor(opts = {}) {
super({ name: 'smart', intervalMs: opts.intervalMs })
/** @type {string[]|null} */
this.devices = null
}
isEnabled() {
return isSmartEnabled()
}
start() {
if (!this.isEnabled()) return
log.info('SMART collector started')
super.start()
}
async collect() {
if (this.devices === null) {
this.devices = (await scanSmartDevices()) || []
if (!this.devices.length) {
log.info('smartctl unavailable or no devices — collector idle')
return []
}
}
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = []
for (const dev of this.devices) {
const attrs = await readSmartAttributes(dev)
if (!attrs) continue
if (attrs.temp != null) {
const def = makeSmartChart(dev, 'temp')
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { value: attrs.temp },
})
}
if (attrs.reallocated != null) {
const def = makeSmartChart(dev, 'reallocated')
registerChart(def)
batch.push({
chart: def.id,
context: def.context,
ts,
values: { value: attrs.reallocated },
})
}
}
return batch
}
}
/** @type {SmartCollector|null} */
let singleton = null
export function getSmartCollector() {
if (!singleton) singleton = new SmartCollector()
return singleton
}
+112
View File
@@ -0,0 +1,112 @@
/**
* StatsD UDP ingest maps gauges/counters into prom-like charts.
* Enable: PEARDATA_STATSD=1
* Bind: PEARDATA_STATSD_PORT=8125 (default)
*/
import dgram from 'dgram'
import { EventEmitter } from 'events'
import { SAMPLE_INTERVAL_MS, registerChart } from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('statsd')
export function isStatsdEnabled() {
const v = process.env.PEARDATA_STATSD
return v === '1' || v === 'on' || v === 'true'
}
function sanitize(name) {
return String(name)
.replace(/[^a-zA-Z0-9_.-]/g, '_')
.slice(0, 80)
}
export class StatsdCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.port = opts.port ?? (Number(process.env.PEARDATA_STATSD_PORT) || 8125)
this.intervalMs =
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
/** @type {Map<string, number>} */
this.values = new Map()
this.socket = null
this.timer = null
this.running = false
}
start() {
if (this.running) return
this.running = true
this.socket = dgram.createSocket('udp4')
this.socket.on('message', (msg) => this._onMessage(msg.toString('utf8')))
this.socket.on('error', (err) => log.warn('StatsD socket error', { error: err.message }))
this.socket.bind(this.port, '127.0.0.1', () => {
log.info('StatsD listening', { port: this.port })
})
this.timer = setInterval(() => this._flush(), this.intervalMs)
if (this.timer.unref) this.timer.unref()
}
stop() {
this.running = false
if (this.timer) clearInterval(this.timer)
this.timer = null
try {
this.socket?.close()
} catch {
// ignore
}
this.socket = null
}
_onMessage(text) {
for (const line of text.split('\n')) {
if (!line.trim()) continue
// name:value|type
const m = line.trim().match(/^([^:]+):(-?[\d.]+)\|([a-z]+)(?:\|@[\d.]+)?$/i)
if (!m) continue
const name = sanitize(m[1])
const value = Number(m[2])
const type = m[3].toLowerCase()
if (!Number.isFinite(value)) continue
if (type === 'c') {
this.values.set(name, (this.values.get(name) || 0) + value)
} else {
this.values.set(name, value)
}
}
}
_flush() {
if (!this.values.size) return
const ts = Date.now()
/** @type {Array<{ chart: string, context: string, ts: number, values: object }>} */
const batch = []
let n = 0
for (const [name, value] of this.values) {
if (n++ >= 64) break
const id = `statsd.${name}`
registerChart({
id,
name: id,
context: 'statsd.metric',
title: `StatsD ${name}`,
units: 'value',
family: 'statsd',
chartType: 'line',
priority: 8500,
plugin: 'statsd',
dimensions: [{ id: 'value', name: 'value', algorithm: 'absolute' }],
})
batch.push({ chart: id, context: 'statsd.metric', ts, values: { value } })
}
if (batch.length) this.emit('samples', batch)
}
}
/** @type {StatsdCollector|null} */
let singleton = null
export function getStatsdCollector() {
if (!singleton) singleton = new StatsdCollector()
return singleton
}
+683
View File
@@ -0,0 +1,683 @@
/**
* Extended host chart catalog (deep /proc + /sys coverage).
* Merged into STATIC_CHART_DEFS by shared/metrics.js.
*/
/** @typedef {import('./metrics.js').ChartDef} ChartDef */
/** @type {ChartDef[]} */
export const DEEP_STATIC_CHARTS = [
// ── Memory deep ───────────────────────────────────────────
{
id: 'mem.zswap',
name: 'mem.zswap',
context: 'mem.zswap',
title: 'Zswap usage',
units: 'MiB',
family: 'zswap',
chartType: 'stacked',
priority: 248,
plugin: 'proc',
dimensions: [
{ id: 'in_ram', name: 'in_ram', algorithm: 'absolute' },
{ id: 'on_disk', name: 'on_disk', algorithm: 'absolute' },
],
},
{
id: 'mem.hwcorrupt',
name: 'mem.hwcorrupt',
context: 'mem.hwcorrupt',
title: 'Hardware corrupted memory',
units: 'MiB',
family: 'hardware',
chartType: 'line',
priority: 249,
plugin: 'proc',
dimensions: [{ id: 'HardwareCorrupted', name: 'HardwareCorrupted', algorithm: 'absolute' }],
},
{
id: 'mem.hugepages',
name: 'mem.hugepages',
context: 'mem.hugepages',
title: 'HugePages',
units: 'MiB',
family: 'hugepages',
chartType: 'stacked',
priority: 252,
plugin: 'proc',
dimensions: [
{ id: 'free', name: 'free', algorithm: 'absolute' },
{ id: 'used', name: 'used', algorithm: 'absolute' },
{ id: 'surplus', name: 'surplus', algorithm: 'absolute' },
{ id: 'reserved', name: 'reserved', algorithm: 'absolute' },
],
},
{
id: 'mem.thp',
name: 'mem.thp',
context: 'mem.thp',
title: 'Transparent HugePages',
units: 'MiB',
family: 'thp',
chartType: 'stacked',
priority: 253,
plugin: 'proc',
dimensions: [
{ id: 'anonymous', name: 'anonymous', algorithm: 'absolute' },
{ id: 'shmem', name: 'shmem', algorithm: 'absolute' },
],
},
{
id: 'mem.thp_details',
name: 'mem.thp_details',
context: 'mem.thp_details',
title: 'Transparent HugePages details',
units: 'MiB',
family: 'thp',
chartType: 'line',
priority: 254,
plugin: 'proc',
dimensions: [
{ id: 'ShmemPmdMapped', name: 'ShmemPmdMapped', algorithm: 'absolute' },
{ id: 'FileHugePages', name: 'FileHugePages', algorithm: 'absolute' },
{ id: 'FilePmdMapped', name: 'FilePmdMapped', algorithm: 'absolute' },
],
},
{
id: 'mem.reclaiming',
name: 'mem.reclaiming',
context: 'mem.reclaiming',
title: 'Memory reclaiming (LRU)',
units: 'MiB',
family: 'reclaiming',
chartType: 'stacked',
priority: 255,
plugin: 'proc',
dimensions: [
{ id: 'Active', name: 'Active', algorithm: 'absolute' },
{ id: 'Inactive', name: 'Inactive', algorithm: 'absolute' },
{ id: 'Active_anon', name: 'Active_anon', algorithm: 'absolute' },
{ id: 'Inactive_anon', name: 'Inactive_anon', algorithm: 'absolute' },
{ id: 'Active_file', name: 'Active_file', algorithm: 'absolute' },
{ id: 'Inactive_file', name: 'Inactive_file', algorithm: 'absolute' },
{ id: 'Unevictable', name: 'Unevictable', algorithm: 'absolute' },
{ id: 'Mlocked', name: 'Mlocked', algorithm: 'absolute' },
],
},
{
id: 'mem.oom_kill',
name: 'mem.oom_kill',
context: 'mem.oom_kill',
title: 'OOM kills',
units: 'kills/s',
family: 'oom',
chartType: 'line',
priority: 256,
plugin: 'proc',
dimensions: [{ id: 'kills', name: 'kills', algorithm: 'incremental' }],
},
{
id: 'mem.balloon',
name: 'mem.balloon',
context: 'mem.balloon',
title: 'Memory balloon',
units: 'KiB/s',
family: 'balloon',
chartType: 'line',
priority: 257,
plugin: 'proc',
dimensions: [
{ id: 'inflate', name: 'inflate', algorithm: 'incremental' },
{ id: 'deflate', name: 'deflate', algorithm: 'incremental' },
{ id: 'migrate', name: 'migrate', algorithm: 'incremental' },
],
},
{
id: 'mem.zswapio',
name: 'mem.zswapio',
context: 'mem.zswapio',
title: 'Zswap I/O',
units: 'KiB/s',
family: 'zswap',
chartType: 'area',
priority: 258,
plugin: 'proc',
dimensions: [
{ id: 'in', name: 'in', algorithm: 'incremental' },
{ id: 'out', name: 'out', algorithm: 'incremental' },
],
},
{
id: 'mem.numa',
name: 'mem.numa',
context: 'mem.numa',
title: 'NUMA events',
units: 'events/s',
family: 'numa',
chartType: 'line',
priority: 259,
plugin: 'proc',
dimensions: [
{ id: 'local', name: 'local', algorithm: 'incremental' },
{ id: 'foreign', name: 'foreign', algorithm: 'incremental' },
{ id: 'interleave', name: 'interleave', algorithm: 'incremental' },
{ id: 'other', name: 'other', algorithm: 'incremental' },
{ id: 'pte_updates', name: 'pte_updates', algorithm: 'incremental' },
{ id: 'pages_migrated', name: 'pages_migrated', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_faults',
name: 'mem.thp_faults',
context: 'mem.thp_faults',
title: 'THP fault events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 260,
plugin: 'proc',
dimensions: [
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
{ id: 'fallback_charge', name: 'fallback_charge', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_split',
name: 'mem.thp_split',
context: 'mem.thp_split',
title: 'THP split events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 261,
plugin: 'proc',
dimensions: [
{ id: 'split', name: 'split', algorithm: 'incremental' },
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
{ id: 'split_pmd', name: 'split_pmd', algorithm: 'incremental' },
],
},
{
id: 'mem.ksm',
name: 'mem.ksm',
context: 'mem.ksm',
title: 'Kernel Same-page Merging',
units: 'MiB',
family: 'ksm',
chartType: 'stacked',
priority: 262,
plugin: 'proc',
dimensions: [
{ id: 'shared', name: 'shared', algorithm: 'absolute' },
{ id: 'sharing', name: 'sharing', algorithm: 'absolute' },
{ id: 'unshared', name: 'unshared', algorithm: 'absolute' },
{ id: 'volatile', name: 'volatile', algorithm: 'absolute' },
],
},
{
id: 'mem.ksm_cow',
name: 'mem.ksm_cow',
context: 'mem.ksm_cow',
title: 'KSM copy-on-write',
units: 'KiB/s',
family: 'ksm',
chartType: 'line',
priority: 263,
plugin: 'proc',
dimensions: [
{ id: 'swapin', name: 'swapin', algorithm: 'incremental' },
{ id: 'write', name: 'write', algorithm: 'incremental' },
],
},
// ── PSI full + stall ──────────────────────────────────────
{
id: 'system.cpu_full_pressure',
name: 'system.cpu_full_pressure',
context: 'system.cpu_full_pressure',
title: 'CPU Full Pressure',
units: 'percentage',
family: 'pressure',
chartType: 'line',
priority: 801,
plugin: 'proc',
dimensions: [
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
],
},
{
id: 'system.memory_full_pressure',
name: 'system.memory_full_pressure',
context: 'system.memory_full_pressure',
title: 'Memory Full Pressure',
units: 'percentage',
family: 'pressure',
chartType: 'line',
priority: 811,
plugin: 'proc',
dimensions: [
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
],
},
{
id: 'system.io_full_pressure',
name: 'system.io_full_pressure',
context: 'system.io_full_pressure',
title: 'I/O Full Pressure',
units: 'percentage',
family: 'pressure',
chartType: 'line',
priority: 821,
plugin: 'proc',
dimensions: [
{ id: 'full10', name: 'full10', algorithm: 'absolute' },
{ id: 'full60', name: 'full60', algorithm: 'absolute' },
{ id: 'full300', name: 'full300', algorithm: 'absolute' },
],
},
// ── Softnet / ICMP / TCP advanced ─────────────────────────
{
id: 'system.softnet_stat',
name: 'system.softnet_stat',
context: 'system.softnet_stat',
title: 'Softnet statistics',
units: 'events/s',
family: 'softnet',
chartType: 'line',
priority: 780,
plugin: 'proc',
dimensions: [
{ id: 'processed', name: 'processed', algorithm: 'incremental' },
{ id: 'dropped', name: 'dropped', algorithm: 'incremental' },
{ id: 'squeezed', name: 'squeezed', algorithm: 'incremental' },
{ id: 'received_rps', name: 'received_rps', algorithm: 'incremental' },
{ id: 'flow_limit', name: 'flow_limit', algorithm: 'incremental' },
],
},
{
id: 'ipv4.icmp',
name: 'ipv4.icmp',
context: 'ipv4.icmp',
title: 'IPv4 ICMP messages',
units: 'messages/s',
family: 'icmp',
chartType: 'line',
priority: 755,
plugin: 'proc',
dimensions: [
{ id: 'received', name: 'received', algorithm: 'incremental' },
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
],
},
{
id: 'ipv4.icmp_errors',
name: 'ipv4.icmp_errors',
context: 'ipv4.icmp_errors',
title: 'IPv4 ICMP errors',
units: 'messages/s',
family: 'icmp',
chartType: 'line',
priority: 756,
plugin: 'proc',
dimensions: [
{ id: 'InErrors', name: 'InErrors', algorithm: 'incremental' },
{ id: 'OutErrors', name: 'OutErrors', algorithm: 'incremental' },
{ id: 'InCsumErrors', name: 'InCsumErrors', algorithm: 'incremental' },
],
},
{
id: 'ip.tcpsyncookies',
name: 'ip.tcpsyncookies',
context: 'ip.tcpsyncookies',
title: 'TCP SYN cookies',
units: 'cookies/s',
family: 'tcp',
chartType: 'line',
priority: 730,
plugin: 'proc',
dimensions: [
{ id: 'received', name: 'received', algorithm: 'incremental' },
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
],
},
{
id: 'ip.tcp_syn_queue',
name: 'ip.tcp_syn_queue',
context: 'ip.tcp_syn_queue',
title: 'TCP SYN queue',
units: 'packets/s',
family: 'tcp',
chartType: 'line',
priority: 731,
plugin: 'proc',
dimensions: [
{ id: 'drops', name: 'drops', algorithm: 'incremental' },
{ id: 'cookies', name: 'cookies', algorithm: 'incremental' },
],
},
{
id: 'ip.tcp_accept_queue',
name: 'ip.tcp_accept_queue',
context: 'ip.tcp_accept_queue',
title: 'TCP accept queue',
units: 'events/s',
family: 'tcp',
chartType: 'line',
priority: 732,
plugin: 'proc',
dimensions: [
{ id: 'overflows', name: 'overflows', algorithm: 'incremental' },
{ id: 'drops', name: 'drops', algorithm: 'incremental' },
],
},
{
id: 'ip.tcpconnaborts',
name: 'ip.tcpconnaborts',
context: 'ip.tcpconnaborts',
title: 'TCP connection aborts',
units: 'aborts/s',
family: 'tcp',
chartType: 'line',
priority: 733,
plugin: 'proc',
dimensions: [
{ id: 'baddata', name: 'baddata', algorithm: 'incremental' },
{ id: 'userclosed', name: 'userclosed', algorithm: 'incremental' },
{ id: 'nomemory', name: 'nomemory', algorithm: 'incremental' },
{ id: 'timeout', name: 'timeout', algorithm: 'incremental' },
{ id: 'linger', name: 'linger', algorithm: 'incremental' },
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
],
},
{
id: 'ip.tcpofo',
name: 'ip.tcpofo',
context: 'ip.tcpofo',
title: 'TCP out-of-order queue',
units: 'packets/s',
family: 'tcp',
chartType: 'line',
priority: 734,
plugin: 'proc',
dimensions: [
{ id: 'inqueue', name: 'inqueue', algorithm: 'incremental' },
{ id: 'dropped', name: 'dropped', algorithm: 'incremental' },
{ id: 'merged', name: 'merged', algorithm: 'incremental' },
{ id: 'pruned', name: 'pruned', algorithm: 'incremental' },
],
},
{
id: 'ip.tcpreorders',
name: 'ip.tcpreorders',
context: 'ip.tcpreorders',
title: 'TCP reorders',
units: 'packets/s',
family: 'tcp',
chartType: 'line',
priority: 735,
plugin: 'proc',
dimensions: [
{ id: 'timestamp', name: 'timestamp', algorithm: 'incremental' },
{ id: 'sack', name: 'sack', algorithm: 'incremental' },
{ id: 'fack', name: 'fack', algorithm: 'incremental' },
{ id: 'reno', name: 'reno', algorithm: 'incremental' },
],
},
{
id: 'ip.tcphandshake',
name: 'ip.tcphandshake',
context: 'ip.tcphandshake',
title: 'TCP handshake issues',
units: 'events/s',
family: 'tcp',
chartType: 'line',
priority: 736,
plugin: 'proc',
dimensions: [
{ id: 'EstabResets', name: 'EstabResets', algorithm: 'incremental' },
{ id: 'OutRsts', name: 'OutRsts', algorithm: 'incremental' },
{ id: 'AttemptFails', name: 'AttemptFails', algorithm: 'incremental' },
{ id: 'SynRetrans', name: 'SynRetrans', algorithm: 'incremental' },
],
},
{
id: 'ip.tcpmemorypressures',
name: 'ip.tcpmemorypressures',
context: 'ip.tcpmemorypressures',
title: 'TCP memory pressures',
units: 'events/s',
family: 'tcp',
chartType: 'line',
priority: 737,
plugin: 'proc',
dimensions: [{ id: 'pressures', name: 'pressures', algorithm: 'incremental' }],
},
]
export function makeDiskAwaitChart(disk) {
return {
id: `disk_await.${disk}`,
name: `disk_await.${disk}`,
context: 'disk.await',
title: `Disk ${disk} average wait time`,
units: 'milliseconds/operation',
family: disk,
chartType: 'line',
priority: 2030,
plugin: 'proc',
dimensions: [
{ id: 'reads', name: 'reads', algorithm: 'absolute' },
{ id: 'writes', name: 'writes', algorithm: 'absolute' },
],
}
}
export function makeDiskAvgszChart(disk) {
return {
id: `disk_avgsz.${disk}`,
name: `disk_avgsz.${disk}`,
context: 'disk.avgsz',
title: `Disk ${disk} average operation size`,
units: 'KiB/operation',
family: disk,
chartType: 'line',
priority: 2040,
plugin: 'proc',
dimensions: [
{ id: 'reads', name: 'reads', algorithm: 'absolute' },
{ id: 'writes', name: 'writes', algorithm: 'absolute' },
],
}
}
export function makeDiskQopsChart(disk) {
return {
id: `disk_qops.${disk}`,
name: `disk_qops.${disk}`,
context: 'disk.qops',
title: `Disk ${disk} queued operations`,
units: 'operations',
family: disk,
chartType: 'line',
priority: 2050,
plugin: 'proc',
dimensions: [{ id: 'operations', name: 'operations', algorithm: 'absolute' }],
}
}
export function makeDiskBusyChart(disk) {
return {
id: `disk_busy.${disk}`,
name: `disk_busy.${disk}`,
context: 'disk.busy',
title: `Disk ${disk} busy time`,
units: 'milliseconds',
family: disk,
chartType: 'area',
priority: 2060,
plugin: 'proc',
dimensions: [{ id: 'busy', name: 'busy', algorithm: 'incremental' }],
}
}
export function makeDiskIotimeChart(disk) {
return {
id: `disk_iotime.${disk}`,
name: `disk_iotime.${disk}`,
context: 'disk.iotime',
title: `Disk ${disk} I/O time`,
units: 'milliseconds/s',
family: disk,
chartType: 'line',
priority: 2070,
plugin: 'proc',
dimensions: [
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
],
}
}
export function makeDiskDiscardChart(disk) {
return {
id: `disk_discard.${disk}`,
name: `disk_discard.${disk}`,
context: 'disk.discard',
title: `Disk ${disk} discards`,
units: 'operations/s',
family: disk,
chartType: 'line',
priority: 2080,
plugin: 'proc',
dimensions: [
{ id: 'operations', name: 'operations', algorithm: 'incremental' },
{ id: 'sectors', name: 'sectors', algorithm: 'incremental' },
],
}
}
export function makeNetSpeedChart(iface) {
return {
id: `net_speed.${iface}`,
name: `net_speed.${iface}`,
context: 'net.speed',
title: `Interface ${iface} link speed`,
units: 'kilobits/s',
family: iface,
chartType: 'line',
priority: 3040,
plugin: 'proc',
dimensions: [{ id: 'speed', name: 'speed', algorithm: 'absolute' }],
}
}
export function makeCpuFreqChart() {
return {
id: 'cpu.cpufreq',
name: 'cpu.cpufreq',
context: 'cpu.cpufreq',
title: 'CPU frequency',
units: 'MHz',
family: 'cpufreq',
chartType: 'line',
priority: 180,
plugin: 'proc',
dimensions: [],
}
}
export function makeCgroupCpuChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.cpu.${safe}`,
name: `cgroup.cpu.${safe}`,
context: 'cgroup.cpu',
title: `Cgroup CPU ${title || safe}`,
units: 'percentage',
family: title || safe,
chartType: 'area',
priority: 6000,
plugin: 'cgroups',
dimensions: [
{ id: 'user', name: 'user', algorithm: 'absolute' },
{ id: 'system', name: 'system', algorithm: 'absolute' },
],
}
}
export function makeCgroupMemChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.mem.${safe}`,
name: `cgroup.mem.${safe}`,
context: 'cgroup.mem',
title: `Cgroup memory ${title || safe}`,
units: 'MiB',
family: title || safe,
chartType: 'area',
priority: 6100,
plugin: 'cgroups',
dimensions: [
{ id: 'usage', name: 'usage', algorithm: 'absolute' },
{ id: 'limit', name: 'limit', algorithm: 'absolute' },
],
}
}
export function makeCgroupIoChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.io.${safe}`,
name: `cgroup.io.${safe}`,
context: 'cgroup.io',
title: `Cgroup I/O ${title || safe}`,
units: 'KiB/s',
family: title || safe,
chartType: 'area',
priority: 6200,
plugin: 'cgroups',
dimensions: [
{ id: 'read', name: 'read', algorithm: 'incremental' },
{ id: 'write', name: 'write', algorithm: 'incremental' },
],
}
}
export function makeSensorTempChart(chip, label) {
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
return {
id: `sensors.temp.${id}`,
name: `sensors.temp.${id}`,
context: 'sensors.temperature',
title: `Temperature ${chip} ${label}`,
units: 'Celsius',
family: chip,
chartType: 'line',
priority: 7000,
plugin: 'sensors',
dimensions: [{ id: 'temperature', name: 'temperature', algorithm: 'absolute' }],
}
}
export function makeThermalZoneChart(zone, type) {
const id = String(zone).replace(/[^a-zA-Z0-9_.-]/g, '_')
return {
id: `sensors.thermal.${id}`,
name: `sensors.thermal.${id}`,
context: 'sensors.thermal',
title: `Thermal zone ${type || id}`,
units: 'Celsius',
family: type || id,
chartType: 'line',
priority: 7010,
plugin: 'sensors',
dimensions: [{ id: 'temperature', name: 'temperature', algorithm: 'absolute' }],
}
}
+574
View File
@@ -0,0 +1,574 @@
/**
* Remaining host / app chart definitions (merged into STATIC_CHART_DEFS).
*/
/** @type {import('./metrics.js').ChartDef[]} */
export const MORE_STATIC_CHARTS = [
// Memory extras
{
id: 'mem.high_low',
name: 'mem.high_low',
context: 'mem.high_low',
title: 'High / low memory watermarks',
units: 'MiB',
family: 'watermarks',
chartType: 'line',
priority: 264,
plugin: 'proc',
dimensions: [
{ id: 'high', name: 'high', algorithm: 'absolute' },
{ id: 'low', name: 'low', algorithm: 'absolute' },
],
},
{
id: 'mem.cma',
name: 'mem.cma',
context: 'mem.cma',
title: 'Contiguous Memory Allocator',
units: 'MiB',
family: 'cma',
chartType: 'stacked',
priority: 265,
plugin: 'proc',
dimensions: [
{ id: 'total', name: 'total', algorithm: 'absolute' },
{ id: 'free', name: 'free', algorithm: 'absolute' },
],
},
{
id: 'mem.directmaps',
name: 'mem.directmaps',
context: 'mem.directmaps',
title: 'DirectMap page sizes',
units: 'MiB',
family: 'directmap',
chartType: 'stacked',
priority: 266,
plugin: 'proc',
dimensions: [
{ id: '4k', name: '4k', algorithm: 'absolute' },
{ id: '2M', name: '2M', algorithm: 'absolute' },
{ id: '1G', name: '1G', algorithm: 'absolute' },
],
},
{
id: 'mem.ksm_savings',
name: 'mem.ksm_savings',
context: 'mem.ksm_savings',
title: 'KSM memory savings',
units: 'MiB',
family: 'ksm',
chartType: 'line',
priority: 267,
plugin: 'proc',
dimensions: [
{ id: 'offered', name: 'offered', algorithm: 'absolute' },
{ id: 'saved', name: 'saved', algorithm: 'absolute' },
],
},
{
id: 'mem.ksm_ratios',
name: 'mem.ksm_ratios',
context: 'mem.ksm_ratios',
title: 'KSM savings ratio',
units: 'percentage',
family: 'ksm',
chartType: 'line',
priority: 268,
plugin: 'proc',
dimensions: [{ id: 'savings', name: 'savings', algorithm: 'absolute' }],
},
{
id: 'mem.thp_file',
name: 'mem.thp_file',
context: 'mem.thp_file',
title: 'THP file events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 269,
plugin: 'proc',
dimensions: [
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
{ id: 'mapped', name: 'mapped', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_zero',
name: 'mem.thp_zero',
context: 'mem.thp_zero',
title: 'THP zero-page events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 270,
plugin: 'proc',
dimensions: [
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_collapse',
name: 'mem.thp_collapse',
context: 'mem.thp_collapse',
title: 'THP collapse events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 271,
plugin: 'proc',
dimensions: [
{ id: 'alloc', name: 'alloc', algorithm: 'incremental' },
{ id: 'failed', name: 'failed', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_swapout',
name: 'mem.thp_swapout',
context: 'mem.thp_swapout',
title: 'THP swapout events',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 272,
plugin: 'proc',
dimensions: [
{ id: 'swapout', name: 'swapout', algorithm: 'incremental' },
{ id: 'fallback', name: 'fallback', algorithm: 'incremental' },
],
},
{
id: 'mem.thp_compact',
name: 'mem.thp_compact',
context: 'mem.thp_compact',
title: 'Memory compaction',
units: 'events/s',
family: 'thp',
chartType: 'line',
priority: 273,
plugin: 'proc',
dimensions: [
{ id: 'success', name: 'success', algorithm: 'incremental' },
{ id: 'fail', name: 'fail', algorithm: 'incremental' },
{ id: 'stall', name: 'stall', algorithm: 'incremental' },
],
},
{
id: 'mem.pagetype_global',
name: 'mem.pagetype_global',
context: 'mem.pagetype_global',
title: 'Free pages by order (global)',
units: 'pages',
family: 'pagetype',
chartType: 'stacked',
priority: 274,
plugin: 'proc',
dimensions: Array.from({ length: 11 }, (_, i) => ({
id: `order${i}`,
name: `order${i}`,
algorithm: 'absolute',
})),
},
{
id: 'system.softirqs',
name: 'system.softirqs',
context: 'system.softirqs',
title: 'SoftIRQs by vector',
units: 'softirqs/s',
family: 'softirq',
chartType: 'stacked',
priority: 781,
plugin: 'proc',
dimensions: [
{ id: 'HI', name: 'HI', algorithm: 'incremental' },
{ id: 'TIMER', name: 'TIMER', algorithm: 'incremental' },
{ id: 'NET_TX', name: 'NET_TX', algorithm: 'incremental' },
{ id: 'NET_RX', name: 'NET_RX', algorithm: 'incremental' },
{ id: 'BLOCK', name: 'BLOCK', algorithm: 'incremental' },
{ id: 'IRQ_POLL', name: 'IRQ_POLL', algorithm: 'incremental' },
{ id: 'TASKLET', name: 'TASKLET', algorithm: 'incremental' },
{ id: 'SCHED', name: 'SCHED', algorithm: 'incremental' },
{ id: 'HRTIMER', name: 'HRTIMER', algorithm: 'incremental' },
{ id: 'RCU', name: 'RCU', algorithm: 'incremental' },
],
},
{
id: 'ipv4.icmpmsg',
name: 'ipv4.icmpmsg',
context: 'ipv4.icmpmsg',
title: 'IPv4 ICMP message types',
units: 'messages/s',
family: 'icmp',
chartType: 'line',
priority: 757,
plugin: 'proc',
dimensions: [
{ id: 'InEchoes', name: 'InEchoes', algorithm: 'incremental' },
{ id: 'OutEchoes', name: 'OutEchoes', algorithm: 'incremental' },
{ id: 'InDestUnreachs', name: 'InDestUnreachs', algorithm: 'incremental' },
{ id: 'OutDestUnreachs', name: 'OutDestUnreachs', algorithm: 'incremental' },
{ id: 'InTimeExcds', name: 'InTimeExcds', algorithm: 'incremental' },
{ id: 'OutTimeExcds', name: 'OutTimeExcds', algorithm: 'incremental' },
{ id: 'InRedirects', name: 'InRedirects', algorithm: 'incremental' },
{ id: 'OutRedirects', name: 'OutRedirects', algorithm: 'incremental' },
],
},
{
id: 'ipv6.packets',
name: 'ipv6.packets',
context: 'ipv6.packets',
title: 'IPv6 packets',
units: 'packets/s',
family: 'ipv6',
chartType: 'line',
priority: 758,
plugin: 'proc',
dimensions: [
{ id: 'received', name: 'received', algorithm: 'incremental' },
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
{ id: 'forwarded', name: 'forwarded', algorithm: 'incremental' },
{ id: 'delivered', name: 'delivered', algorithm: 'incremental' },
],
},
{
id: 'ipv6.errors',
name: 'ipv6.errors',
context: 'ipv6.errors',
title: 'IPv6 errors',
units: 'packets/s',
family: 'ipv6',
chartType: 'line',
priority: 759,
plugin: 'proc',
dimensions: [
{ id: 'InHdrErrors', name: 'InHdrErrors', algorithm: 'incremental' },
{ id: 'InAddrErrors', name: 'InAddrErrors', algorithm: 'incremental' },
{ id: 'InDiscards', name: 'InDiscards', algorithm: 'incremental' },
{ id: 'OutDiscards', name: 'OutDiscards', algorithm: 'incremental' },
{ id: 'InTruncatedPkts', name: 'InTruncatedPkts', algorithm: 'incremental' },
],
},
{
id: 'ipv6.icmp',
name: 'ipv6.icmp',
context: 'ipv6.icmp',
title: 'IPv6 ICMP',
units: 'messages/s',
family: 'ipv6',
chartType: 'line',
priority: 760,
plugin: 'proc',
dimensions: [
{ id: 'received', name: 'received', algorithm: 'incremental' },
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
{ id: 'InErrors', name: 'InErrors', algorithm: 'incremental' },
{ id: 'OutErrors', name: 'OutErrors', algorithm: 'incremental' },
],
},
{
id: 'net.conntrack',
name: 'net.conntrack',
context: 'net.conntrack',
title: 'Connection tracking',
units: 'connections',
family: 'conntrack',
chartType: 'line',
priority: 770,
plugin: 'proc',
dimensions: [
{ id: 'entries', name: 'entries', algorithm: 'absolute' },
{ id: 'max', name: 'max', algorithm: 'absolute' },
{ id: 'searched', name: 'searched', algorithm: 'incremental' },
{ id: 'found', name: 'found', algorithm: 'incremental' },
{ id: 'new', name: 'new', algorithm: 'incremental' },
{ id: 'invalid', name: 'invalid', algorithm: 'incremental' },
{ id: 'drop', name: 'drop', algorithm: 'incremental' },
{ id: 'insert_failed', name: 'insert_failed', algorithm: 'incremental' },
],
},
{
id: 'system.inotify',
name: 'system.inotify',
context: 'system.inotify',
title: 'Inotify watches',
units: 'watches',
family: 'inotify',
chartType: 'line',
priority: 790,
plugin: 'proc',
dimensions: [
{ id: 'max_user_watches', name: 'max_user_watches', algorithm: 'absolute' },
{ id: 'max_user_instances', name: 'max_user_instances', algorithm: 'absolute' },
],
},
{
id: 'md.health',
name: 'md.health',
context: 'md.health',
title: 'MD RAID arrays',
units: 'arrays',
family: 'md',
chartType: 'line',
priority: 4500,
plugin: 'mdstat',
dimensions: [
{ id: 'active', name: 'active', algorithm: 'absolute' },
{ id: 'degraded', name: 'degraded', algorithm: 'absolute' },
{ id: 'recovering', name: 'recovering', algorithm: 'absolute' },
],
},
{
id: 'system.power',
name: 'system.power',
context: 'system.power',
title: 'Package power (RAPL)',
units: 'Watts',
family: 'power',
chartType: 'line',
priority: 7100,
plugin: 'powercap',
dimensions: [{ id: 'package', name: 'package', algorithm: 'absolute' }],
},
{
id: 'mem.edac',
name: 'mem.edac',
context: 'mem.edac',
title: 'EDAC memory errors',
units: 'errors',
family: 'edac',
chartType: 'line',
priority: 275,
plugin: 'edac',
dimensions: [
{ id: 'corrected', name: 'corrected', algorithm: 'absolute' },
{ id: 'uncorrected', name: 'uncorrected', algorithm: 'absolute' },
],
},
{
id: 'peardata.cpu',
name: 'peardata.cpu',
context: 'peardata.cpu',
title: 'Agent CPU usage',
units: 'percentage',
family: 'self',
chartType: 'area',
priority: 9000,
plugin: 'self',
dimensions: [
{ id: 'user', name: 'user', algorithm: 'absolute' },
{ id: 'system', name: 'system', algorithm: 'absolute' },
],
},
{
id: 'peardata.mem',
name: 'peardata.mem',
context: 'peardata.mem',
title: 'Agent memory',
units: 'MiB',
family: 'self',
chartType: 'area',
priority: 9010,
plugin: 'self',
dimensions: [
{ id: 'rss', name: 'rss', algorithm: 'absolute' },
{ id: 'heap', name: 'heap', algorithm: 'absolute' },
],
},
{
id: 'peardata.charts',
name: 'peardata.charts',
context: 'peardata.charts',
title: 'Agent chart cardinality',
units: 'charts',
family: 'self',
chartType: 'line',
priority: 9020,
plugin: 'self',
dimensions: [{ id: 'registered', name: 'registered', algorithm: 'absolute' }],
},
]
export function makeDiskSvctmChart(disk) {
return {
id: `disk_svctm.${disk}`,
name: `disk_svctm.${disk}`,
context: 'disk.svctm',
title: `Disk ${disk} service time`,
units: 'milliseconds/operation',
family: disk,
chartType: 'line',
priority: 2090,
plugin: 'proc',
dimensions: [{ id: 'svctm', name: 'svctm', algorithm: 'absolute' }],
}
}
export function makeDiskMergedChart(disk) {
return {
id: `disk_merged.${disk}`,
name: `disk_merged.${disk}`,
context: 'disk.merged',
title: `Disk ${disk} merged operations`,
units: 'operations/s',
family: disk,
chartType: 'line',
priority: 2095,
plugin: 'proc',
dimensions: [
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
],
}
}
export function makeDiskFlushChart(disk) {
return {
id: `disk_flush.${disk}`,
name: `disk_flush.${disk}`,
context: 'disk.flush',
title: `Disk ${disk} flush requests`,
units: 'operations/s',
family: disk,
chartType: 'line',
priority: 2096,
plugin: 'proc',
dimensions: [{ id: 'operations', name: 'operations', algorithm: 'incremental' }],
}
}
export function makeNetDuplexChart(iface) {
return {
id: `net_duplex.${iface}`,
name: `net_duplex.${iface}`,
context: 'net.duplex',
title: `Interface ${iface} duplex`,
units: 'state',
family: iface,
chartType: 'line',
priority: 3045,
plugin: 'proc',
dimensions: [
{ id: 'full', name: 'full', algorithm: 'absolute' },
{ id: 'half', name: 'half', algorithm: 'absolute' },
],
}
}
export function makeNetMtuChart(iface) {
return {
id: `net_mtu.${iface}`,
name: `net_mtu.${iface}`,
context: 'net.mtu',
title: `Interface ${iface} MTU`,
units: 'octets',
family: iface,
chartType: 'line',
priority: 3046,
plugin: 'proc',
dimensions: [{ id: 'mtu', name: 'mtu', algorithm: 'absolute' }],
}
}
export function makeNetQueueChart(iface) {
return {
id: `net_queue.${iface}`,
name: `net_queue.${iface}`,
context: 'net.queue_length',
title: `Interface ${iface} TX queue length`,
units: 'packets',
family: iface,
chartType: 'line',
priority: 3047,
plugin: 'proc',
dimensions: [{ id: 'tx_queue_len', name: 'tx_queue_len', algorithm: 'absolute' }],
}
}
export function makeIrqChart(irq, name) {
const id = String(irq).replace(/[^\w.-]/g, '_')
return {
id: `system.intr_irq.${id}`,
name: `system.intr_irq.${id}`,
context: 'system.intr_irq',
title: `IRQ ${irq} ${name || ''}`.trim(),
units: 'interrupts/s',
family: 'irq',
chartType: 'line',
priority: 115,
plugin: 'proc',
dimensions: [{ id: 'interrupts', name: 'interrupts', algorithm: 'incremental' }],
}
}
export function makeCpuIdleChart(coreId) {
return {
id: `cpuidle.cpu${coreId}`,
name: `cpuidle.cpu${coreId}`,
context: 'cpuidle.cpu_cstate_residency_time',
title: `CPU ${coreId} C-state residency`,
units: 'percentage',
family: `cpu${coreId}`,
chartType: 'stacked',
priority: 190,
plugin: 'proc',
dimensions: [{ id: 'active', name: 'active', algorithm: 'absolute' }],
}
}
export function makeNumaNodeMemChart(node) {
return {
id: `mem.numa_node.${node}`,
name: `mem.numa_node.${node}`,
context: 'mem.numa_node_mem_usage',
title: `NUMA node ${node} memory`,
units: 'MiB',
family: `node${node}`,
chartType: 'stacked',
priority: 276,
plugin: 'proc',
dimensions: [
{ id: 'MemTotal', name: 'MemTotal', algorithm: 'absolute' },
{ id: 'MemFree', name: 'MemFree', algorithm: 'absolute' },
{ id: 'MemUsed', name: 'MemUsed', algorithm: 'absolute' },
],
}
}
export function makeCgroupMemDetailChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.mem_detail.${safe}`,
name: `cgroup.mem_detail.${safe}`,
context: 'cgroup.mem_detail',
title: `Cgroup memory detail ${title || safe}`,
units: 'MiB',
family: title || safe,
chartType: 'stacked',
priority: 6110,
plugin: 'cgroups',
dimensions: [
{ id: 'anon', name: 'anon', algorithm: 'absolute' },
{ id: 'file', name: 'file', algorithm: 'absolute' },
{ id: 'kernel', name: 'kernel', algorithm: 'absolute' },
{ id: 'sock', name: 'sock', algorithm: 'absolute' },
],
}
}
export function makeCgroupThrottleChart(id, title) {
const safe = String(id).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 96)
return {
id: `cgroup.throttle.${safe}`,
name: `cgroup.throttle.${safe}`,
context: 'cgroup.cpu_throttle',
title: `Cgroup CPU throttle ${title || safe}`,
units: 'percentage',
family: title || safe,
chartType: 'area',
priority: 6010,
plugin: 'cgroups',
dimensions: [{ id: 'throttled', name: 'throttled', algorithm: 'absolute' }],
}
}
+38
View File
@@ -6,8 +6,40 @@
* (per-CPU, per-disk, per-iface, per-mount) are registered at runtime.
*/
import { DEEP_STATIC_CHARTS } from './metrics-deep.js'
import { MORE_STATIC_CHARTS } from './metrics-more.js'
export const SAMPLE_INTERVAL_MS = 1000
export {
makeDiskAwaitChart,
makeDiskAvgszChart,
makeDiskQopsChart,
makeDiskBusyChart,
makeDiskIotimeChart,
makeDiskDiscardChart,
makeNetSpeedChart,
makeCgroupCpuChart,
makeCgroupMemChart,
makeCgroupIoChart,
makeSensorTempChart,
makeThermalZoneChart,
} from './metrics-deep.js'
export {
makeDiskSvctmChart,
makeDiskMergedChart,
makeDiskFlushChart,
makeNetDuplexChart,
makeNetMtuChart,
makeNetQueueChart,
makeIrqChart,
makeCpuIdleChart,
makeNumaNodeMemChart,
makeCgroupMemDetailChart,
makeCgroupThrottleChart,
} from './metrics-more.js'
/**
* @typedef {{ id: string, name: string, algorithm: 'absolute'|'incremental', multiplier?: number, divisor?: number }} DimensionDef
* @typedef {{
@@ -226,6 +258,7 @@ export const STATIC_CHART_DEFS = [
{ id: 'kernel_stack', name: 'kernel_stack', algorithm: 'absolute' },
{ id: 'page_tables', name: 'page_tables', algorithm: 'absolute' },
{ id: 'vmalloc_used', name: 'vmalloc_used', algorithm: 'absolute' },
{ id: 'percpu', name: 'percpu', algorithm: 'absolute' },
],
},
{
@@ -256,6 +289,9 @@ export const STATIC_CHART_DEFS = [
dimensions: [
{ id: 'dirty', name: 'dirty', algorithm: 'absolute' },
{ id: 'writeback', name: 'writeback', algorithm: 'absolute' },
{ id: 'FuseWriteback', name: 'FuseWriteback', algorithm: 'absolute' },
{ id: 'NfsWriteback', name: 'NfsWriteback', algorithm: 'absolute' },
{ id: 'Bounce', name: 'Bounce', algorithm: 'absolute' },
],
},
{
@@ -553,6 +589,8 @@ export const STATIC_CHART_DEFS = [
{ id: 'some300', name: 'some300', algorithm: 'absolute' },
],
},
...DEEP_STATIC_CHARTS,
...MORE_STATIC_CHARTS,
]
/** @type {Map<string, ChartDef>} */
+44
View File
@@ -0,0 +1,44 @@
import test from 'brittle'
import { DEEP_STATIC_CHARTS } from '../shared/metrics-deep.js'
import { STATIC_CHART_DEFS, CHART_BY_ID } from '../shared/metrics.js'
import { parseSoftnet, parsePressureFull, parseNetstatTables } from '../server/services/collectors/host-deep.js'
import { MetricsCollector } from '../server/services/collector.js'
test('deep static charts are merged into catalog', (t) => {
t.ok(DEEP_STATIC_CHARTS.length > 20)
t.ok(STATIC_CHART_DEFS.length >= 35 + DEEP_STATIC_CHARTS.length)
for (const id of ['mem.zswap', 'mem.reclaiming', 'ip.tcpsyncookies', 'system.softnet_stat']) {
t.ok(CHART_BY_ID.has(id), id)
}
})
test('host-deep parsers tolerate missing proc', (t) => {
const soft = parseSoftnet()
t.ok(typeof soft.processed === 'number')
const tables = parseNetstatTables()
t.ok(tables.TcpExt)
// may be null off Linux
const p = parsePressureFull('cpu')
t.ok(p === null || typeof p.some10 === 'number')
})
test('collector emits expanded disk/net charts on Linux', async (t) => {
const c = new MetricsCollector({ intervalMs: 40 })
/** @type {any[]} */
let batch = []
c.on('samples', (b) => {
batch = b
})
c.start()
await new Promise((r) => setTimeout(r, 100))
c.stop()
t.ok(batch.length > 5)
const charts = new Set(batch.map((s) => s.chart))
t.ok(charts.has('system.cpu'))
// expanded dims present when meminfo exists
const kernel = batch.find((s) => s.chart === 'mem.kernel')
if (kernel) t.ok('percpu' in kernel.values)
const wb = batch.find((s) => s.chart === 'mem.writeback')
if (wb) t.ok('Bounce' in wb.values)
})
+43
View File
@@ -0,0 +1,43 @@
import test from 'brittle'
import { MORE_STATIC_CHARTS } from '../shared/metrics-more.js'
import { CHART_BY_ID, getAllChartDefs } from '../shared/metrics.js'
import { parseSoftirqs, parseConntrack, parseSnmp6 } from '../server/services/collectors/host-more.js'
import { MetricsCollector } from '../server/services/collector.js'
test('more static charts merged', (t) => {
t.ok(MORE_STATIC_CHARTS.length > 15)
for (const id of [
'mem.directmaps',
'system.softirqs',
'net.conntrack',
'ipv6.packets',
'peardata.cpu',
'system.power',
]) {
t.ok(CHART_BY_ID.has(id), id)
}
t.ok(getAllChartDefs().length > 80)
})
test('host-more parsers', (t) => {
const soft = parseSoftirqs()
t.ok(typeof soft === 'object')
const ct = parseConntrack()
t.ok(typeof ct.entries === 'number')
t.ok(typeof parseSnmp6() === 'object')
})
test('collector second tick includes deep/more charts on Linux', async (t) => {
const c = new MetricsCollector({ intervalMs: 40 })
/** @type {Set<string>} */
const charts = new Set()
c.on('samples', (batch) => {
for (const s of batch) charts.add(s.chart)
})
c.start()
await new Promise((r) => setTimeout(r, 120))
c.stop()
t.ok(charts.has('system.cpu'))
// softirqs / conntrack may be empty arrays of zeros but charts should emit
t.ok(charts.has('system.softirqs') || charts.has('net.conntrack') || charts.has('mem.directmaps'))
})