diff --git a/functions/infra/collect_battery_metrics.go b/functions/infra/collect_battery_metrics.go new file mode 100644 index 00000000..79fec8f8 --- /dev/null +++ b/functions/infra/collect_battery_metrics.go @@ -0,0 +1,120 @@ +package infra + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "time" +) + +// batteryStatus modela el JSON que imprime `termux-battery-status` (binario del +// paquete termux-api en Android/Termux). Solo se declaran los campos que +// consumimos como metricas. +type batteryStatus struct { + Health string `json:"health"` + Percentage float64 `json:"percentage"` + Plugged string `json:"plugged"` + Status string `json:"status"` + Temperature float64 `json:"temperature"` + Current float64 `json:"current"` +} + +// CollectBatteryMetrics recolecta metricas de bateria de un dispositivo +// Android via el comando `termux-battery-status` (paquete termux-api) y las +// devuelve como slice de PromSample con nombres estilo node_exporter. +// +// Es best-effort y diseñada para correr en cualquier nodo de la flota, +// incluidos Linux normales donde `termux-battery-status` NO existe: en ese +// caso (binario no encontrado, comando fallido o JSON invalido) devuelve un +// slice vacio y error nil — NO es un fallo, simplemente no hay bateria que +// reportar. Solo emite samples cuando el comando existe y responde JSON valido. +// +// El comando se ejecuta con un timeout de 5s via context para no colgar el +// agente de monitorizacion si termux-api se queda sin responder. +func CollectBatteryMetrics() ([]PromSample, error) { + // Localizamos el binario por ruta absoluta con os.Stat en vez de + // exec.LookPath: en Android el syscall faccessat2 que usa LookPath esta + // bloqueado por seccomp y mata el proceso con SIGSYS. Si no esta presente + // (Linux normal), no hay bateria que reportar (no-op). + bin := findTermuxBattery() + if bin == "" { + return []PromSample{}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, bin).Output() + if err != nil { + // Comando presente pero fallido (timeout, sin permisos, etc.): no-op. + return []PromSample{}, nil + } + + samples, err := parseBatteryJSON(out) + if err != nil { + // JSON inesperado o invalido: no-op, no abortamos al caller. + return []PromSample{}, nil + } + return samples, nil +} + +// findTermuxBattery devuelve la ruta absoluta del binario termux-battery-status +// si existe, o "" si no. Usa os.Stat (permitido por seccomp en Android) en vez +// de exec.LookPath (que invoca faccessat2 y crashea con SIGSYS en Android). +func findTermuxBattery() string { + candidates := []string{} + if prefix := os.Getenv("PREFIX"); prefix != "" { + candidates = append(candidates, prefix+"/bin/termux-battery-status") + } + candidates = append(candidates, "/data/data/com.termux/files/usr/bin/termux-battery-status") + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// BatterySamplesFromJSON parsea la salida JSON de `termux-battery-status` y +// produce los PromSample de bateria. Es pura y exportada para que un caller que +// ya tenga el JSON (por ejemplo leido de un fichero, util en Android donde el +// agente no puede ejecutar subprocesos) lo convierta sin volver a ejecutar el +// comando. +func BatterySamplesFromJSON(data []byte) ([]PromSample, error) { + return parseBatteryJSON(data) +} + +// parseBatteryJSON parsea la salida JSON de `termux-battery-status` y produce +// los PromSample de bateria. Es pura: no ejecuta comandos ni toca el entorno, +// lo que la hace testeable con un JSON fijo. Devuelve error solo si el JSON no +// es valido o no tiene la forma esperada. +func parseBatteryJSON(data []byte) ([]PromSample, error) { + var bs batteryStatus + if err := json.Unmarshal(data, &bs); err != nil { + return nil, err + } + + // charging = 1 si el status indica carga/lleno o si esta enchufado. + var charging float64 + if bs.Status == "CHARGING" || bs.Status == "FULL" || bs.Plugged != "UNPLUGGED" { + charging = 1 + } + + samples := []PromSample{ + {Name: "node_battery_percent", Value: bs.Percentage}, + {Name: "node_battery_temp_celsius", Value: bs.Temperature}, + {Name: "node_battery_charging", Value: charging}, + {Name: "node_battery_current_ua", Value: bs.Current}, + { + Name: "node_battery_health_info", + Labels: map[string]string{ + "health": bs.Health, + "status": bs.Status, + "plugged": bs.Plugged, + }, + Value: 1, + }, + } + return samples, nil +} diff --git a/functions/infra/collect_battery_metrics.md b/functions/infra/collect_battery_metrics.md new file mode 100644 index 00000000..ef62e993 --- /dev/null +++ b/functions/infra/collect_battery_metrics.md @@ -0,0 +1,81 @@ +--- +name: collect_battery_metrics +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func CollectBatteryMetrics() ([]PromSample, error)" +description: "Recolecta metricas de bateria de un dispositivo Android via el comando termux-battery-status (paquete termux-api) y las devuelve como slice de PromSample con nombres estilo node_exporter: porcentaje, temperatura, estado de carga (booleano), corriente en microamperios y una serie informativa node_battery_health_info con labels health/status/plugged. Best-effort y multiplataforma: en nodos sin termux-battery-status (Linux normales) es un no-op que devuelve slice vacio y error nil; solo emite samples cuando el comando existe y responde JSON valido. El comando corre con timeout de 5s via context." +tags: [prometheus, metrics, node-exporter, battery, termux, android, fleet-metrics, infra, monitoring] +uses_functions: [] +uses_types: ["PromSample_go_infra"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["context", "encoding/json", "os/exec", "time"] +params: [] +output: "slice de PromSample con metricas de bateria. node_battery_percent (0-100), node_battery_temp_celsius, node_battery_charging (1 si carga/lleno/enchufado, si no 0), node_battery_current_ua (microamperios, negativo al descargar) y node_battery_health_info{health,status,plugged} con value 1. En nodos sin termux-api devuelve slice vacio. Error nil siempre en condiciones normales: la funcion traga los fallos de ejecucion/parseo como no-op (slice vacio)." +tested: true +tests: + - "TestCollectBatteryMetrics_ParseDischarging" + - "TestCollectBatteryMetrics_ParseCharging" + - "TestCollectBatteryMetrics_ParsePluggedNotUnplugged" + - "TestCollectBatteryMetrics_ParseFull" + - "TestCollectBatteryMetrics_InvalidJSON" +test_file_path: "functions/infra/collect_battery_metrics_test.go" +file_path: "functions/infra/collect_battery_metrics.go" +--- + +## Ejemplo + +```go +samples, err := CollectBatteryMetrics() +if err != nil { + log.Fatal(err) +} +// En un Linux normal samples sera vacio (no-op); en Android/Termux trae +// node_battery_percent, node_battery_temp_celsius, node_battery_charging, etc. +// Componer con el resto del capability group fleet-metrics: +host, _ := CollectHostMetrics() +all := append(host, samples...) +body := FormatPromExposition(all, time.Now().UnixMilli()) +err = PushPromRemote( + "https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus", + "user", "pass", + body, + map[string]string{"instance": "pixel-phone"}, +) +``` + +## Cuando usarla + +Cuando un nodo de la flota es un movil Android con Termux + termux-api y quieres +exponer la salud de su bateria como metricas Prometheus para push a un backend +remoto (VictoriaMetrics, Mimir). Llamala junto a `collect_host_metrics_go_infra` +en el loop del agente de monitorizacion push y concatena los slices: en moviles +añade las series de bateria, en el resto de nodos no aporta nada (no-op seguro), +asi puedes usar el MISMO agente en toda la flota sin ramas por plataforma. + +## Gotchas + +- **Solo produce datos en Termux/Android con termux-api instalado**: necesita el + binario `termux-battery-status` (paquete `termux-api` + la app Termux:API). En + cualquier otro nodo (Linux de escritorio, VPS, macOS) `exec.LookPath` falla y + la funcion es un no-op que devuelve `[]PromSample{}, nil`. No es un error: + simplemente no hay bateria que reportar. +- **No devuelve error nunca en condiciones normales**: por diseño best-effort, + tanto el binario ausente como un comando fallido (timeout, permisos) o un JSON + invalido se tragan como slice vacio. La firma mantiene `error` por convencion + de impureza, pero el caller no necesita ramificar por error de plataforma. +- **Timeout de 5s**: usa `exec.CommandContext` con `context.WithTimeout`. Si + termux-api se cuelga, la llamada aborta a los 5s y devuelve no-op. +- **node_battery_current_ua puede ser negativo**: convencion de Android — corriente + negativa = descarga, positiva = carga. Se reporta tal cual (microamperios). +- **node_battery_charging es heuristico**: vale 1 si `status` es `CHARGING` o + `FULL`, o si `plugged != "UNPLUGGED"`. Cubre el caso de estar enchufado sin + cargar activamente (ej. `NOT_CHARGING` con cargador conectado). +- **No incluye la label `instance`**: igual que el resto de colectores del grupo, + esa la añade `push_prom_remote_go_infra` via extra_label en el push. +- **El parseo esta factorizado** en `parseBatteryJSON` (funcion pura interna) para + poder testear los samples sin ejecutar termux-battery-status real. diff --git a/functions/infra/collect_host_metrics.go b/functions/infra/collect_host_metrics.go new file mode 100644 index 00000000..ad6b02cf --- /dev/null +++ b/functions/infra/collect_host_metrics.go @@ -0,0 +1,246 @@ +package infra + +import ( + "fmt" + "os" + "sort" + "strconv" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/host" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/net" + "github.com/shirou/gopsutil/v4/process" + "github.com/shirou/gopsutil/v4/sensors" +) + +// isAndroidHost indica si el host es Android (incluido Termux). Se usa para +// evitar rutas de gopsutil que invocan os.FindProcess -> pidfd_open, syscall +// bloqueado por el seccomp de Android que mata el proceso con SIGSYS. +func isAndroidHost() bool { + if os.Getenv("ANDROID_ROOT") != "" || os.Getenv("ANDROID_DATA") != "" { + return true + } + if _, err := os.Stat("/system/build.prop"); err == nil { + return true + } + return false +} + +// pseudoFstypes son filesystems virtuales que no representan almacenamiento +// real y se ignoran al recolectar metricas de particiones. +var pseudoFstypes = map[string]bool{ + "tmpfs": true, + "devtmpfs": true, + "overlay": true, + "squashfs": true, + "proc": true, + "sysfs": true, + "cgroup": true, + "cgroup2": true, + "devpts": true, + "mqueue": true, + "debugfs": true, + "tracefs": true, + "fusectl": true, + "configfs": true, + "pstore": true, + "bpf": true, + "securityfs": true, +} + +// CollectHostMetrics recolecta metricas del host actual (CPU, memoria, swap, +// disco, red, temperaturas y procesos) y las devuelve como un slice de +// PromSample con nombres estilo node_exporter simplificados. +// +// Es robusta: cada grupo de colector se ejecuta en su propio bloque con manejo +// de error local. Si un colector secundario falla (red, temperaturas, etc.) se +// omite ese grupo sin abortar. Solo retorna error si falla la informacion +// basica de host (uptime), que se considera el minimo imprescindible. +// +// Funciona en Linux amd64 y Android/Termux (linux arm64): las temperaturas son +// best-effort y se omiten si no hay sensores disponibles (tipico en Android). +func CollectHostMetrics() ([]PromSample, error) { + var samples []PromSample + + // --- Host basico: uptime (imprescindible, error si falla) --- + uptime, err := host.Uptime() + if err != nil { + return nil, fmt.Errorf("collect host uptime: %w", err) + } + samples = append(samples, PromSample{ + Name: "node_uptime_seconds", + Value: float64(uptime), + }) + + // --- Load average (linux/darwin; best-effort) --- + if avg, err := load.Avg(); err == nil && avg != nil { + samples = append(samples, + PromSample{Name: "node_load1", Value: avg.Load1}, + PromSample{Name: "node_load5", Value: avg.Load5}, + PromSample{Name: "node_load15", Value: avg.Load15}, + ) + } + + // --- CPU global (intervalo corto de muestreo) --- + if pcts, err := cpu.Percent(200*time.Millisecond, false); err == nil && len(pcts) > 0 { + samples = append(samples, PromSample{ + Name: "node_cpu_percent", + Value: pcts[0], + }) + } + + // --- CPU por nucleo --- + if pcts, err := cpu.Percent(200*time.Millisecond, true); err == nil { + for i, p := range pcts { + samples = append(samples, PromSample{ + Name: "node_cpu_core_percent", + Labels: map[string]string{"core": strconv.Itoa(i)}, + Value: p, + }) + } + } + + // --- Memoria virtual --- + if vm, err := mem.VirtualMemory(); err == nil && vm != nil { + samples = append(samples, + PromSample{Name: "node_mem_total_bytes", Value: float64(vm.Total)}, + PromSample{Name: "node_mem_used_bytes", Value: float64(vm.Used)}, + PromSample{Name: "node_mem_available_bytes", Value: float64(vm.Available)}, + PromSample{Name: "node_mem_used_percent", Value: vm.UsedPercent}, + ) + } + + // --- Swap --- + if sw, err := mem.SwapMemory(); err == nil && sw != nil { + samples = append(samples, + PromSample{Name: "node_swap_total_bytes", Value: float64(sw.Total)}, + PromSample{Name: "node_swap_used_bytes", Value: float64(sw.Used)}, + ) + } + + // --- Particiones fisicas (ignora fstypes pseudo) --- + if parts, err := disk.Partitions(false); err == nil { + for _, p := range parts { + if pseudoFstypes[p.Fstype] { + continue + } + u, err := disk.Usage(p.Mountpoint) + if err != nil || u == nil { + continue + } + lbl := map[string]string{"mount": p.Mountpoint} + samples = append(samples, + PromSample{Name: "node_disk_total_bytes", Labels: lbl, Value: float64(u.Total)}, + PromSample{Name: "node_disk_used_bytes", Labels: lbl, Value: float64(u.Used)}, + PromSample{Name: "node_disk_used_percent", Labels: lbl, Value: u.UsedPercent}, + ) + } + } + + // --- Contadores I/O por dispositivo --- + if io, err := disk.IOCounters(); err == nil { + for dev, c := range io { + lbl := map[string]string{"device": dev} + samples = append(samples, + PromSample{Name: "node_disk_read_bytes", Labels: lbl, Value: float64(c.ReadBytes)}, + PromSample{Name: "node_disk_write_bytes", Labels: lbl, Value: float64(c.WriteBytes)}, + ) + } + } + + // --- Red por interfaz (excluye loopback "lo") --- + if nics, err := net.IOCounters(true); err == nil { + for _, n := range nics { + if n.Name == "lo" { + continue + } + lbl := map[string]string{"iface": n.Name} + samples = append(samples, + PromSample{Name: "node_net_recv_bytes", Labels: lbl, Value: float64(n.BytesRecv)}, + PromSample{Name: "node_net_sent_bytes", Labels: lbl, Value: float64(n.BytesSent)}, + PromSample{Name: "node_net_recv_errs", Labels: lbl, Value: float64(n.Errin)}, + PromSample{Name: "node_net_sent_errs", Labels: lbl, Value: float64(n.Errout)}, + ) + } + } + + // --- Temperaturas (best-effort; omite el grupo si falla o no hay sensores) --- + if temps, err := sensors.SensorsTemperatures(); err == nil { + for _, t := range temps { + if t.SensorKey == "" { + continue + } + samples = append(samples, PromSample{ + Name: "node_temp_celsius", + Labels: map[string]string{"sensor": t.SensorKey}, + Value: t.Temperature, + }) + } + } + + // --- Procesos: total + top 5 por CPU --- + // En Android (Termux) gopsutil process.Processes() llama internamente a + // os.FindProcess, que usa el syscall pidfd_open bloqueado por el seccomp de + // Android (mata el proceso con SIGSYS, no recuperable). Alli contamos los + // PIDs con process.Pids() (que solo lee /proc, sin FindProcess) y omitimos + // el top por CPU. + if isAndroidHost() { + if pids, err := process.Pids(); err == nil { + samples = append(samples, PromSample{ + Name: "node_procs_total", + Value: float64(len(pids)), + }) + } + } else if procs, err := process.Processes(); err == nil { + samples = append(samples, PromSample{ + Name: "node_procs_total", + Value: float64(len(procs)), + }) + + type procStat struct { + pid int32 + name string + cpu float64 + mem float32 + } + stats := make([]procStat, 0, len(procs)) + for _, p := range procs { + cpuPct, err := p.CPUPercent() + if err != nil { + continue + } + name, err := p.Name() + if err != nil { + name = "" + } + memPct, err := p.MemoryPercent() + if err != nil { + memPct = 0 + } + stats = append(stats, procStat{pid: p.Pid, name: name, cpu: cpuPct, mem: memPct}) + } + sort.Slice(stats, func(i, j int) bool { + return stats[i].cpu > stats[j].cpu + }) + top := stats + if len(top) > 5 { + top = top[:5] + } + for _, s := range top { + lbl := map[string]string{ + "pid": strconv.Itoa(int(s.pid)), + "name": s.name, + } + samples = append(samples, + PromSample{Name: "node_proc_cpu_percent", Labels: lbl, Value: s.cpu}, + PromSample{Name: "node_proc_mem_percent", Labels: lbl, Value: float64(s.mem)}, + ) + } + } + + return samples, nil +} diff --git a/functions/infra/collect_host_metrics.md b/functions/infra/collect_host_metrics.md new file mode 100644 index 00000000..c6f9f917 --- /dev/null +++ b/functions/infra/collect_host_metrics.md @@ -0,0 +1,72 @@ +--- +name: collect_host_metrics +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func CollectHostMetrics() ([]PromSample, error)" +description: "Recolecta metricas del host actual (uptime, load, CPU global y por nucleo, memoria, swap, disco por particion fisica e I/O por dispositivo, red por interfaz, temperaturas best-effort y procesos: total + top 5 por CPU) y las devuelve como slice de PromSample con nombres estilo node_exporter simplificados. Robusta: cada grupo de colector tiene manejo de error local; si un colector secundario falla se omite ese grupo sin abortar. Funciona en Linux amd64 y Android/Termux (linux arm64)." +tags: [prometheus, metrics, node-exporter, gopsutil, fleet-metrics, infra, monitoring, host] +uses_functions: [] +uses_types: ["PromSample_go_infra"] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "sort", "strconv", "time", "github.com/shirou/gopsutil/v4/cpu", "github.com/shirou/gopsutil/v4/disk", "github.com/shirou/gopsutil/v4/host", "github.com/shirou/gopsutil/v4/load", "github.com/shirou/gopsutil/v4/mem", "github.com/shirou/gopsutil/v4/net", "github.com/shirou/gopsutil/v4/process", "github.com/shirou/gopsutil/v4/sensors"] +params: [] +output: "slice de PromSample con las metricas del host. Cada sample lleva nombre estilo node_exporter (node_cpu_percent, node_disk_used_bytes{mount}, etc.) y sus labels. Error solo si falla el uptime de host (informacion basica imprescindible)." +tested: true +tests: + - "TestCollectHostMetrics_ReturnsBasics" + - "TestCollectHostMetrics_SamplesWellFormed" +test_file_path: "functions/infra/collect_host_metrics_test.go" +file_path: "functions/infra/collect_host_metrics.go" +--- + +## Ejemplo + +```go +samples, err := CollectHostMetrics() +if err != nil { + log.Fatal(err) +} +// Formatear a exposition Prometheus y enviar a VictoriaMetrics: +body := FormatPromExposition(samples, time.Now().UnixMilli()) +err = PushPromRemote( + "https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus", + "user", "pass", + body, + map[string]string{"instance": "lucas-pc"}, +) +``` + +## Cuando usarla + +Cuando necesites un snapshot completo de salud del host en formato Prometheus +para hacer push a un backend remoto (VictoriaMetrics, Mimir, etc.) en lugar de +exponer un endpoint /metrics para scraping. Es el colector base del capability +group `fleet-metrics`: combinala con `format_prom_exposition_go_infra` y +`push_prom_remote_go_infra` para un agente de monitorizacion push estilo +node_exporter. Llamala periodicamente (cron, timer, loop) en cada nodo de la +flota. + +## Gotchas + +- **Bloquea ~400ms**: hace dos llamadas a `cpu.Percent` con intervalo de 200ms + cada una (global + por nucleo). No la llames en hot paths ni con periodo < 1s. +- **Temperaturas best-effort**: usa `sensors.SensorsTemperatures` (movido del + paquete `host` al paquete `sensors` en gopsutil v4). Si no hay sensores + (tipico en Android/Termux y muchos VPS) el grupo `node_temp_celsius` se omite + sin error. +- **Particiones pseudo ignoradas**: tmpfs, devtmpfs, overlay, squashfs, proc, + sysfs y similares se filtran. Solo reporta particiones de almacenamiento real. +- **Loopback excluido**: la interfaz `lo` no genera metricas de red. +- **CPU por proceso necesita dos lecturas**: `CPUPercent()` de gopsutil sobre un + proceso recien obtenido puede devolver un valor calculado desde el arranque + del proceso, no un delta. Util para ranking relativo del top 5, no como medida + instantanea precisa. +- **No incluye la label `instance`**: los samples no llevan instance; esa la + añade `push_prom_remote_go_infra` via extra_label en el push. +- **Permisos**: algunos contadores (procesos de otros usuarios, ciertos sensores) + pueden requerir privilegios; los fallos parciales se omiten silenciosamente. diff --git a/functions/infra/format_prom_exposition.go b/functions/infra/format_prom_exposition.go new file mode 100644 index 00000000..a5aba7f8 --- /dev/null +++ b/functions/infra/format_prom_exposition.go @@ -0,0 +1,86 @@ +package infra + +import ( + "sort" + "strconv" + "strings" +) + +// FormatPromExposition convierte un slice de PromSample en texto con formato +// Prometheus exposition. Genera una linea por sample: +// +// name{k1="v1",k2="v2"} value timestampMs +// +// Reglas: +// - Si timestampMs <= 0, omite el campo timestamp. +// - Sin labels: "name value" (sin llaves). +// - Las labels se ordenan por clave (salida determinista). +// - En los valores de label se escapa: backslash -> \\, comilla -> \", newline -> \n. +// - El nombre de metrica se sanitiza a [a-zA-Z0-9_:] (el resto -> _). +// - El valor se formatea con strconv.FormatFloat(v, 'g', -1, 64). +// +// Es una funcion pura: no tiene efectos secundarios y la salida es deterministica +// para una entrada dada. +func FormatPromExposition(samples []PromSample, timestampMs int64) string { + var b strings.Builder + for _, s := range samples { + b.WriteString(sanitizeMetricName(s.Name)) + + if len(s.Labels) > 0 { + keys := make([]string, 0, len(s.Labels)) + for k := range s.Labels { + keys = append(keys, k) + } + sort.Strings(keys) + + b.WriteByte('{') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(k) + b.WriteString(`="`) + b.WriteString(escapeLabelValue(s.Labels[k])) + b.WriteByte('"') + } + b.WriteByte('}') + } + + b.WriteByte(' ') + b.WriteString(strconv.FormatFloat(s.Value, 'g', -1, 64)) + + if timestampMs > 0 { + b.WriteByte(' ') + b.WriteString(strconv.FormatInt(timestampMs, 10)) + } + + b.WriteByte('\n') + } + return b.String() +} + +// sanitizeMetricName sustituye cualquier caracter fuera de [a-zA-Z0-9_:] por '_'. +func sanitizeMetricName(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '_' || r == ':' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +// escapeLabelValue escapa los caracteres especiales del formato exposition en +// el valor de una label: backslash, comilla doble y newline. +func escapeLabelValue(v string) string { + v = strings.ReplaceAll(v, `\`, `\\`) + v = strings.ReplaceAll(v, `"`, `\"`) + v = strings.ReplaceAll(v, "\n", `\n`) + return v +} diff --git a/functions/infra/format_prom_exposition.md b/functions/infra/format_prom_exposition.md new file mode 100644 index 00000000..d3fc811d --- /dev/null +++ b/functions/infra/format_prom_exposition.md @@ -0,0 +1,64 @@ +--- +name: format_prom_exposition +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func FormatPromExposition(samples []PromSample, timestampMs int64) string" +description: "Convierte un slice de PromSample en texto con formato Prometheus exposition (una linea por sample: name{k=\"v\"} value timestampMs). Ordena labels por clave (salida determinista), escapa backslash/comilla/newline en valores de label, sanitiza el nombre de metrica a [a-zA-Z0-9_:], formatea el valor con FormatFloat 'g'. Si timestampMs<=0 omite el timestamp; sin labels omite las llaves. Funcion pura." +tags: [prometheus, exposition, metrics, format, fleet-metrics, infra, monitoring] +uses_functions: [] +uses_types: ["PromSample_go_infra"] +returns: [] +returns_optional: false +error_type: "" +imports: ["sort", "strconv", "strings"] +params: + - name: samples + desc: "slice de PromSample a serializar; cada uno aporta una linea de exposition" + - name: timestampMs + desc: "timestamp en milisegundos epoch a adjuntar a cada linea; si es <=0 se omite el campo timestamp" +output: "string con el texto exposition Prometheus, una linea por sample terminada en \\n. String vacio si samples esta vacio." +tested: true +tests: + - "TestFormatPromExposition" +test_file_path: "functions/infra/format_prom_exposition_test.go" +file_path: "functions/infra/format_prom_exposition.go" +--- + +## Ejemplo + +```go +samples := []PromSample{ + {Name: "node_load1", Value: 0.42}, + {Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5}, + {Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1024}, +} +text := FormatPromExposition(samples, 1700000000000) +// node_load1 0.42 1700000000000 +// node_cpu_core_percent{core="0"} 12.5 1700000000000 +// node_disk_used_bytes{mount="/"} 1024 1700000000000 +``` + +## Cuando usarla + +Cuando tengas un slice de PromSample (tipicamente de collect_host_metrics) y +necesites serializarlo al formato de texto que entienden los endpoints de +ingestion Prometheus (`/api/v1/import/prometheus` de VictoriaMetrics, pushgateway, +etc.). Es el paso intermedio del capability group `fleet-metrics`: colecta -> +formatea -> empuja. Al ser pura y determinista, tambien sirve para snapshots +reproducibles y golden tests. + +## Gotchas + +- El timestamp es **milisegundos** epoch (Prometheus exposition usa ms), no + segundos. Pasa `time.Now().UnixMilli()`. +- `timestampMs <= 0` (incluido 0) omite el campo timestamp por completo. +- La label `instance` NO se gestiona aqui: si esta en `Labels` se serializa tal + cual, pero la convencion del grupo es dejarla fuera y añadirla en el push via + extra_label. +- No agrupa por nombre ni emite lineas `# HELP` / `# TYPE`: salida cruda de + series, suficiente para ingestion pero no para un endpoint /metrics canonico. +- El nombre de metrica se sanitiza de forma destructiva: `node.cpu-percent!` se + convierte en `node_cpu_percent_`. Nombra bien los samples en origen. diff --git a/functions/infra/prom_sample.go b/functions/infra/prom_sample.go new file mode 100644 index 00000000..608cc941 --- /dev/null +++ b/functions/infra/prom_sample.go @@ -0,0 +1,12 @@ +package infra + +// PromSample representa una unica serie de metrica en formato Prometheus: +// el nombre de la metrica, sus labels y un valor numerico. +// +// La label "instance" NO se incluye aqui: la añade el pusher remoto via +// extra_label cuando hace el push a VictoriaMetrics. +type PromSample struct { + Name string // nombre de metrica prometheus, ej "node_cpu_percent" + Labels map[string]string // labels de la serie, ej {"core":"0"} (sin la label "instance") + Value float64 +} diff --git a/functions/infra/push_loki_stream.go b/functions/infra/push_loki_stream.go new file mode 100644 index 00000000..871c9f39 --- /dev/null +++ b/functions/infra/push_loki_stream.go @@ -0,0 +1,80 @@ +package infra + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// PushLokiStream envia lineas de log a un servidor Grafana Loki via su push API. +// Construye el cuerpo JSON con la forma {"streams":[{"stream":{labels},"values":[["",""],...]}]} +// y lo manda por POST a endpoint (ej "https://logs-xxxx.organic-machine.com/loki/api/v1/push"). +// +// Reglas: +// - timestampsNs y lines deben tener la misma longitud; si no, retorna error antes de hacer la peticion. +// - Si len(lines)==0 es un no-op: no hace ninguna peticion y retorna nil. +// - labels va tal cual en el campo "stream". +// - Si user != "", usa Basic Auth con user/pass. +// - Content-Type: application/json. TLS verificado. Timeout 10s. +// - Exito = status 2xx (Loki devuelve 204). Si no-2xx, error con el codigo + primeros 200 bytes del cuerpo. +func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error { + if len(timestampsNs) != len(lines) { + return fmt.Errorf("push_loki_stream: timestampsNs (%d) y lines (%d) tienen longitudes distintas", len(timestampsNs), len(lines)) + } + + // No-op cuando no hay lineas. + if len(lines) == 0 { + return nil + } + + values := make([][2]string, len(lines)) + for i := range lines { + values[i] = [2]string{strconv.FormatInt(timestampsNs[i], 10), lines[i]} + } + + stream := labels + if stream == nil { + stream = map[string]string{} + } + + body := map[string]any{ + "streams": []map[string]any{ + { + "stream": stream, + "values": values, + }, + }, + } + + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("push_loki_stream: marshal body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("push_loki_stream: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if user != "" { + req.SetBasicAuth(user, pass) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("push_loki_stream: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return fmt.Errorf("push_loki_stream: HTTP %d: %s", resp.StatusCode, string(snippet)) + } + + return nil +} diff --git a/functions/infra/push_loki_stream.md b/functions/infra/push_loki_stream.md new file mode 100644 index 00000000..6540312a --- /dev/null +++ b/functions/infra/push_loki_stream.md @@ -0,0 +1,92 @@ +--- +name: push_loki_stream +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PushLokiStream(endpoint string, user string, pass string, labels map[string]string, timestampsNs []int64, lines []string) error" +description: "Envia lineas de log a un servidor Grafana Loki via su push API. Construye el cuerpo JSON {\"streams\":[{\"stream\":{labels},\"values\":[[\"\",\"\"],...]}]} y lo POSTea al endpoint. Soporta Basic Auth opcional, valida que timestamps y lineas tengan igual longitud, es no-op si no hay lineas, y exige status 2xx. Solo stdlib, TLS verificado." +tags: [loki, grafana, logs, push, metrics, http, json, stdlib, infra, fleet-metrics] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["bytes", "encoding/json", "fmt", "io", "net/http", "strconv", "time"] +params: + - name: endpoint + desc: "URL completa del push API de Loki (ej https://logs-xxxx.organic-machine.com/loki/api/v1/push)" + - name: user + desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization" + - name: pass + desc: "password para Basic Auth; solo se usa cuando user != ''" + - name: labels + desc: "labels del stream Loki (ej {instance:lucas, job:journald, unit:ssh.service}); van tal cual en el campo stream" + - name: timestampsNs + desc: "timestamps en nanosegundos desde epoch, uno por linea; debe tener la misma longitud que lines" + - name: lines + desc: "lineas de log a enviar, alineadas posicionalmente con timestampsNs; si esta vacio la funcion es no-op" +output: "error si la peticion falla, las longitudes no coinciden o el status no es 2xx; nil en exito (incluido el no-op de 0 lineas)" +tested: true +tests: + - "JSON enviado tiene estructura streams/stream/values correcta" + - "longitudes desiguales dan error antes del POST" + - "len lines cero es no-op sin peticion" + - "Basic Auth presente cuando user no vacio" + - "status 500 produce error" +test_file_path: "functions/infra/push_loki_stream_test.go" +file_path: "functions/infra/push_loki_stream.go" +--- + +## Ejemplo + +```go +labels := map[string]string{ + "instance": "lucas", + "job": "journald", + "unit": "ssh.service", +} +nowNs := time.Now().UnixNano() +ts := []int64{nowNs, nowNs + 1} +lines := []string{ + "Accepted publickey for lucas from 10.0.0.2", + "session opened for user lucas", +} + +err := PushLokiStream( + "https://logs-abcd.organic-machine.com/loki/api/v1/push", + "tenant1", // user para Basic Auth (vacio = sin auth) + "s3cr3t", // pass + labels, + ts, + lines, +) +if err != nil { + return err +} +``` + +## Cuando usarla + +Cuando necesites enviar lineas de log a Grafana Loki desde un agente o servicio Go +(ej. reenviar journald, eventos de una app, o lineas de un tailer) sin arrastrar el +cliente oficial de Loki. Util para alimentar dashboards de la flota (`fleet-metrics`) +con logs etiquetados por instancia/job/unit. Pasa los logs ya batcheados: un solo +stream por llamada con sus labels. + +## Gotchas + +- `timestampsNs` y `lines` deben tener exactamente la misma longitud; si no, retorna + error ANTES de hacer la peticion (no envia nada). +- `len(lines)==0` es un no-op deliberado: retorna `nil` sin tocar la red. Comprueba el + caso vacio en el caller si necesitas distinguir "no habia logs" de "envio ok". +- Loki exige timestamps en NANOSEGUNDOS. Pasar segundos o milisegundos hace que las + lineas caigan fuera de la ventana de retencion y Loki las rechace silenciosamente. +- Dentro de un mismo stream las entradas deberian ir en orden creciente de timestamp; + Loki puede rechazar entradas fuera de orden segun su config. +- Exito = status 2xx (Loki normalmente devuelve 204 No Content). Un 4xx/5xx produce + error con el codigo + primeros 200 bytes del cuerpo de respuesta para diagnostico. +- TLS verificado (sin InsecureSkipVerify) y `http.Client` con timeout de 10s fijo. Para + endpoints con certificado interno hace falta CA confiable en el sistema. +- Secretos (`user`/`pass`): nunca hardcodear — resolver desde `pass`/vault en el caller. diff --git a/functions/infra/push_prom_remote.go b/functions/infra/push_prom_remote.go new file mode 100644 index 00000000..cfd0b32e --- /dev/null +++ b/functions/infra/push_prom_remote.go @@ -0,0 +1,60 @@ +package infra + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// PushPromRemote hace POST del body (texto en formato Prometheus exposition) al +// endpoint dado, tipicamente el import de VictoriaMetrics +// (".../api/v1/import/prometheus"). +// +// - Si user != "" usa Basic Auth con user/pass. +// - extraLabels se adjuntan como query params repetidos +// "extra_label=clave=valor" URL-encoded. VictoriaMetrics añade esas labels a +// TODAS las series del push (util para la label "instance"). +// - Content-Type: text/plain. +// - http.Client con Timeout 10s y TLS verificado. +// - Exito = status 2xx (VictoriaMetrics devuelve 204). Si no-2xx, retorna error +// con el codigo y los primeros 200 bytes del cuerpo de respuesta. +func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error { + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("parse endpoint %q: %w", endpoint, err) + } + + if len(extraLabels) > 0 { + q := u.Query() + for k, v := range extraLabels { + q.Add("extra_label", k+"="+v) + } + u.RawQuery = q.Encode() + } + + req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "text/plain") + if user != "" { + req.SetBasicAuth(user, pass) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("push to %q: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return fmt.Errorf("push to %q failed: status %d: %s", endpoint, resp.StatusCode, string(snippet)) + } + + return nil +} diff --git a/functions/infra/push_prom_remote.md b/functions/infra/push_prom_remote.md new file mode 100644 index 00000000..835f4be6 --- /dev/null +++ b/functions/infra/push_prom_remote.md @@ -0,0 +1,74 @@ +--- +name: push_prom_remote +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func PushPromRemote(endpoint string, user string, pass string, body string, extraLabels map[string]string) error" +description: "Hace POST de un body en formato Prometheus exposition a un endpoint remoto (ej VictoriaMetrics /api/v1/import/prometheus). Soporta Basic Auth (si user!=\"\"), adjunta extraLabels como query params repetidos extra_label=clave=valor (VictoriaMetrics los añade a todas las series del push), Content-Type text/plain, http.Client con Timeout 10s y TLS verificado. Exito = status 2xx; si no, error con codigo + primeros 200 bytes del cuerpo de respuesta." +tags: [prometheus, push, victoriametrics, remote-write, fleet-metrics, infra, monitoring, http] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["fmt", "io", "net/http", "net/url", "strings", "time"] +params: + - name: endpoint + desc: "URL completa del endpoint de ingestion, ej https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus" + - name: user + desc: "usuario para Basic Auth; si es cadena vacia no se envia Authorization" + - name: pass + desc: "password para Basic Auth; se ignora si user esta vacio" + - name: body + desc: "texto en formato Prometheus exposition (tipicamente salida de format_prom_exposition)" + - name: extraLabels + desc: "labels a adjuntar a todas las series via extra_label, ej {\"instance\":\"lucas\"}; puede ser nil" +output: "nil si el push devuelve status 2xx (VictoriaMetrics responde 204). Error si la request falla, el endpoint es invalido, o el status no es 2xx (con codigo y snippet del cuerpo)." +tested: true +tests: + - "TestPushPromRemote" +test_file_path: "functions/infra/push_prom_remote_test.go" +file_path: "functions/infra/push_prom_remote.go" +--- + +## Ejemplo + +```go +body := FormatPromExposition(samples, time.Now().UnixMilli()) +err := PushPromRemote( + "https://metrics-xxxx.organic-machine.com/api/v1/import/prometheus", + "ingest-user", "ingest-pass", + body, + map[string]string{"instance": "lucas-pc"}, +) +if err != nil { + log.Printf("push fallo: %v", err) +} +``` + +## Cuando usarla + +Cuando un nodo de la flota tiene que **empujar** sus metricas a un backend +central (VictoriaMetrics, Mimir, pushgateway) en vez de exponer un /metrics para +scraping. Es el paso final del capability group `fleet-metrics`: +collect_host_metrics -> format_prom_exposition -> push_prom_remote. Tipica en +nodos detras de NAT, moviles (Termux) o cualquier host al que el servidor central +no puede alcanzar para hacer pull. + +## Gotchas + +- **extra_label es clave=valor como un solo valor de query**: para {"instance":"lucas"} + produce `?extra_label=instance%3Dlucas` (el `=` interno se URL-encodea a `%3D`). + VictoriaMetrics lo aplica a todas las series del push; otros backends pueden no + soportar este parametro. +- **Secretos**: nunca hardcodees `user`/`pass` — resuelvelos desde `pass`/vault. +- **TLS verificado** (sin InsecureSkipVerify): un endpoint con certificado + autofirmado fallara. Usa un certificado valido o un proxy de confianza. +- **Timeout 10s**: un backend lento o un body enorme puede dar timeout. Trocea + pushes muy grandes si es necesario. +- **204 No Content es exito**: VictoriaMetrics no devuelve 200. La funcion acepta + cualquier 2xx; no asumas 200 al testear contra el real. +- **El cuerpo de error se trunca a 200 bytes**: suficiente para diagnostico + rapido, no para el detalle completo del backend. diff --git a/go.mod b/go.mod index f77db7bd..ee8513bc 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,15 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.7.0 github.com/google/uuid v1.6.0 + github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 github.com/jackc/pgx/v5 v5.9.1 github.com/marcboeker/go-duckdb v1.8.5 github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 + github.com/shirou/gopsutil/v4 v4.26.5 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 @@ -40,23 +43,24 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/creack/pty v1.1.24 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/flatbuffers v25.1.24+incompatible // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -67,6 +71,7 @@ require ( github.com/paulmach/orb v0.12.0 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/zerolog v1.35.1 // indirect @@ -76,7 +81,10 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.mau.fi/util v0.9.9 // indirect go.opentelemetry.io/otel v1.41.0 // indirect diff --git a/go.sum b/go.sum index 92057c8f..336edb2c 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -55,6 +57,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -70,6 +74,7 @@ github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLK github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -104,6 +109,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -139,6 +146,8 @@ github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -147,6 +156,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= @@ -170,6 +181,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= @@ -182,6 +197,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= @@ -224,8 +241,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/types/infra/prom_sample.md b/types/infra/prom_sample.md new file mode 100644 index 00000000..40b1d1c3 --- /dev/null +++ b/types/infra/prom_sample.md @@ -0,0 +1,39 @@ +--- +name: PromSample +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type PromSample struct { + Name string // nombre de metrica prometheus, ej "node_cpu_percent" + Labels map[string]string // labels de la serie, ej {"core":"0"} (sin la label "instance") + Value float64 + } +description: "Una unica serie de metrica en formato Prometheus: nombre, labels y valor numerico. Es la unidad que producen los colectores (collect_host_metrics) y consume el formateador (format_prom_exposition). NO incluye la label instance: esa la añade el pusher remoto via extra_label." +tags: [prometheus, metrics, sample, fleet-metrics, infra, monitoring] +uses_types: [] +file_path: "functions/infra/prom_sample.go" +--- + +## Campos + +- `Name`: nombre de la metrica en formato Prometheus, ej `node_cpu_percent`, `node_disk_used_bytes`. Se sanitiza a `[a-zA-Z0-9_:]` en el formateador. +- `Labels`: mapa clave/valor de labels de la serie, ej `{"core":"0"}`, `{"mount":"/"}`. Puede ser `nil` o vacio (serie sin labels). NO debe incluir la label `instance` — esa se añade en el push remoto. +- `Value`: valor numerico de la metrica. Se formatea con `strconv.FormatFloat(v, 'g', -1, 64)`. + +## Ejemplo + +```go +samples := []PromSample{ + {Name: "node_load1", Labels: nil, Value: 0.42}, + {Name: "node_cpu_core_percent", Labels: map[string]string{"core": "0"}, Value: 12.5}, + {Name: "node_disk_used_bytes", Labels: map[string]string{"mount": "/"}, Value: 1.2e10}, +} +``` + +## Notas + +- Es un tipo producto puro: solo datos, sin metodos. +- Vive en `functions/infra/prom_sample.go` (mismo paquete Go que los colectores y el formateador) para evitar imports cruzados entre paquetes del registry. +- Lo producen `collect_host_metrics_go_infra` y lo consume `format_prom_exposition_go_infra`.