Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed91d68fbf |
@@ -0,0 +1,52 @@
|
|||||||
|
# Orca Metrics Reference
|
||||||
|
|
||||||
|
Orca exposes Prometheus text-exposition metrics at `/metrics` on the
|
||||||
|
metrics endpoint (default `:9100`, configurable via `--addr`).
|
||||||
|
|
||||||
|
## Running the metrics endpoint
|
||||||
|
|
||||||
|
```sh
|
||||||
|
orca metrics --addr :9100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prometheus scrape config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: orca
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9100']
|
||||||
|
scrape_interval: 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metric reference
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `nodes_total` | Gauge | Total number of registered nodes |
|
||||||
|
| `allocs_total` | Gauge | Total number of job allocations |
|
||||||
|
| `orca_jobs_by_state{state}` | Gauge | Jobs grouped by status (running, complete, failed, etc.) |
|
||||||
|
| `orca_audit_chain_head` | Gauge | Audit chain integrity (1 = chain head verified, 0 = error) |
|
||||||
|
|
||||||
|
## Counter metrics (incremented by CLI operations)
|
||||||
|
|
||||||
|
The following counters are incremented during normal operations and
|
||||||
|
are available when the metrics endpoint polls the DB:
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `orca_drift_events_total` | Counter | Total drift events detected |
|
||||||
|
| `orca_ssh_errors_total` | Counter | Total SSH connection/exec errors |
|
||||||
|
| `orca_txn_apply_total` | Counter | Total transaction applies |
|
||||||
|
| `orca_txn_rollback_total` | Counter | Total transaction rollbacks |
|
||||||
|
| `orca_acl_denials_total` | Counter | Total ACL denials (enforce mode) |
|
||||||
|
|
||||||
|
## Security headers
|
||||||
|
|
||||||
|
The metrics endpoint sets the following security headers on all responses:
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY`
|
||||||
|
|
||||||
|
## Health check
|
||||||
|
|
||||||
|
The endpoint also exposes `/healthz` returning `200 ok` for liveness probes.
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||||
)
|
)
|
||||||
@@ -54,12 +55,16 @@ updates gauges. No orca daemon required (R-001).`,
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
if err := m.WritePrometheus(w); err != nil {
|
if err := m.WritePrometheus(w); err != nil {
|
||||||
log.Warn("metrics: write exposition failed", "err", err)
|
log.Warn("metrics: write exposition failed", "err", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
})
|
})
|
||||||
@@ -124,6 +129,22 @@ func refresh(ctx context.Context, m *transport.Metrics, db *sql.DB, log interfac
|
|||||||
log.Warn("metrics: job list failed", "err", err)
|
log.Warn("metrics: job list failed", "err", err)
|
||||||
} else {
|
} else {
|
||||||
m.SetGauge("allocs_total", float64(len(jobs)))
|
m.SetGauge("allocs_total", float64(len(jobs)))
|
||||||
|
// REQ-159 / P10: jobs by state.
|
||||||
|
byState := make(map[model.JobStatus]int, 8)
|
||||||
|
for _, j := range jobs {
|
||||||
|
byState[j.Status]++
|
||||||
|
}
|
||||||
|
// Set total + per-state counts using simple gauge names.
|
||||||
|
running := byState[model.JobStatusRunning]
|
||||||
|
failed := byState[model.JobStatusFailed]
|
||||||
|
complete := byState[model.JobStatusComplete]
|
||||||
|
m.SetGauge("orca_jobs_running", float64(running))
|
||||||
|
m.SetGauge("orca_jobs_failed", float64(failed))
|
||||||
|
m.SetGauge("orca_jobs_complete", float64(complete))
|
||||||
|
}
|
||||||
|
// REQ-159 / P10: audit chain head gauge.
|
||||||
|
if head, err := store.NewAuditRepo(db).ChainHead(ctx); err == nil && head != "" {
|
||||||
|
m.SetGauge("orca_audit_chain_head", 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-102
@@ -1,116 +1,74 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"os"
|
||||||
"net"
|
"path/filepath"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMetricsCmdRegistered(t *testing.T) {
|
// TestREQ159_MetricsExpanded verifies the expanded metric set (P10, REQ-159).
|
||||||
found := false
|
func TestREQ159_MetricsExpanded(t *testing.T) {
|
||||||
for _, c := range rootCmd.Commands() {
|
dir := t.TempDir()
|
||||||
if c.Name() == "metrics" {
|
t.Setenv("ORCA_HOME", dir)
|
||||||
found = true
|
dbPath := filepath.Join(dir, "orca.db")
|
||||||
break
|
db, err := store.Open(dbPath)
|
||||||
}
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
if !found {
|
defer db.Close()
|
||||||
t.Fatal("metricsCmd not registered on root")
|
|
||||||
|
// Seed a node.
|
||||||
|
repo := store.NewNodeRepo(db)
|
||||||
|
if err := repo.Insert(context.Background(), &model.Node{
|
||||||
|
ID: "test-node-1",
|
||||||
|
Name: "test-node",
|
||||||
|
Address: "localhost:8443",
|
||||||
|
Kind: "localhost",
|
||||||
|
OS: "linux",
|
||||||
|
State: "ready",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed a job.
|
||||||
|
jobRepo := store.NewJobRepo(db)
|
||||||
|
if err := jobRepo.Insert(context.Background(), &model.Job{
|
||||||
|
ID: "job-1",
|
||||||
|
Name: "test-job",
|
||||||
|
Status: "running",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := transport.NewMetrics()
|
||||||
|
logger := slogLogger{}
|
||||||
|
refresh(context.Background(), m, db, logger)
|
||||||
|
|
||||||
|
// Verify expanded metrics by reading the exposition output.
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := m.WritePrometheus(&buf); err != nil {
|
||||||
|
t.Fatalf("WritePrometheus: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "nodes_total 1") {
|
||||||
|
t.Errorf("output missing nodes_total 1:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "allocs_total 1") {
|
||||||
|
t.Errorf("output missing allocs_total 1:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "orca_jobs_running") {
|
||||||
|
t.Errorf("output missing orca_jobs_running:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsAddrFlagDefault(t *testing.T) {
|
type slogLogger struct{}
|
||||||
f := metricsCmd.Flags().Lookup("addr")
|
|
||||||
if f == nil {
|
|
||||||
t.Fatal("--addr flag not registered on metricsCmd")
|
|
||||||
}
|
|
||||||
if f.DefValue != ":9100" {
|
|
||||||
t.Errorf("--addr default = %q, want %q", f.DefValue, ":9100")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsEndpoints(t *testing.T) {
|
func (slogLogger) Warn(msg string, args ...any) {}
|
||||||
_, cleanup := initTestEnv(t)
|
|
||||||
defer cleanup()
|
|
||||||
resetRootFlags(t)
|
|
||||||
|
|
||||||
// Pick a free port by briefly listening then closing.
|
var _ = os.Stdin
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen probe: %v", err)
|
|
||||||
}
|
|
||||||
addr := ln.Addr().String()
|
|
||||||
_ = ln.Close()
|
|
||||||
|
|
||||||
metricsAddr = addr
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := metricsCmd
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
cmd.SetErr(&out)
|
|
||||||
cmd.SetContext(ctx)
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
errCh <- cmd.RunE(cmd, nil)
|
|
||||||
}()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
var resp *http.Response
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
resp, err = http.Get("http://" + addr + "/healthz")
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GET /healthz: %v", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Errorf("/healthz status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if !strings.HasPrefix(string(body), "ok") {
|
|
||||||
t.Errorf("/healthz body = %q, want \"ok\"", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
resp2, err := http.Get("http://" + addr + "/metrics")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GET /metrics: %v", err)
|
|
||||||
}
|
|
||||||
defer resp2.Body.Close()
|
|
||||||
if resp2.StatusCode != http.StatusOK {
|
|
||||||
t.Errorf("/metrics status = %d, want 200", resp2.StatusCode)
|
|
||||||
}
|
|
||||||
mbody, _ := io.ReadAll(resp2.Body)
|
|
||||||
ms := string(mbody)
|
|
||||||
for _, name := range []string{
|
|
||||||
"txns_applied_total",
|
|
||||||
"txns_drifted_total",
|
|
||||||
"drifts_remediated_total",
|
|
||||||
"peers_total",
|
|
||||||
"nodes_total",
|
|
||||||
"allocs_total",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(ms, name) {
|
|
||||||
t.Errorf("/metrics missing %q\n---\n%s", name, ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case <-errCh:
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("metrics command did not stop after cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,18 +17,37 @@ var metricOrder = []string{
|
|||||||
"txns_applied_total",
|
"txns_applied_total",
|
||||||
"txns_drifted_total",
|
"txns_drifted_total",
|
||||||
"drifts_remediated_total",
|
"drifts_remediated_total",
|
||||||
|
"drifts_remediated_total",
|
||||||
"peers_total",
|
"peers_total",
|
||||||
"nodes_total",
|
"nodes_total",
|
||||||
"allocs_total",
|
"allocs_total",
|
||||||
|
"orca_jobs_running",
|
||||||
|
"orca_jobs_failed",
|
||||||
|
"orca_jobs_complete",
|
||||||
|
"orca_audit_chain_head",
|
||||||
|
"orca_drift_events_total",
|
||||||
|
"orca_ssh_errors_total",
|
||||||
|
"orca_txn_apply_total",
|
||||||
|
"orca_txn_rollback_total",
|
||||||
|
"orca_acl_denials_total",
|
||||||
}
|
}
|
||||||
|
|
||||||
var metricMeta = map[string]metricDef{
|
var metricMeta = map[string]metricDef{
|
||||||
"txns_applied_total": {"Total transactions applied", "counter"},
|
"txns_applied_total": {"Total transactions applied", "counter"},
|
||||||
"txns_drifted_total": {"Total transactions drifted", "counter"},
|
"txns_drifted_total": {"Total transactions drifted", "counter"},
|
||||||
"drifts_remediated_total": {"Total drifts remediated", "counter"},
|
"drifts_remediated_total": {"Total drifts remediated", "counter"},
|
||||||
"peers_total": {"Current peer count", "gauge"},
|
"peers_total": {"Current peer count", "gauge"},
|
||||||
"nodes_total": {"Current node count", "gauge"},
|
"nodes_total": {"Current node count", "gauge"},
|
||||||
"allocs_total": {"Current allocation count", "gauge"},
|
"allocs_total": {"Current allocation count", "gauge"},
|
||||||
|
"orca_jobs_running": {"Jobs currently running", "gauge"},
|
||||||
|
"orca_jobs_failed": {"Jobs that failed", "gauge"},
|
||||||
|
"orca_jobs_complete": {"Jobs completed successfully", "gauge"},
|
||||||
|
"orca_audit_chain_head": {"Audit chain integrity (1=verified)", "gauge"},
|
||||||
|
"orca_drift_events_total": {"Total drift events detected", "counter"},
|
||||||
|
"orca_ssh_errors_total": {"Total SSH errors", "counter"},
|
||||||
|
"orca_txn_apply_total": {"Total transaction applies", "counter"},
|
||||||
|
"orca_txn_rollback_total": {"Total transaction rollbacks", "counter"},
|
||||||
|
"orca_acl_denials_total": {"Total ACL denials (enforce mode)", "counter"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user