Adds the integration layer that turns external sources into provenance-tagged observations without ever executing what it reads. - research folder poller: os.OpenRoot, text allowlist, binary/archive/symlink rejection, content fingerprints, duplicate tracking - git poller: bare shallow mirror with hooks disabled and ext protocol refused, commit observations, co-change pairs - Prometheus poller: threshold crossings and relative-change detection - webhooks: HMAC over timestamp.body, +/-300s window, replay cache, 256 KiB cap, per-IP rate limit; refused entirely without a secret - capabilities read_metrics (allow) and repository.read (ask), scoped to the invoking mind's integrations - migrations 0013, API, UI pages, noemactl integrations, docs/integrations.md
163 lines
5.2 KiB
Go
163 lines
5.2 KiB
Go
package integrations
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/noema/internal/graph"
|
|
"github.com/andydixon/noema/internal/security/httpclient"
|
|
"github.com/andydixon/noema/internal/security/secrets"
|
|
)
|
|
|
|
// PrometheusPoller evaluates instant queries against a Prometheus-compatible
|
|
// API (Prometheus, VictoriaMetrics, Thanos) and emits observations when a
|
|
// threshold is crossed or a value moves by more than the configured change
|
|
// ratio, so steady metrics do not flood cognition.
|
|
type PrometheusPoller struct {
|
|
Secrets *secrets.Resolver
|
|
}
|
|
|
|
type promQuery struct {
|
|
Name string `json:"name"`
|
|
Expr string `json:"expr"`
|
|
Threshold float64 `json:"threshold"`
|
|
Compare string `json:"compare"` // gt | lt | "" (change-only)
|
|
Change float64 `json:"change"` // relative change that counts as notable (default 0.2)
|
|
Unit string `json:"unit"`
|
|
}
|
|
|
|
// Query runs one instant query and returns the scalar value of the first
|
|
// sample (or an error when the result is empty or not numeric).
|
|
func (p PrometheusPoller) Query(ctx context.Context, endpoint, token string, allowPrivate bool, expr string) (float64, map[string]string, error) {
|
|
client := httpclient.New(httpclient.Options{Timeout: 20 * time.Second, AllowPrivate: allowPrivate, MaxBody: 1 << 20})
|
|
u := strings.TrimRight(endpoint, "/") + "/api/v1/query?query=" + url.QueryEscape(expr)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
body, err := httpclient.ReadAll(res.Body, 1<<20)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if res.StatusCode != 200 {
|
|
return 0, nil, fmt.Errorf("HTTP %d from metrics endpoint", res.StatusCode)
|
|
}
|
|
var out struct {
|
|
Status string `json:"status"`
|
|
Data struct {
|
|
ResultType string `json:"resultType"`
|
|
Result []struct {
|
|
Metric map[string]string `json:"metric"`
|
|
Value []any `json:"value"`
|
|
} `json:"result"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil || out.Status != "success" {
|
|
return 0, nil, fmt.Errorf("unexpected metrics response")
|
|
}
|
|
if len(out.Data.Result) == 0 || len(out.Data.Result[0].Value) < 2 {
|
|
return 0, nil, fmt.Errorf("empty result")
|
|
}
|
|
v, err := strconv.ParseFloat(fmt.Sprint(out.Data.Result[0].Value[1]), 64)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("non-numeric result")
|
|
}
|
|
return v, out.Data.Result[0].Metric, nil
|
|
}
|
|
|
|
// Poll implements Poller.
|
|
func (p PrometheusPoller) Poll(ctx context.Context, in Integration, sink *Sink) error {
|
|
endpoint, _ := in.Config["endpoint"].(string)
|
|
allowPrivate, _ := in.Config["allow_private"].(bool)
|
|
token := ""
|
|
if in.SecretRef != "" && p.Secrets != nil {
|
|
var err error
|
|
if token, err = p.Secrets.Resolve(in.SecretRef); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
raw, _ := json.Marshal(in.Config["queries"])
|
|
var queries []promQuery
|
|
json.Unmarshal(raw, &queries) //nolint:errcheck
|
|
last, _ := in.State["last_values"].(map[string]any)
|
|
if last == nil {
|
|
last = map[string]any{}
|
|
}
|
|
host := hostOf(endpoint)
|
|
for _, q := range queries {
|
|
sink.Item()
|
|
v, labels, err := p.Query(ctx, endpoint, token, allowPrivate, q.Expr)
|
|
if err != nil {
|
|
sink.Fail(fmt.Errorf("%s: %w", q.Name, err))
|
|
continue
|
|
}
|
|
prev, had := last[q.Name].(float64)
|
|
last[q.Name] = v
|
|
change := q.Change
|
|
if change <= 0 {
|
|
change = 0.2
|
|
}
|
|
crossed := (q.Compare == "gt" && v > q.Threshold) || (q.Compare == "lt" && v < q.Threshold)
|
|
wasCrossed := had && ((q.Compare == "gt" && prev > q.Threshold) || (q.Compare == "lt" && prev < q.Threshold))
|
|
moved := had && math.Abs(v-prev) > change*math.Max(math.Abs(prev), 1e-9)
|
|
if !had || crossed != wasCrossed || moved {
|
|
sal := 0.4
|
|
verb := "is"
|
|
if crossed && !wasCrossed {
|
|
sal, verb = 0.8, "crossed its threshold at"
|
|
} else if !crossed && wasCrossed {
|
|
sal, verb = 0.6, "returned within threshold at"
|
|
} else if moved {
|
|
sal, verb = 0.55, "changed to"
|
|
}
|
|
instance := labels["instance"]
|
|
text := fmt.Sprintf("metric %s (%s) %s %s%s", q.Name, q.Expr, verb, formatNum(v), q.Unit)
|
|
if had {
|
|
text += fmt.Sprintf(" (was %s%s)", formatNum(prev), q.Unit)
|
|
}
|
|
if instance != "" {
|
|
text += " on " + instance
|
|
}
|
|
ents := []string{host}
|
|
if instance != "" {
|
|
ents = append(ents, strings.Split(instance, ":")[0])
|
|
}
|
|
if _, err := sink.Observe(ctx, Observation{Summary: text, Text: text, Reference: endpoint + " " + q.Expr, ContentType: "text/plain", Salience: sal,
|
|
Metadata: map[string]any{"metric": q.Name, "value": v, "previous": prev, "threshold": q.Threshold, "compare": q.Compare, "instance": instance}, Entities: ents, EntityKind: graph.System}); err != nil {
|
|
sink.Fail(err)
|
|
}
|
|
}
|
|
}
|
|
return sink.Store.SetState(ctx, in.ID, map[string]any{"last_values": last, "last_poll": time.Now().Format(time.RFC3339)})
|
|
}
|
|
|
|
func hostOf(endpoint string) string {
|
|
u, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return endpoint
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func formatNum(v float64) string {
|
|
if math.Abs(v) >= 1000 || v == math.Trunc(v) {
|
|
return strconv.FormatFloat(v, 'f', 0, 64)
|
|
}
|
|
return strconv.FormatFloat(v, 'f', 2, 64)
|
|
}
|