190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
// Package metrics exposes a deliberately small, low-cardinality Prometheus
|
|
// surface without adding a runtime dependency. Tenant, Site, device and Path
|
|
// identifiers never become labels.
|
|
package metrics
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
func GenerateInstanceID() (string, error) {
|
|
value := make([]byte, 12)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", errors.New("generate Sense instance identifier")
|
|
}
|
|
return "ins_" + hex.EncodeToString(value), nil
|
|
}
|
|
|
|
type Registry struct {
|
|
instanceID string
|
|
version string
|
|
|
|
reconcileRunOK atomic.Uint64
|
|
reconcileRunError atomic.Uint64
|
|
reconcileItemOK atomic.Uint64
|
|
reconcileItemError atomic.Uint64
|
|
reconcileItemLost atomic.Uint64
|
|
reconcileDurationNS atomic.Uint64
|
|
reconcileTotal atomic.Int64
|
|
reconcileUnconverged atomic.Int64
|
|
|
|
orphanScanOK atomic.Uint64
|
|
orphanScanError atomic.Uint64
|
|
orphanObserved atomic.Int64
|
|
orphanOwnedStale atomic.Int64
|
|
orphanUnowned atomic.Int64
|
|
orphanBlockedRatio atomic.Uint64
|
|
orphanBlockedAge atomic.Uint64
|
|
orphanBlockedScope atomic.Uint64
|
|
orphanDeleted atomic.Uint64
|
|
orphanDeleteFailed atomic.Uint64
|
|
}
|
|
|
|
func New(instanceID, version string) *Registry {
|
|
return &Registry{instanceID: instanceID, version: version}
|
|
}
|
|
|
|
func (r *Registry) ObserveReconcileRun(err error, duration time.Duration) {
|
|
if err == nil {
|
|
r.reconcileRunOK.Add(1)
|
|
} else {
|
|
r.reconcileRunError.Add(1)
|
|
}
|
|
if duration < 0 {
|
|
duration = 0
|
|
}
|
|
r.reconcileDurationNS.Store(uint64(duration))
|
|
}
|
|
|
|
func (r *Registry) ObserveReconcileItem(result string) {
|
|
switch result {
|
|
case "success":
|
|
r.reconcileItemOK.Add(1)
|
|
case "lease_lost":
|
|
r.reconcileItemLost.Add(1)
|
|
default:
|
|
r.reconcileItemError.Add(1)
|
|
}
|
|
}
|
|
|
|
func (r *Registry) SetConvergence(total, unconverged int) {
|
|
r.reconcileTotal.Store(int64(total))
|
|
r.reconcileUnconverged.Store(int64(unconverged))
|
|
}
|
|
|
|
func (r *Registry) ObserveOrphanScan(observed, ownedStale, unowned int, err error) {
|
|
if err == nil {
|
|
r.orphanScanOK.Add(1)
|
|
r.orphanObserved.Store(int64(observed))
|
|
r.orphanOwnedStale.Store(int64(ownedStale))
|
|
r.orphanUnowned.Store(int64(unowned))
|
|
} else {
|
|
r.orphanScanError.Add(1)
|
|
}
|
|
}
|
|
|
|
func (r *Registry) ObserveOrphanCleanupBlocked(reason string) {
|
|
switch reason {
|
|
case "snapshot_expired":
|
|
r.orphanBlockedAge.Add(1)
|
|
case "ratio_exceeded":
|
|
r.orphanBlockedRatio.Add(1)
|
|
default:
|
|
r.orphanBlockedScope.Add(1)
|
|
}
|
|
}
|
|
|
|
func (r *Registry) ObserveOrphanCleanup(deleted, failed int) {
|
|
if deleted > 0 {
|
|
r.orphanDeleted.Add(uint64(deleted))
|
|
}
|
|
if failed > 0 {
|
|
r.orphanDeleteFailed.Add(uint64(failed))
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Handler() http.Handler {
|
|
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
writer.Header().Set("Cache-Control", "no-store")
|
|
_, _ = writer.Write([]byte(r.render()))
|
|
})
|
|
}
|
|
|
|
func (r *Registry) render() string {
|
|
var output strings.Builder
|
|
writeMetric(&output, "sense_build_info", "Sense process build and instance information.",
|
|
fmt.Sprintf("{instance_id=%s,version=%s}", quoteLabel(r.instanceID), quoteLabel(r.version)), 1)
|
|
writeMetric(&output, "sense_reconcile_runs_total", "Reconciliation runs by result.",
|
|
`{result="success"}`, r.reconcileRunOK.Load())
|
|
writeSample(&output, "sense_reconcile_runs_total", `{result="error"}`, r.reconcileRunError.Load())
|
|
writeMetric(&output, "sense_reconcile_items_total", "Reconciliation items by fixed result.",
|
|
`{result="success"}`, r.reconcileItemOK.Load())
|
|
writeSample(&output, "sense_reconcile_items_total", `{result="error"}`, r.reconcileItemError.Load())
|
|
writeSample(&output, "sense_reconcile_items_total", `{result="lease_lost"}`, r.reconcileItemLost.Load())
|
|
writeMetric(&output, "sense_reconcile_last_duration_seconds", "Duration of the last reconciliation run.",
|
|
"", float64(r.reconcileDurationNS.Load())/float64(time.Second))
|
|
writeMetric(&output, "sense_reconcile_devices", "Enabled video devices by convergence state.",
|
|
`{state="total"}`, r.reconcileTotal.Load())
|
|
writeSample(&output, "sense_reconcile_devices", `{state="unconverged"}`, r.reconcileUnconverged.Load())
|
|
writeMetric(&output, "sense_orphan_scan_runs_total", "MediaMTX orphan scans by result.",
|
|
`{result="success"}`, r.orphanScanOK.Load())
|
|
writeSample(&output, "sense_orphan_scan_runs_total", `{result="error"}`, r.orphanScanError.Load())
|
|
writeMetric(&output, "sense_orphan_paths", "Path counts from the last successful orphan scan.",
|
|
`{classification="observed"}`, r.orphanObserved.Load())
|
|
writeSample(&output, "sense_orphan_paths", `{classification="owned_stale"}`, r.orphanOwnedStale.Load())
|
|
writeSample(&output, "sense_orphan_paths", `{classification="unowned"}`, r.orphanUnowned.Load())
|
|
writeMetric(&output, "sense_orphan_cleanup_blocked_total", "Orphan cleanups blocked by a fixed safety reason.",
|
|
`{reason="ratio_exceeded"}`, r.orphanBlockedRatio.Load())
|
|
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="snapshot_expired"}`, r.orphanBlockedAge.Load())
|
|
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="scope_changed"}`, r.orphanBlockedScope.Load())
|
|
writeMetric(&output, "sense_orphan_cleanup_items_total", "Owned stale path cleanup results.",
|
|
`{result="deleted"}`, r.orphanDeleted.Load())
|
|
writeSample(&output, "sense_orphan_cleanup_items_total", `{result="failed"}`, r.orphanDeleteFailed.Load())
|
|
return output.String()
|
|
}
|
|
|
|
func writeMetric(builder *strings.Builder, name, help, labels string, value any) {
|
|
metricType := "gauge"
|
|
if strings.HasSuffix(name, "_total") {
|
|
metricType = "counter"
|
|
}
|
|
builder.WriteString("# HELP ")
|
|
builder.WriteString(name)
|
|
builder.WriteByte(' ')
|
|
builder.WriteString(help)
|
|
builder.WriteByte('\n')
|
|
builder.WriteString("# TYPE ")
|
|
builder.WriteString(name)
|
|
builder.WriteByte(' ')
|
|
builder.WriteString(metricType)
|
|
builder.WriteByte('\n')
|
|
writeSample(builder, name, labels, value)
|
|
}
|
|
|
|
func writeSample(builder *strings.Builder, name, labels string, value any) {
|
|
builder.WriteString(name)
|
|
builder.WriteString(labels)
|
|
builder.WriteByte(' ')
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
builder.WriteString(strconv.FormatFloat(typed, 'f', 6, 64))
|
|
default:
|
|
builder.WriteString(fmt.Sprint(typed))
|
|
}
|
|
builder.WriteByte('\n')
|
|
}
|
|
|
|
func quoteLabel(value string) string {
|
|
replacer := strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
|
|
return `"` + replacer.Replace(value) + `"`
|
|
}
|