feat(infra): grupo fleet-metrics — collect_host_metrics, format_prom_exposition, push_prom_remote, push_loki_stream, collect_battery_metrics + tipo PromSample (gopsutil; Android-safe: sin exec/pidfd, procesos via /proc)

This commit is contained in:
Egutierrez
2026-06-07 14:25:45 +02:00
parent 8742cb25be
commit d996542f88
14 changed files with 1055 additions and 2 deletions
+120
View File
@@ -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
}
@@ -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.
+246
View File
@@ -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
}
+72
View File
@@ -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.
+86
View File
@@ -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
}
+64
View File
@@ -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.
+12
View File
@@ -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
}
+80
View File
@@ -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":[["<ts_ns>","<line>"],...]}]}
// 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
}
+92
View File
@@ -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\":[[\"<ts_ns>\",\"<line>\"],...]}]} 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.
+60
View File
@@ -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
}
+74
View File
@@ -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.
+10 -2
View File
@@ -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
+19
View File
@@ -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=
+39
View File
@@ -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`.