181 lines
6.4 KiB
Go
181 lines
6.4 KiB
Go
package alerts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Laya asks the laya model (POST {URL}/predict) what it would do about an
|
|
// alert. It runs in shadow mode: the verdict is recorded on the alert and
|
|
// shown in notifications, but nothing is ever acted on, muted or dropped
|
|
// because of it. A failed call leaves the alert exactly as it was.
|
|
type Laya struct {
|
|
URL string // base URL, e.g. https://laya.k6n.net
|
|
Client *http.Client
|
|
}
|
|
|
|
// LayaActions are the remediations laya may choose between. They are
|
|
// suggestions only; none is wired to an automated action yet.
|
|
var LayaActions = map[string]string{
|
|
"ignore": "transient blip or noise, nobody needs to look at it",
|
|
"restart_deployment": "the service is stuck, leaking or crashed; restarting its pods is likely to clear it",
|
|
"check_capacity": "requests time out or resources are exhausted; check load, connection pools, CPU/memory limits and scaling",
|
|
"fix_dependency": "a database, upstream service or network path this service depends on is failing",
|
|
"investigate_code": "a bug or bad release in this service; needs a code fix or a rollback",
|
|
}
|
|
|
|
var layaUrgency = []string{"cosmetic", "degraded performance", "service outage"}
|
|
|
|
// LayaVerdict is laya's answer, reduced to what a person needs to judge it.
|
|
type LayaVerdict struct {
|
|
Model string `json:"model,omitempty"`
|
|
Action string `json:"action"`
|
|
ActionConfidence float64 `json:"actionConfidence"` // probability of the chosen action
|
|
ActionProbs map[string]float64 `json:"actionProbs,omitempty"`
|
|
Human float64 `json:"human"` // probability a person must intervene
|
|
Urgency string `json:"urgency"`
|
|
UrgencyScore float64 `json:"urgencyScore"` // expected value on 0 (cosmetic) .. 2 (outage)
|
|
UrgencyConf float64 `json:"urgencyConfidence"`
|
|
}
|
|
|
|
func layaRequest(state string) map[string]any {
|
|
return map[string]any{
|
|
"state": state,
|
|
"questions": map[string]any{
|
|
"remediation_action": map[string]any{
|
|
"type": "choice",
|
|
"instructions": "Which operational response fits this production error alert best?",
|
|
"criteria": LayaActions,
|
|
},
|
|
"requires_human_attention": map[string]any{
|
|
"type": "noul",
|
|
"instructions": "Does this alert need a human engineer to intervene, or will it resolve or matter little on its own?",
|
|
"criteria": map[string]string{
|
|
"true": "errors are new or rising well above normal and users or requests are failing",
|
|
"false": "a transient blip, client noise or bot traffic",
|
|
},
|
|
},
|
|
"urgency": map[string]any{
|
|
"type": "score",
|
|
"instructions": "Rate the operational severity of this alert.",
|
|
"criteria": layaUrgency,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type layaAnswer struct {
|
|
Choice string `json:"choice"`
|
|
Probabilities map[string]float64 `json:"probabilities"`
|
|
AnswerConfidence float64 `json:"answer_confidence"`
|
|
Noul float64 `json:"noul"`
|
|
Score float64 `json:"score"`
|
|
Legend map[string]string `json:"legend"`
|
|
}
|
|
|
|
type layaResponse struct {
|
|
Model string `json:"model"`
|
|
Answers map[string]layaAnswer `json:"answers"`
|
|
}
|
|
|
|
// Classify asks laya about the alert. The state is the alert's plain-text
|
|
// message, so the model sees exactly the facts a person would, nothing more.
|
|
func (l *Laya) Classify(ctx context.Context, a Alert) (*LayaVerdict, error) {
|
|
a.Laya = nil
|
|
return l.classifyState(ctx, a.Text(""))
|
|
}
|
|
|
|
func (l *Laya) classifyState(ctx context.Context, state string) (*LayaVerdict, error) {
|
|
raw, err := json.Marshal(layaRequest(state))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(l.URL, "/")+"/predict", bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, errors.New("laya: invalid URL")
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
client := l.Client
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("laya: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
|
return nil, fmt.Errorf("laya: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))
|
|
}
|
|
var lr layaResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
|
return nil, fmt.Errorf("laya: invalid response: %w", err)
|
|
}
|
|
return lr.verdict()
|
|
}
|
|
|
|
func (lr layaResponse) verdict() (*LayaVerdict, error) {
|
|
act, ok1 := lr.Answers["remediation_action"]
|
|
hum, ok2 := lr.Answers["requires_human_attention"]
|
|
urg, ok3 := lr.Answers["urgency"]
|
|
if !ok1 || !ok2 || !ok3 || act.Choice == "" {
|
|
return nil, errors.New("laya: response is missing answers")
|
|
}
|
|
v := &LayaVerdict{Model: lr.Model, Action: act.Choice, ActionConfidence: act.Probabilities[act.Choice], ActionProbs: act.Probabilities,
|
|
Human: hum.Noul, UrgencyScore: urg.Score}
|
|
// The urgency label is the most probable bucket, not the rounded score:
|
|
// a 50/50 cosmetic/outage split must not read as "degraded".
|
|
best := -1.0
|
|
for k, p := range urg.Probabilities {
|
|
if p > best || (p == best && k < v.Urgency) {
|
|
best, v.Urgency = p, k
|
|
}
|
|
}
|
|
v.UrgencyConf = best
|
|
if label, ok := urg.Legend[v.Urgency]; ok {
|
|
v.Urgency = label
|
|
} else if i, err := strconv.Atoi(v.Urgency); err == nil && i >= 0 && i < len(layaUrgency) {
|
|
v.Urgency = layaUrgency[i]
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// Summary is a one-line rendering, e.g.
|
|
// "check_capacity 62% · human 71% · degraded performance 75%".
|
|
func (v LayaVerdict) Summary() string {
|
|
return fmt.Sprintf("%s %s · human %s · %s %s", v.Action, pct(v.ActionConfidence), pct(v.Human), v.Urgency, pct(v.UrgencyConf))
|
|
}
|
|
|
|
// RunnersUp lists the other actions by probability, for judging how close
|
|
// the call was.
|
|
func (v LayaVerdict) RunnersUp() string {
|
|
type kv struct {
|
|
k string
|
|
p float64
|
|
}
|
|
var rest []kv
|
|
for k, p := range v.ActionProbs {
|
|
if k != v.Action {
|
|
rest = append(rest, kv{k, p})
|
|
}
|
|
}
|
|
sort.Slice(rest, func(i, j int) bool { return rest[i].p > rest[j].p })
|
|
parts := make([]string, 0, len(rest))
|
|
for _, r := range rest {
|
|
parts = append(parts, r.k+" "+pct(r.p))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func pct(p float64) string { return strconv.Itoa(int(p*100+0.5)) + "%" }
|