386 lines
11 KiB
Go
386 lines
11 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
"github.com/tegwick/fluid-core/internal/fitness"
|
||
|
|
"github.com/tegwick/fluid-core/internal/observation"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ---------- pressure ----------
|
||
|
|
|
||
|
|
func runPressure(ctx context.Context, g globals, args []string) error {
|
||
|
|
if len(args) == 0 {
|
||
|
|
return errors.New("pressure needs a subcommand: list, show, analyze, dismiss")
|
||
|
|
}
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
reg := observation.NewPressureRegistry(store, contract.InterfaceID(iface))
|
||
|
|
|
||
|
|
switch args[0] {
|
||
|
|
case "list":
|
||
|
|
fs := newFlagSet("pressure list")
|
||
|
|
status := fs.String("status", "", "filter by status, such as OPEN or DISMISSED")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
list, err := reg.List(ctx, contract.FluidPressureStatus(*status))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(list) == 0 {
|
||
|
|
fmt.Println("no pressure recorded")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "ID\tCLASS\tSEV\tCONF\tCONSUMERS\tSTATUS\tSUMMARY")
|
||
|
|
for _, p := range list {
|
||
|
|
consumers := int64(0)
|
||
|
|
if p.Frequency != nil && p.Frequency.IndependentConsumers != nil {
|
||
|
|
consumers = *p.Frequency.IndependentConsumers
|
||
|
|
}
|
||
|
|
fmt.Fprintf(w, "%s\t%s\t%.2f\t%.2f\t%d\t%s\t%s\n",
|
||
|
|
p.ID, p.Class, unitOf(p.Severity), unitOf(p.Confidence),
|
||
|
|
consumers, p.Status, truncate(oneLine(p.Summary), 60))
|
||
|
|
}
|
||
|
|
return w.Flush()
|
||
|
|
|
||
|
|
case "show":
|
||
|
|
if len(args) < 2 {
|
||
|
|
return errors.New("pressure show needs an id")
|
||
|
|
}
|
||
|
|
p, err := reg.Get(ctx, contract.PressureID(args[1]))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
enc := json.NewEncoder(os.Stdout)
|
||
|
|
enc.SetIndent("", " ")
|
||
|
|
return enc.Encode(p)
|
||
|
|
|
||
|
|
case "analyze":
|
||
|
|
return analyzePressure(ctx, g, store, reg, args[1:])
|
||
|
|
|
||
|
|
case "dismiss":
|
||
|
|
fs := newFlagSet("pressure dismiss")
|
||
|
|
reason := fs.String("reason", "", "why this pressure will not be acted on")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
rest := fs.fs.Args()
|
||
|
|
if len(rest) == 0 {
|
||
|
|
return errors.New("pressure dismiss needs an id")
|
||
|
|
}
|
||
|
|
if *reason == "" {
|
||
|
|
return errors.New("dismissal requires --reason; an unexplained dismissal is not auditable")
|
||
|
|
}
|
||
|
|
|
||
|
|
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
|
||
|
|
if err := reg.SetStatus(ctx, contract.PressureID(rest[0]),
|
||
|
|
contract.FluidPressureStatusDISMISSED, actor, *reason); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("dismissed %s\n", rest[0])
|
||
|
|
return nil
|
||
|
|
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("unknown pressure subcommand %q", args[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// analyzePressure runs the classifier over recorded telemetry.
|
||
|
|
//
|
||
|
|
// Analysis is an explicit command rather than something that happens on ingest.
|
||
|
|
// Blueprint section 30 wants adaptive work to be budgeted and deferrable, and a
|
||
|
|
// classifier that ran on every event would be neither.
|
||
|
|
func analyzePressure(ctx context.Context, g globals, store *evidence.SQLStore, reg *observation.PressureRegistry, args []string) error {
|
||
|
|
fs := newFlagSet("pressure analyze")
|
||
|
|
since := fs.String("since", "168h", "how far back to analyze, as a Go duration")
|
||
|
|
dryRun := fs.Bool("dry-run", false, "report findings without recording them")
|
||
|
|
if err := fs.Parse(args); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
window, err := time.ParseDuration(*since)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("invalid --since: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
||
|
|
InterfaceID: contract.InterfaceID(g.iface),
|
||
|
|
Since: time.Now().Add(-window),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(events) == 0 {
|
||
|
|
fmt.Println("no telemetry in the window; nothing to analyze")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
classifier := observation.NewClassifier(
|
||
|
|
observation.DefaultClassifierOptions(), observation.NewTopologyAnalyzer())
|
||
|
|
findings := classifier.Classify(events)
|
||
|
|
|
||
|
|
if len(findings) == 0 {
|
||
|
|
fmt.Printf("analyzed %d events; no material pressure found\n", len(events))
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "CLASS\tSEV\tCONF\tCONSUMERS\tSUMMARY")
|
||
|
|
for _, f := range findings {
|
||
|
|
fmt.Fprintf(w, "%s\t%.2f\t%.2f\t%d\t%s\n",
|
||
|
|
f.Class, f.Severity, f.Confidence, f.Consumers, truncate(oneLine(f.Summary), 70))
|
||
|
|
}
|
||
|
|
if err := w.Flush(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if *dryRun {
|
||
|
|
fmt.Printf("\n%d finding(s) from %d events; not recorded (--dry-run)\n", len(findings), len(events))
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
recorded, err := reg.RecordAll(ctx, findings)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
fmt.Printf("\nrecorded %d pressure record(s) from %d events\n", len(recorded), len(events))
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- cohorts ----------
|
||
|
|
|
||
|
|
func runCohort(ctx context.Context, g globals, args []string) error {
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
fs := newFlagSet("cohort list")
|
||
|
|
since := fs.String("since", "168h", "how far back to summarize")
|
||
|
|
saltFile := fs.String("salt-file", os.Getenv("FLUID_REDACTION_SALT"), "pseudonymization salt file")
|
||
|
|
if err := fs.Parse(args); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
window, err := time.ParseDuration(*since)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("invalid --since: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
policy, err := loadPolicy(*saltFile)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
||
|
|
InterfaceID: contract.InterfaceID(iface),
|
||
|
|
Since: time.Now().Add(-window),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
pops := observation.NewCohortEngine("unclassified", policy).Populations(events)
|
||
|
|
if len(pops) == 0 {
|
||
|
|
fmt.Println("no cohort activity in the window")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "COHORT\tCONSUMERS\tEVENTS")
|
||
|
|
for _, p := range pops {
|
||
|
|
consumers := fmt.Sprint(p.Consumers)
|
||
|
|
if p.Suppressed {
|
||
|
|
// Reporting the exact count of a tiny cohort identifies individuals.
|
||
|
|
consumers = fmt.Sprintf("<%d (suppressed)", policy.CohortMinimumSize)
|
||
|
|
}
|
||
|
|
fmt.Fprintf(w, "%s\t%s\t%d\n", p.Cohort, consumers, p.Events)
|
||
|
|
}
|
||
|
|
return w.Flush()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- fitness ----------
|
||
|
|
|
||
|
|
func runFitness(ctx context.Context, g globals, args []string) error {
|
||
|
|
if len(args) == 0 || args[0] != "compare" {
|
||
|
|
return errors.New("usage: fluid fitness compare --control R-1 --candidate R-2")
|
||
|
|
}
|
||
|
|
|
||
|
|
iface, err := g.requireInterface()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
store, err := g.open(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer store.Close()
|
||
|
|
|
||
|
|
fs := newFlagSet("fitness compare")
|
||
|
|
control := fs.String("control", "", "control revision")
|
||
|
|
candidate := fs.String("candidate", "", "candidate revision")
|
||
|
|
since := fs.String("since", "168h", "measurement window")
|
||
|
|
target := fs.Float64("target-requests-per-task", 0, "primary target for requests per completed task")
|
||
|
|
latencyGuard := fs.Float64("guard-p95-latency-ms", 0, "p95 latency guardrail")
|
||
|
|
errorGuard := fs.Float64("guard-error-rate", 0, "error rate guardrail")
|
||
|
|
if err := fs.Parse(args[1:]); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if *control == "" || *candidate == "" {
|
||
|
|
return errors.New("fitness compare needs --control and --candidate")
|
||
|
|
}
|
||
|
|
|
||
|
|
window, err := time.ParseDuration(*since)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("invalid --since: %w", err)
|
||
|
|
}
|
||
|
|
start := time.Now().Add(-window)
|
||
|
|
|
||
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
||
|
|
InterfaceID: contract.InterfaceID(iface),
|
||
|
|
Since: start,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
measureWindow := fitness.Window{Start: start}
|
||
|
|
observations := fitness.NewMeasurer().Measure(events, measureWindow)
|
||
|
|
|
||
|
|
// Specs are declared here rather than inferred from the data, so that the
|
||
|
|
// criteria a comparison was judged against are visible in the command that
|
||
|
|
// ran it.
|
||
|
|
var specs []fitness.MetricSpec
|
||
|
|
if *target > 0 {
|
||
|
|
specs = append(specs, fitness.MetricSpec{
|
||
|
|
Name: fitness.MetricRequestsPerTask, Role: fitness.RolePrimary,
|
||
|
|
Direction: fitness.Lower, Target: target,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if *latencyGuard > 0 {
|
||
|
|
specs = append(specs, fitness.MetricSpec{
|
||
|
|
Name: fitness.MetricP95LatencyMS, Role: fitness.RoleGuardrail,
|
||
|
|
Direction: fitness.Lower, Threshold: latencyGuard,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if *errorGuard > 0 {
|
||
|
|
specs = append(specs, fitness.MetricSpec{
|
||
|
|
Name: fitness.MetricErrorRate, Role: fitness.RoleGuardrail,
|
||
|
|
Direction: fitness.Lower, Threshold: errorGuard,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if len(specs) == 0 {
|
||
|
|
return errors.New("no criteria given: pass at least --target-requests-per-task")
|
||
|
|
}
|
||
|
|
|
||
|
|
eval := fitness.NewEvaluator().Evaluate(
|
||
|
|
contract.RevisionID(*control), contract.RevisionID(*candidate),
|
||
|
|
measureWindow, specs, observations)
|
||
|
|
|
||
|
|
fmt.Printf("Fitness: %s vs %s\nWindow: since %s\nVerdict: %s\n\n",
|
||
|
|
eval.Control, eval.Candidate, start.Format(time.RFC3339), eval.Verdict)
|
||
|
|
|
||
|
|
w := out()
|
||
|
|
fmt.Fprintln(w, "METRIC\tROLE\tBASELINE\tCURRENT\tDELTA\tOUTCOME")
|
||
|
|
for _, m := range eval.Metrics {
|
||
|
|
outcome := ""
|
||
|
|
switch {
|
||
|
|
case m.Underpowered:
|
||
|
|
outcome = "underpowered"
|
||
|
|
case m.TargetMet != nil && *m.TargetMet:
|
||
|
|
outcome = "target met"
|
||
|
|
case m.TargetMet != nil:
|
||
|
|
outcome = "target missed"
|
||
|
|
case m.GuardrailBreached != nil && *m.GuardrailBreached:
|
||
|
|
outcome = "BREACHED"
|
||
|
|
case m.GuardrailBreached != nil:
|
||
|
|
outcome = "within guardrail"
|
||
|
|
}
|
||
|
|
fmt.Fprintf(w, "%s\t%s\t%.4g\t%.4g\t%+.4g\t%s\n",
|
||
|
|
m.Name, m.Role, m.Baseline, m.Current, m.Delta, outcome)
|
||
|
|
}
|
||
|
|
if err := w.Flush(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(eval.Reasons) > 0 {
|
||
|
|
fmt.Println()
|
||
|
|
for _, r := range eval.Reasons {
|
||
|
|
fmt.Printf(" %s\n", r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- helpers ----------
|
||
|
|
|
||
|
|
// loadPolicy builds a redaction policy from a salt file.
|
||
|
|
//
|
||
|
|
// It refuses to invent a salt. A generated one would change on every
|
||
|
|
// invocation, so the same consumer would look like a new consumer each time and
|
||
|
|
// every cohort count would be wrong.
|
||
|
|
func loadPolicy(saltFile string) (observation.RedactionPolicy, error) {
|
||
|
|
if saltFile == "" {
|
||
|
|
return observation.RedactionPolicy{}, errors.New(
|
||
|
|
"no redaction salt: pass --salt-file or set FLUID_REDACTION_SALT")
|
||
|
|
}
|
||
|
|
salt, err := os.ReadFile(saltFile)
|
||
|
|
if err != nil {
|
||
|
|
return observation.RedactionPolicy{}, fmt.Errorf("read salt: %w", err)
|
||
|
|
}
|
||
|
|
policy := observation.DefaultRedactionPolicy([]byte(trimSpaceStr(string(salt))))
|
||
|
|
if err := policy.Validate(); err != nil {
|
||
|
|
return observation.RedactionPolicy{}, err
|
||
|
|
}
|
||
|
|
return policy, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func trimSpaceStr(s string) string {
|
||
|
|
start, end := 0, len(s)
|
||
|
|
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\t' || s[start] == '\r') {
|
||
|
|
start++
|
||
|
|
}
|
||
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\t' || s[end-1] == '\r') {
|
||
|
|
end--
|
||
|
|
}
|
||
|
|
return s[start:end]
|
||
|
|
}
|
||
|
|
|
||
|
|
func unitOf(v *contract.UnitInterval) float64 {
|
||
|
|
if v == nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return float64(*v)
|
||
|
|
}
|
||
|
|
|
||
|
|
func truncate(s string, n int) string {
|
||
|
|
if len(s) <= n {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
return s[:n-1] + "…"
|
||
|
|
}
|