Compare commits

..
2 Commits
Author SHA1 Message Date
seon 13797e5741 feat(ai): redesign the Jev triage state and add JEV_MODE=enforce review rules
第 2 回キャンペーンの突き合わせ結果を反映した。triage の state にコードで計算した
最新値の要約(now: プローブ、1 分 p95、1 分エラー率、再起動数、slo_now_ok)と
直近 5 分のエラーログ抜粋を加え、recovering は slo_now_ok を読むだけにした。
layer の choice を irreversible_needed の noul に置き換え、target_valid は
カタログ action のときだけ尋ねる。JEV_MODE=enforce では review の 2 ルール
(best_action 不一致かつ確信度 0.9 以上、target_valid 0.5 未満)で提案を
escalate に格下げし、判断を res.jev.enforced に記録する。既定は shadow のまま。
2026-10-08 17:45:28 +09:00
seon 795be156d9 fix(harness): require the 1-minute p95 inside the SLO before declaring recovery
キャンペーン第 2 回の C-G1 で、300 ms 遅延が 3 分続いている最中に 5 分窓の p95 平均が
0.375 秒まで薄まり、復旧と判定された。SLO 状態に p95_1m_s / latency_1m_ok を追加し、
recovered の条件に 1 分窓の p95 も含める。
2026-10-08 17:45:28 +09:00
6 changed files with 196 additions and 41 deletions

No files matched your search

+6 -5
View File
@@ -95,11 +95,11 @@ func main() {
HitlURL: env("HITL_URL", "http://hitl:8081"), HitlURL: env("HITL_URL", "http://hitl:8081"),
} }
ag := agent.New(acfg, oai, tr, hc, store, enums, pv) ag := agent.New(acfg, oai, tr, hc, store, enums, pv)
jevMode := env("JEV_MODE", "off") // off | shadow (shadow: Jev answers are recorded next to the LLM diagnosis, never acted on) jevMode := env("JEV_MODE", "off") // off | shadow | enforce (shadow records Jev answers next to the diagnosis; enforce also applies the two review rules)
var jc *jev.Client var jc *jev.Client
if jevMode == "shadow" { if jevMode == "shadow" || jevMode == "enforce" {
jc = jev.New(env("JEV_BASE_URL", ""), env("JEV_MODEL", "jev-1.13.0"), keyReader(env("JEV_KEY_FILE", "/run/secrets/typesafe-api-key"))) jc = jev.New(env("JEV_BASE_URL", ""), env("JEV_MODEL", "jev-1.13.0"), keyReader(env("JEV_KEY_FILE", "/run/secrets/typesafe-api-key")))
ag.EnableShadow(jc) ag.EnableShadow(jc, jevMode == "enforce")
} }
log.Printf("agent up: model=%s prompt=%s labels=%d actions=%d jev=%s", acfg.Model, pv, len(enums.Labels), len(enums.Actions), jevMode) log.Printf("agent up: model=%s prompt=%s labels=%d actions=%d jev=%s", acfg.Model, pv, len(enums.Labels), len(enums.Actions), jevMode)
if jc != nil && !jc.KeyPresent() { if jc != nil && !jc.KeyPresent() {
@@ -173,10 +173,11 @@ func main() {
return return
} }
rev := ag.ShadowReview(c, req, res) rev := ag.ShadowReview(c, req, res)
ag.Dispose(c, req, res)
if tri != nil || rev != nil { if tri != nil || rev != nil {
res.Jev = map[string]any{"mode": "shadow", "triage": tri, "review": rev} res.Jev = map[string]any{"mode": jevMode, "triage": tri, "review": rev}
} }
ag.Enforce(res, rev) // no-op unless JEV_MODE=enforce
ag.Dispose(c, req, res)
ag.SaveShadow(context.Background(), req, res.SessionID, res, tri, rev) ag.SaveShadow(context.Background(), req, res.SessionID, res, tri, rev)
writeJSON(w, 200, res) writeJSON(w, 200, res)
}) })
+12 -11
View File
@@ -36,15 +36,16 @@ type Config struct {
} }
type Agent struct { type Agent struct {
cfg Config cfg Config
oai *openai.Client oai *openai.Client
tools *tools.Runner tools *tools.Runner
harness *harness.Client harness *harness.Client
store *rag.Store store *rag.Store
enums diag.Enums enums diag.Enums
promptV string promptV string
http *http.Client http *http.Client
jev *jev.Client // nil = shadow gates off jev *jev.Client // nil = shadow gates off
jevEnforce bool // JEV_MODE=enforce: review rules may downgrade a proposal to escalate
} }
func New(cfg Config, oai *openai.Client, tr *tools.Runner, hc *harness.Client, store *rag.Store, enums diag.Enums, promptVersion string) *Agent { func New(cfg Config, oai *openai.Client, tr *tools.Runner, hc *harness.Client, store *rag.Store, enums diag.Enums, promptVersion string) *Agent {
@@ -63,8 +64,8 @@ func New(cfg Config, oai *openai.Client, tr *tools.Runner, hc *harness.Client, s
return &Agent{cfg: cfg, oai: oai, tools: tr, harness: hc, store: store, enums: enums, promptV: promptVersion, http: &http.Client{Timeout: 30 * time.Second}} return &Agent{cfg: cfg, oai: oai, tools: tr, harness: hc, store: store, enums: enums, promptV: promptVersion, http: &http.Client{Timeout: 30 * time.Second}}
} }
// EnableShadow turns the Jev shadow gates on (JEV_MODE=shadow). Answers are recorded only. // EnableShadow turns the Jev gates on (JEV_MODE=shadow records only; enforce also applies the review rules).
func (a *Agent) EnableShadow(c *jev.Client) { a.jev = c } func (a *Agent) EnableShadow(c *jev.Client, enforce bool) { a.jev, a.jevEnforce = c, enforce }
// Request is the harness -> AI VM contract (harness alerts.go diagnose()). // Request is the harness -> AI VM contract (harness alerts.go diagnose()).
type Request struct { type Request struct {
+50
View File
@@ -0,0 +1,50 @@
package agent
import (
"testing"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/jev"
)
func f(v float64) *float64 { return &v }
func TestEnforceRules(t *testing.T) {
a := &Agent{jevEnforce: true}
// rule A: best_action disagrees with high confidence -> escalate
res := &Result{}
res.ProposedAction, res.Target = "net.route_change", ""
rev := &ShadowRecord{Gate: "review", Answers: map[string]any{"best_action": jev.Answer{Type: "choice", Choice: "escalate", Confidence: f(0.95)}}}
a.Enforce(res, rev)
if res.ProposedAction != "escalate" || res.Jev["enforced"].(map[string]any)["rule"] != "best_action_disagrees" {
t.Fatalf("rule A: %+v %v", res.Diagnosis, res.Jev)
}
// rule B: target invalid -> escalate
res = &Result{}
res.ProposedAction, res.Target = "k8s.rollout_restart", ""
rev = &ShadowRecord{Gate: "review", Answers: map[string]any{"best_action": jev.Answer{Type: "choice", Choice: "k8s.rollout_restart", Confidence: f(0.9)}, "target_valid": jev.Answer{Type: "noul", Noul: f(0.2)}}}
a.Enforce(res, rev)
if res.ProposedAction != "escalate" || res.Jev["enforced"].(map[string]any)["rule"] != "target_invalid" {
t.Fatalf("rule B: %+v %v", res.Diagnosis, res.Jev)
}
// agreement with valid target -> unchanged; none/escalate -> untouched; shadow mode -> untouched
res = &Result{}
res.ProposedAction, res.Target = "k8s.rollout_restart", "api"
rev = &ShadowRecord{Gate: "review", Answers: map[string]any{"best_action": jev.Answer{Choice: "k8s.rollout_restart", Confidence: f(0.99)}, "target_valid": jev.Answer{Noul: f(0.9)}}}
a.Enforce(res, rev)
if res.ProposedAction != "k8s.rollout_restart" || res.Jev != nil {
t.Fatalf("agreement: %+v", res.Diagnosis)
}
res = &Result{}
res.ProposedAction = "none"
a.Enforce(res, &ShadowRecord{Answers: map[string]any{"best_action": jev.Answer{Choice: "escalate", Confidence: f(0.99)}}})
if res.ProposedAction != "none" {
t.Fatalf("none must not change")
}
s := &Agent{jevEnforce: false}
res = &Result{}
res.ProposedAction = "k8s.scale"
s.Enforce(res, &ShadowRecord{Answers: map[string]any{"best_action": jev.Answer{Choice: "escalate", Confidence: f(0.99)}}})
if res.ProposedAction != "k8s.scale" {
t.Fatalf("shadow must not change")
}
}
+91 -14
View File
@@ -77,26 +77,69 @@ func (a *Agent) ShadowTriage(ctx context.Context, req Request, prefetched string
labels["unknown"] = labelHints["unknown"] labels["unknown"] = labelHints["unknown"]
rec := &ShadowRecord{Gate: "triage"} rec := &ShadowRecord{Gate: "triage"}
rec.state = map[string]any{ rec.state = map[string]any{
"alert": compactAlert(req.Alert), "alert": compactAlert(req.Alert),
"samples_last_10m": prefetched, "now": a.nowSummary(ctx),
"slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%", "samples_last_10m": prefetched,
"recent_error_logs": a.recentErrorLogs(ctx),
"slo": "availability: probe_success = 1; latency: api p95 < 0.5 s; errors: 5xx ratio < 1%",
} }
rec.questions = map[string]jev.Question{ rec.questions = map[string]jev.Question{
"label": {Type: "choice", Instructions: "Which root cause label best explains `alert` together with `samples_last_10m`?", Criteria: labels}, "label": {Type: "choice", Instructions: "Which root cause label best explains `alert`, `now`, `samples_last_10m` and `recent_error_logs`?", Criteria: labels},
"recovering": {Type: "noul", Instructions: "Looking only at the newest values in `samples_last_10m`, is the symptom already gone: probe_success back to 1 and api_p95_latency_s and api_error_ratio inside `slo`?", "recovering": {Type: "noul", Instructions: "Look only at `now`: is `now.slo_now_ok` true, meaning the newest probe, the 1-minute p95 and the 1-minute error ratio are all inside `slo` right now?",
Criteria: map[string]string{"true": "the newest samples are inside the SLO again", "false": "the newest samples still show the failure, or the samples are missing"}}, Criteria: map[string]string{"true": "now.slo_now_ok is true: the symptom is gone in the newest samples", "false": "now.slo_now_ok is false, or the values are missing"}},
"needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (pod logs, Kubernetes objects, VM status, database replication status)?", "needs_investigation": {Type: "noul", Instructions: "To choose a safe remediation action, would an operator need evidence that is not in this state (more logs, Kubernetes objects, VM status, database replication status)?",
Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}}, Criteria: map[string]string{"true": "more evidence is needed before any action", "false": "this state alone is enough to decide the action"}},
"layer": {Type: "choice", Instructions: "Which recovery layer handles this alert?", Criteria: map[string]string{ "irreversible_needed": {Type: "noul", Instructions: "Would fixing the root cause shown in this state require an irreversible or human-only operation: promoting the database replica, repairing a database schema or data, or starting or recreating a stopped VM?",
"layer1": "the platform recovers by itself (ReplicaSet, Argo CD selfHeal, MIG autohealing, compose restart); no action needed", Criteria: map[string]string{"true": "the fix is irreversible or human-only (database promotion, schema or data repair, VM lifecycle)", "false": "the platform recovers by itself or a reversible catalogue action is enough"}},
"layer2": "a reversible catalogue action with human approval is needed",
"layer3": "an irreversible or human-only action is needed (database promotion, data fix, instance lifecycle)",
}},
} }
a.ask(ctx, rec) a.ask(ctx, rec)
return rec return rec
} }
// nowSummary computes the newest values and the SLO verdict in code (Jev reads text literally and should not compare
// numbers or read the tail of a series: jev-1.13 jaggedness notes).
func (a *Agent) nowSummary(ctx context.Context) map[string]any {
q := func(promql string) any {
v, err := a.tools.Instant(ctx, promql)
if err != nil || v != v { // NaN
return nil
}
return v
}
probeALB := q(`probe_success{target="alb"}`)
probeDB := q(`probe_success{target="db-primary"}`)
probeMin1m := q(`min_over_time(probe_success{target="alb"}[1m])`)
probeMin3m := q(`min_over_time(probe_success{target="alb"}[3m])`)
p95 := q(`histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket[1m])))`)
errRatio := q(`sum(rate(demo_http_requests_total{status=~"5.."}[1m])) / sum(rate(demo_http_requests_total[1m]))`)
restarts := q(`sum(increase(kube_pod_container_status_restarts_total{namespace="demo"}[10m]))`)
legacyUp := q(`count(up{job="legacy-api"} == 1)`)
f := func(v any) (float64, bool) {
x, ok := v.(float64)
return x, ok
}
pa, okA := f(probeALB)
pv, okP := f(p95)
ev, okE := f(errRatio)
probeOK := okA && pa >= 1
latOK := !okP || pv < 0.5 // no traffic = inside SLO (same rule as the harness judge)
errOK := !okE || ev < 0.01
return map[string]any{
"probe_alb_now": probeALB, "probe_db_primary_now": probeDB, "probe_alb_min_1m": probeMin1m, "probe_alb_min_3m": probeMin3m,
"api_p95_1m_s": p95, "api_error_ratio_1m": errRatio, "api_restarts_10m": restarts, "legacy_instances_up": legacyUp,
"probe_ok_now": probeOK, "latency_ok_now": latOK, "errors_ok_now": errOK, "slo_now_ok": probeOK && latOK && errOK,
}
}
// recentErrorLogs returns a short, newest-first excerpt of api error lines (5 minutes, 8 lines) for the triage state.
func (a *Agent) recentErrorLogs(ctx context.Context) string {
out := a.tools.Call(ctx, "query_logs", `{"logql":"{namespace=\"demo\", app=\"api\"} |~ \"(?i)error|timeout|refused|SQLSTATE\"","range_minutes":5,"limit":8}`)
if len(out) > 1800 {
out = out[:1800] + "..."
}
return out
}
// ShadowReview asks Jev to check the finished diagnosis against its evidence and the runbook of the proposed action. // ShadowReview asks Jev to check the finished diagnosis against its evidence and the runbook of the proposed action.
// It runs for every diagnosis (also none / escalate) so the comparison covers the whole campaign. // It runs for every diagnosis (also none / escalate) so the comparison covers the whole campaign.
func (a *Agent) ShadowReview(ctx context.Context, req Request, res *Result) *ShadowRecord { func (a *Agent) ShadowReview(ctx context.Context, req Request, res *Result) *ShadowRecord {
@@ -127,19 +170,53 @@ func (a *Agent) ShadowReview(ctx context.Context, req Request, res *Result) *Sha
"rationale": d.Rationale, "proposed_action": d.ProposedAction, "target": d.Target, "harness_target": targetFor(d.ProposedAction, d.Target)}, "rationale": d.Rationale, "proposed_action": d.ProposedAction, "target": d.Target, "harness_target": targetFor(d.ProposedAction, d.Target)},
"runbook": runbook, "runbook": runbook,
} }
catalogue := d.ProposedAction != "" && d.ProposedAction != "none" && d.ProposedAction != "escalate"
rec.questions = map[string]jev.Question{ rec.questions = map[string]jev.Question{
"label_supported": {Type: "noul", Instructions: "Do the items in `diagnosis.evidence` support `diagnosis.root_cause_label`?", "label_supported": {Type: "noul", Instructions: "Do the items in `diagnosis.evidence` support `diagnosis.root_cause_label`?",
Criteria: map[string]string{"true": "the evidence items show the condition that the label describes", "false": "the evidence is about something else, or too thin to support the label"}}, Criteria: map[string]string{"true": "the evidence items show the condition that the label describes", "false": "the evidence is about something else, or too thin to support the label"}},
"runbook_conditions_met": {Type: "noul", Instructions: "Does `diagnosis.evidence` satisfy the conditions written after 'When' or '使う場面' in `runbook` for `diagnosis.proposed_action`? If `runbook` is empty, answer false.", "runbook_conditions_met": {Type: "noul", Instructions: "Does `diagnosis.evidence` satisfy the conditions written after 'When' or '使う場面' in `runbook` for `diagnosis.proposed_action`? If `runbook` is empty, answer false.",
Criteria: map[string]string{"true": "each condition in the runbook is backed by a quoted evidence item", "false": "a condition is not backed by evidence, the evidence contradicts it, or the runbook is empty"}}, Criteria: map[string]string{"true": "each condition in the runbook is backed by a quoted evidence item", "false": "a condition is not backed by evidence, the evidence contradicts it, or the runbook is empty"}},
"target_valid": {Type: "noul", Instructions: "Is `diagnosis.target` a concrete, non-empty object name that the harness can act on for `diagnosis.proposed_action` (a deployment such as api or demo/api, a node name such as ip-10-10-10-11, a VM name such as selfheal-db-replica, or a legacy instance name such as legacy-xxxx)? For proposed_action none or escalate, answer true.",
Criteria: map[string]string{"true": "the target names one existing object of the kind the action needs", "false": "the target is empty, generic, or of the wrong kind"}},
"best_action": {Type: "choice", Instructions: "Given `diagnosis.evidence`, `diagnosis.rationale` and `runbook`, which catalogue action should be proposed? Prefer the smallest action; choose escalate for irreversible changes or weak evidence; choose none when the platform already recovers.", Criteria: actions}, "best_action": {Type: "choice", Instructions: "Given `diagnosis.evidence`, `diagnosis.rationale` and `runbook`, which catalogue action should be proposed? Prefer the smallest action; choose escalate for irreversible changes or weak evidence; choose none when the platform already recovers.", Criteria: actions},
} }
if catalogue {
rec.questions["target_valid"] = jev.Question{Type: "noul", Instructions: "Is `diagnosis.target` a concrete, non-empty object name that the harness can act on for `diagnosis.proposed_action` (a deployment such as api or demo/api, a node name such as ip-10-10-10-11, a VM name such as selfheal-db-replica, or a legacy instance name such as legacy-xxxx)?",
Criteria: map[string]string{"true": "the target names one existing object of the kind the action needs", "false": "the target is empty, generic, or of the wrong kind"}}
}
a.ask(ctx, rec) a.ask(ctx, rec)
return rec return rec
} }
// Enforce applies the two review rules when JEV_MODE=enforce (2026-10-08 campaign-2 report, 3.3). It only ever downgrades
// a catalogue proposal to escalate; none and escalate are never changed. The decision is recorded in res.Jev["enforced"].
func (a *Agent) Enforce(res *Result, rev *ShadowRecord) {
if !a.jevEnforce || res == nil || rev == nil || rev.Answers == nil {
return
}
d := &res.Diagnosis
if d.ProposedAction == "" || d.ProposedAction == "none" || d.ProposedAction == "escalate" {
return
}
var rule, reason string
if ans, ok := rev.Answers["best_action"].(jev.Answer); ok && ans.Choice != "" && ans.Choice != d.ProposedAction && ans.Confidence != nil && *ans.Confidence >= 0.9 {
rule, reason = "best_action_disagrees", fmt.Sprintf("jev best_action=%s (confidence %.2f) != proposed %s", ans.Choice, *ans.Confidence, d.ProposedAction)
}
if rule == "" {
if ans, ok := rev.Answers["target_valid"].(jev.Answer); ok && ans.Noul != nil && *ans.Noul < 0.5 {
rule, reason = "target_invalid", fmt.Sprintf("jev target_valid=%.2f for target %q", *ans.Noul, d.Target)
}
}
if rule == "" {
return
}
log.Printf("jev enforce: %s: %s -> escalate", rule, reason)
if res.Jev == nil {
res.Jev = map[string]any{}
}
res.Jev["enforced"] = map[string]any{"rule": rule, "reason": reason, "from": d.ProposedAction, "to": "escalate"}
d.Rationale = fmt.Sprintf("%s [jev %s: %s]", d.Rationale, rule, reason)
d.ProposedAction = "escalate"
}
func (a *Agent) ask(ctx context.Context, rec *ShadowRecord) { func (a *Agent) ask(ctx context.Context, rec *ShadowRecord) {
c, cancel := context.WithTimeout(ctx, 12*time.Second) c, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel() defer cancel()
+19
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -206,6 +207,24 @@ func (r *Runner) queryMetrics(ctx context.Context, promql string, minutes int64)
return map[string]any{"series_count": len(pr.Data.Result), "series": out, "range_minutes": minutes}, nil return map[string]any{"series_count": len(pr.Data.Result), "series": out, "range_minutes": minutes}, nil
} }
// Instant runs an instant PromQL query and returns the first sample as a float (NaN when the series is absent).
func (r *Runner) Instant(ctx context.Context, promql string) (float64, error) {
q := url.Values{"query": {promql}}
var pr promResp
if err := r.getJSON(ctx, strings.TrimRight(r.cfg.MimirURL, "/")+"/api/v1/query?"+q.Encode(), &pr); err != nil {
return math.NaN(), err
}
if len(pr.Data.Result) == 0 || len(pr.Data.Result[0].Value) != 2 {
return math.NaN(), nil
}
sv, _ := pr.Data.Result[0].Value[1].(string)
f, err := strconv.ParseFloat(sv, 64)
if err != nil {
return math.NaN(), err
}
return f, nil
}
func (r *Runner) probeStatus(ctx context.Context) (any, error) { func (r *Runner) probeStatus(ctx context.Context) (any, error) {
q := url.Values{"query": {"probe_success"}} q := url.Values{"query": {"probe_success"}}
var pr promResp var pr promResp
+18 -11
View File
@@ -47,22 +47,27 @@ func (a *App) mimirScalar(ctx context.Context, expr string) (float64, error) {
// SLOState is one evaluation of experiment design 4.2 (probe 12 consecutive successes = min over 3m; p95 and 5xx ratio over 5m). // SLOState is one evaluation of experiment design 4.2 (probe 12 consecutive successes = min over 3m; p95 and 5xx ratio over 5m).
// Values are nil when the series is absent (JSON has no NaN). // Values are nil when the series is absent (JSON has no NaN).
// 2026-10-08 (campaign 2, C-G1): a 300 ms delay that lasted 3 minutes was declared recovered while still active because the
// 5-minute p95 average diluted it to 0.375 s. The 1-minute p95 (p95_1m_s) must also be inside the SLO.
type SLOState struct { type SLOState struct {
At time.Time `json:"at"` At time.Time `json:"at"`
ProbeMin3m *float64 `json:"probe_min_3m"` ProbeMin3m *float64 `json:"probe_min_3m"`
P95 *float64 `json:"p95_s"` P95 *float64 `json:"p95_s"`
ErrorRatio *float64 `json:"error_ratio"` P95Now *float64 `json:"p95_1m_s"`
ProbeOK bool `json:"probe_ok"` ErrorRatio *float64 `json:"error_ratio"`
LatencyOK bool `json:"latency_ok"` ProbeOK bool `json:"probe_ok"`
ErrorsOK bool `json:"errors_ok"` LatencyOK bool `json:"latency_ok"`
Recovered bool `json:"recovered"` LatencyNowOK bool `json:"latency_1m_ok"`
MissingData []string `json:"missing_data,omitempty"` ErrorsOK bool `json:"errors_ok"`
QueryErrors []string `json:"query_errors,omitempty"` Recovered bool `json:"recovered"`
MissingData []string `json:"missing_data,omitempty"`
QueryErrors []string `json:"query_errors,omitempty"`
} }
const ( const (
qProbeMin3m = `min_over_time(probe_success{job="blackbox",target="alb"}[3m])` qProbeMin3m = `min_over_time(probe_success{job="blackbox",target="alb"}[3m])`
qP95 = `histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket{route=~"orders|price"}[5m])))` qP95 = `histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket{route=~"orders|price"}[5m])))`
qP95Now = `histogram_quantile(0.95, sum by (le) (rate(demo_http_request_duration_seconds_bucket{route=~"orders|price"}[1m])))`
qErrRatio = `sum(rate(demo_http_requests_total{route=~"orders|price",status=~"5.."}[5m])) / sum(rate(demo_http_requests_total{route=~"orders|price"}[5m]))` qErrRatio = `sum(rate(demo_http_requests_total{route=~"orders|price",status=~"5.."}[5m])) / sum(rate(demo_http_requests_total{route=~"orders|price"}[5m]))`
) )
@@ -84,8 +89,10 @@ func (a *App) evaluateSLO(ctx context.Context) SLOState {
st.ProbeOK = st.ProbeMin3m != nil && *st.ProbeMin3m >= 1 // the probe series must exist: no data is not recovery st.ProbeOK = st.ProbeMin3m != nil && *st.ProbeMin3m >= 1 // the probe series must exist: no data is not recovery
st.P95 = get("p95", qP95) st.P95 = get("p95", qP95)
st.LatencyOK = st.P95 == nil || *st.P95 < 0.5 // no traffic in the window (no k6) counts as within SLO st.LatencyOK = st.P95 == nil || *st.P95 < 0.5 // no traffic in the window (no k6) counts as within SLO
st.P95Now = get("p95_1m", qP95Now)
st.LatencyNowOK = st.P95Now == nil || *st.P95Now < 0.5
st.ErrorRatio = get("errors", qErrRatio) st.ErrorRatio = get("errors", qErrRatio)
st.ErrorsOK = st.ErrorRatio == nil || *st.ErrorRatio < 0.01 st.ErrorsOK = st.ErrorRatio == nil || *st.ErrorRatio < 0.01
st.Recovered = st.ProbeOK && st.LatencyOK && st.ErrorsOK && len(st.QueryErrors) == 0 st.Recovered = st.ProbeOK && st.LatencyOK && st.LatencyNowOK && st.ErrorsOK && len(st.QueryErrors) == 0
return st return st
} }