homelab: v0.6.0 — usage telemetry (usage top), evidence-driven verb prioritization
Answers the question that drove the whole CLI — which verbs to add next — with
data instead of one maintainer's habits, and resolves the cross-user-usage ask
in-bounds (no reading anyone's home).
- emit on dispatch: every verb fire-and-forgets one Loki line {job,user,verb} +
"exit=N ver=X". ONLY the verb path + exit code — never args, paths, flags, or
secrets (the emit never sees arguments). Best-effort: 800ms timeout, errors
swallowed, never affects the command; opt-out HOMELAB_TELEMETRY=0. Discovery
verbs (manifest/version/help) and usage itself don't self-record.
- usage top [--since 30d] [--user U] [--json]: ranks verbs via
sum by (verb)(count_over_time({job="homelab-usage"}[…])) against the shared
Loki. Cross-user analytics WITHOUT touching ~/.claude — the privacy-preserving
answer to "what does the team use".
- Loki sink (zero new infra, dogfoods v0.5 logs path); push verified HTTP 204 no
auth. ADR docs/adr/0011.
Live-verified: ran 4 verbs, usage top ranked them correctly (metrics query=2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
666fefd22b
commit
3e3fdb34f0
9 changed files with 215 additions and 4 deletions
77
cli/cmd_usage.go
Normal file
77
cli/cmd_usage.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func usageCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"usage", "top"}, Tier: TierRead,
|
||||
Summary: "rank homelab verb usage across users (from Loki): usage top [--since 30d] [--user U] [--json]", Run: usageTop},
|
||||
}
|
||||
}
|
||||
|
||||
// usageQuery builds the LogQL metric query that counts invocations per verb.
|
||||
func usageQuery(since, user string) string {
|
||||
sel := `job="` + usageJob + `"`
|
||||
if user != "" {
|
||||
sel += `, user="` + user + `"`
|
||||
}
|
||||
return fmt.Sprintf(`sum by (verb) (count_over_time({%s}[%s]))`, sel, since)
|
||||
}
|
||||
|
||||
func usageTop(args []string) error {
|
||||
since := flagValue(args, "--since")
|
||||
if since == "" {
|
||||
since = "30d"
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("query", usageQuery(since, flagValue(args, "--user")))
|
||||
body, err := lbGetBody(lokiHost, "/loki/api/v1/query", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if containsArg(args, "--json") {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
var r struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Value []interface{} `json:"value"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
type row struct {
|
||||
verb string
|
||||
n int
|
||||
}
|
||||
var rows []row
|
||||
for _, s := range r.Data.Result {
|
||||
n := 0
|
||||
if len(s.Value) == 2 {
|
||||
if f, e := strconv.ParseFloat(fmt.Sprint(s.Value[1]), 64); e == nil {
|
||||
n = int(f)
|
||||
}
|
||||
}
|
||||
rows = append(rows, row{s.Metric["verb"], n})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("(no usage recorded yet)")
|
||||
return nil
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].n > rows[j].n })
|
||||
for _, r := range rows {
|
||||
fmt.Printf("%6d %s\n", r.n, r.verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue