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
62
cli/telemetry.go
Normal file
62
cli/telemetry.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// usageJob is the Loki stream job label for homelab usage telemetry.
|
||||
const usageJob = "homelab-usage"
|
||||
|
||||
// emitUsage best-effort records one verb invocation to Loki for cross-user
|
||||
// usage analytics. Labels are low-cardinality (job/user/verb); the line carries
|
||||
// only exit code + CLI version. NEVER args, paths, flags, or secrets. It must
|
||||
// never affect the command: all errors are swallowed and a tight timeout bounds
|
||||
// the cost. Opt out with HOMELAB_TELEMETRY=0.
|
||||
func emitUsage(verb string, runErr error) {
|
||||
switch os.Getenv("HOMELAB_TELEMETRY") {
|
||||
case "0", "off", "false", "no":
|
||||
return
|
||||
}
|
||||
if verb == "" || strings.HasPrefix(verb, "usage") {
|
||||
return // don't self-record the analytics reader
|
||||
}
|
||||
exit := 0
|
||||
if runErr != nil {
|
||||
exit = 1
|
||||
}
|
||||
body, err := json.Marshal(lokiPush{Streams: []lokiStream{{
|
||||
Stream: map[string]string{"job": usageJob, "user": currentUser(), "verb": verb},
|
||||
Values: [][2]string{{
|
||||
strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
"exit=" + strconv.Itoa(exit) + " ver=" + version,
|
||||
}},
|
||||
}}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest("POST", "https://"+lokiHost+"/loki/api/v1/push", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := clientDialingIP(internalLBIP, 800*time.Millisecond).Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
type lokiPush struct {
|
||||
Streams []lokiStream `json:"streams"`
|
||||
}
|
||||
|
||||
type lokiStream struct {
|
||||
Stream map[string]string `json:"stream"`
|
||||
Values [][2]string `json:"values"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue