Compare commits

...
Author SHA1 Message Date
mats 7b68f60202 Merge origin/main into feature/issues-tui
Build and Publish / Metadata (push) Successful in 5s
Build and Publish / BuildAndPushAmd64 (push) Failing after 1m42s
2026-11-01 11:40:57 +01:00
matsandClaude Sonnet 5.5 51a8412ae1 Remove Needle; stop paging on nginx 4xx
Needle scored ~chance on the golden set and never gated alerts, so drop the
sidecar, proxy handlers, UI buttons, k8s container and build step. The labelled
eval sets move to cmd/triage-eval/data.

nginx 4xx (registry 401 challenges, /oauth2/auth, scanner 405s) is now dropped
in FilterActionable instead of only being discounted, so it cannot ride along
with a real 5xx. nginx 5xx still alerts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 11:40:53 +01:00
mats 601f382af6 Merge pull request 'eval' (#2) from feature/issues-tui into main
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndPushAmd64 (push) Failing after 1m19s
Reviewed-on: #2
2026-09-27 16:47:09 +02:00
mats 71184e0823 Merge branch 'main' into feature/issues-tui 2026-09-27 16:46:37 +02:00
mats e046bd86d1 eval 2026-09-27 16:45:10 +02:00
39 changed files with 3049 additions and 1208 deletions

No files matched your search

+26 -34
View File
@@ -1,6 +1,9 @@
name: Build and Publish name: Build and Publish
run-name: ${{ gitea.actor }} build 🚀 run-name: ${{ gitea.actor }} build 🚀
on: [push] on:
push:
branches: [main]
workflow_dispatch:
jobs: jobs:
Metadata: Metadata:
@@ -26,7 +29,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
BuildAndDeployAmd64: BuildAndPushAmd64:
needs: Metadata needs: Metadata
runs-on: amd64 runs-on: amd64
steps: steps:
@@ -39,15 +42,32 @@ jobs:
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \ --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \ --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
--progress=plain \ --progress=plain \
-t matst80/otel-debug:${{ needs.Metadata.outputs.version }} \
-t matst80/otel-debug:latest \ -t matst80/otel-debug:latest \
. .
- name: Push amd64 images - name: Push images
run: | run: |
docker push matst80/otel-debug:${{ needs.Metadata.outputs.version }}
docker push matst80/otel-debug:latest docker push matst80/otel-debug:latest
- name: Rollout amd64 otel deployment - name: Reconcile after image is published
env:
GITOPS_WEBHOOK_SECRET: ${{ secrets.GITOPS_WEBHOOK_SECRET }}
run: | run: |
kubectl rollout restart deployment/otel-debug-receiver -n monitoring if [ -z "$GITOPS_WEBHOOK_SECRET" ]; then
kubectl rollout status deployment/otel-debug-receiver -n monitoring echo "GITOPS_WEBHOOK_SECRET must match the k8s-helper Gitea webhook secret" >&2
exit 1
fi
payload="$(mktemp)"
trap 'rm -f "$payload"' EXIT
printf '{"ref":"%s","after":"%s","repository":{"clone_url":"https://git.k6n.net/mats/go-otel.git","ssh_url":"git@git.k6n.net:mats/go-otel.git"}}' \
"$GITHUB_REF" "$GITHUB_SHA" > "$payload"
signature="$(openssl dgst -sha256 -hmac "$GITOPS_WEBHOOK_SECRET" "$payload" | awk '{print $NF}')"
curl --fail --silent --show-error \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: $signature" \
--data-binary "@$payload" \
https://ui.k6n.net/api/gitops/webhooks/gitea
- name: Upload source maps - name: Upload source maps
env: env:
RELEASE: ${{ needs.Metadata.outputs.version }} RELEASE: ${{ needs.Metadata.outputs.version }}
@@ -69,31 +89,3 @@ jobs:
done < <(find "$maps_dir" -type f -name '*.js.map' -print0) done < <(find "$maps_dir" -type f -name '*.js.map' -print0)
rm -rf "$maps_dir" rm -rf "$maps_dir"
[ "$found" = 1 ] || { echo "no source maps found in UI build" >&2; exit 1; } [ "$found" = 1 ] || { echo "no source maps found in UI build" >&2; exit 1; }
# - name: Rollout amd64 deployment (pin to version)
# run: |
# kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-otel-debug-amd64:${{ needs.Metadata.outputs.version }}
# # kubectl rollout status deployment/cart-actor-x86 -n cart
# BuildAndDeployArm64:
# needs: Metadata
# runs-on: arm64
# steps:
# - uses: actions/checkout@v4
# - name: Build arm64 image
# run: |
# docker build \
# --build-arg VERSION=${{ needs.Metadata.outputs.version }} \
# --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
# --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
# --progress=plain \
# -t registry.knatofs.se/go-otel-debug:latest \
# -t registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }} \
# .
# - name: Push arm64 images
# run: |
# docker push registry.knatofs.se/go-otel-debug:latest
# docker push registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }}
# - name: Rollout arm64 deployment (pin to version)
# run: |
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-otel-debug:${{ needs.Metadata.outputs.version }}
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
+2
View File
@@ -0,0 +1,2 @@
{"enabled":true,"dryRun":false,"channels":["telegram"],"lastRun":"2026-09-25T18:56:43.3470721Z","alerts":[{"id":"tlxng1-nginx","at":"2026-09-25T18:31:13.347300133Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:26:13.347300133Z","windowEnd":"2026-09-25T18:31:13.347300133Z","errorCount":69,"baselineMean":14.03448275862069,"excess":4.9655172413793105,"patterns":[{"signature":"3c59646ff131a8ccb8f4d1931f3536112cac2eb59d5a5dbdcbe3f08873d23f0d","count":4,"baselineMean":0.034482758620689655,"excess":3.9655172413793105,"novel":false,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:31:11.346792393Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"0751de83a2f0ce211f2b7ab1a64d8aba","PatternSignature":"3c59646ff131a8ccb8f4d1931f3536112cac2eb59d5a5dbdcbe3f08873d23f0d","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/","Host":"auth.k6n.net","StatusCode":503,"ExceptionType":"","ClientIP":"34.177.93.136","UAClass":"browser","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}},{"signature":"fb485cd240e1dab0261891dc14170112bbc37c90189f276b45935afdedf534cb","count":1,"baselineMean":0,"excess":1,"novel":false,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:31:06.252746363Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"d05925e26f581fcba99d165ea5928114","PatternSignature":"fb485cd240e1dab0261891dc14170112bbc37c90189f276b45935afdedf534cb","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/oauth2/auth","Host":"users.k6n.net","StatusCode":401,"ExceptionType":"","ClientIP":"","UAClass":"","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}}]},"patterns":[{"category":"dependency_unreachable","priority":"high","status":503,"host":"auth.k6n.net","path":"/","services":["nginx"],"routes":[{"ingress":"dev/zitadel-ingress","host":"auth.k6n.net","path":"/","pathType":"Prefix","namespace":"dev","service":"zitadel-dev","port":"8080","deployments":["zitadel-dev"]}],"pods":[{"namespace":"dev","deployment":"zitadel-dev","found":false}],"rule":"status-5xx-upstream","new":false,"count":4,"baseline":0.034482758620689655,"clients":1,"what":"HTTP 503 | /"},{"category":"client_credential_issue","priority":"low","status":401,"host":"users.k6n.net","path":"/oauth2/auth","services":["nginx"],"routes":[{"ingress":"ecommerce/ecommerce-auth","host":"users.k6n.net","path":"/","pathType":"Prefix","namespace":"ecommerce","service":"auth","port":"8080","deployments":["auth"]}],"pods":[{"namespace":"ecommerce","deployment":"auth","pod":"auth-8f497c689-qfpzb","lines":["2026/09/25 18:13:06 oidc: device grant JHMJ-C23E started for client dev-console","2026/09/25 18:13:06 oidc: device grant EQ5R-UWRW approved by mats@tornberg.me","2026/09/25 18:23:19 oidc: device grant RHMN-6257 started for client dev-console"],"found":true}],"rule":"status-401","new":false,"count":1,"baseline":0,"what":"HTTP 401 | /oauth2/auth"}]},{"id":"tlxn9d-nginx","at":"2026-09-25T18:27:13.347226389Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:22:13.347226389Z","windowEnd":"2026-09-25T18:27:13.347226389Z","errorCount":64,"baselineMean":13.931034482758621,"excess":4,"patterns":[{"signature":"ce6a670c8bf24372ec95cd45548edf7c8e219f3b8dcdd37852800956555ac46f","count":4,"baselineMean":0,"excess":4,"novel":false,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:26:26.152736211Z","Service":"nginx","Release":"","Kind":"span","Source":"backend","IsError":true,"StatusClass":"","DurationMS":0,"TraceID":"d1de7de09ad342e7b0670cb78cac1430","PatternSignature":"ce6a670c8bf24372ec95cd45548edf7c8e219f3b8dcdd37852800956555ac46f","IssueFingerprint":"","IsRetry":false,"IsTimeout":false,"IsCancellation":false,"Message":"","SpanName":"","Route":"/","Host":"cc.k6n.net","StatusCode":405,"ExceptionType":"","ClientIP":"35.189.233.235","UAClass":"browser","IsDuplicate":false,"IsRootCause":true,"HasUpstream":false,"TraceServices":["nginx"],"BadActor":""}}]},"patterns":[{"category":"needs_review","priority":"low","status":405,"host":"cc.k6n.net","path":"/","services":["nginx"],"rule":"no-rule","new":false,"count":4,"baseline":0,"clients":1,"what":"HTTP 405 | /"}]},{"id":"tlxn61-nginx","at":"2026-09-25T18:25:13.346661147Z","spike":{"service":"nginx","windowStart":"2026-09-25T18:20:13.346661147Z","windowEnd":"2026-09-25T18:25:13.346661147Z","errorCount":60,"baselineMean":14.03448275862069,"excess":1,"patterns":[{"signature":"ea444cb83ff471aad7f3fab8a5b36dddb25f333f7dce2eb4ff27f6e9bb3969f7","count":1,"baselineMean":0,"excess":1,"novel":true,"clients":1,"services":["nginx"],"example":{"Timestamp":"2026-09-25T18:24:36.217473837Z","Service":"nginx","Release":"","Kind":"span","SourcLine truncated
-4
View File
@@ -5,10 +5,6 @@ RELEASE="${RELEASE:-$(git rev-parse --short HEAD)}"
PUBLIC_SOURCEMAPS="${PUBLIC_SOURCEMAPS:-false}" PUBLIC_SOURCEMAPS="${PUBLIC_SOURCEMAPS:-false}"
UPLOAD_SOURCEMAPS="${UPLOAD_SOURCEMAPS:-true}" UPLOAD_SOURCEMAPS="${UPLOAD_SOURCEMAPS:-true}"
echo "Building Needle 3 sidecar image..."
docker build -t matst80/needle-sidecar:latest cmd/needle-sidecar
docker push matst80/needle-sidecar:latest
echo "Building otel-debug receiver image..." echo "Building otel-debug receiver image..."
docker build \ docker build \
--build-arg "APP_RELEASE=${RELEASE}" \ --build-arg "APP_RELEASE=${RELEASE}" \
+144
View File
@@ -0,0 +1,144 @@
// laya-eval runs alert scenarios through laya and compares its shadow verdict
// with what a person judged the right response to be, so the model can be
// assessed before it is ever allowed to act.
//
// go run ./cmd/laya-eval # built-in scenarios
// go run ./cmd/laya-eval -file alerts.json # a GET /api/alerts dump (no expectations)
// go run ./cmd/laya-eval -send # also deliver each as a TEST alert
//
// -send uses the channels configured by OTEL_ALERT_* (see alerts.ConfigFromEnv).
package main
import (
"context"
_ "embed"
"encoding/json"
"flag"
"fmt"
"os"
"text/tabwriter"
"time"
"git.tornberg.me/go-otel/internal/alerts"
)
//go:embed scenarios.json
var builtin []byte
// Scenario is one alert and the response a person would pick for it.
type Scenario struct {
Name string `json:"name"`
Expect *Expectation `json:"expect,omitempty"`
Alert alerts.Alert `json:"alert"`
}
type Expectation struct {
Action string `json:"action"`
Human bool `json:"human"`
Why string `json:"why"`
}
func main() {
url := flag.String("url", envOr("OTEL_ALERT_LAYA_URL", "https://laya.k6n.net"), "laya base URL")
file := flag.String("file", "", "scenarios JSON, or a GET /api/alerts response (default: built-in scenarios)")
send := flag.Bool("send", false, "deliver each scenario, with its verdict, as a TEST alert to the configured channels")
flag.Parse()
raw := builtin
if *file != "" {
var err error
if raw, err = os.ReadFile(*file); err != nil {
fail(err)
}
}
scenarios, err := parse(raw)
if err != nil {
fail(err)
}
var notifiers []alerts.Notifier
if *send {
cfg := alerts.ConfigFromEnv()
if notifiers = cfg.Notifiers(); len(notifiers) == 0 {
fail(fmt.Errorf("-send: no channel configured (OTEL_ALERT_TELEGRAM_TOKEN + OTEL_ALERT_TELEGRAM_CHAT_ID, OTEL_ALERT_WEBHOOK_URL or OTEL_ALERT_AOD_URL)"))
}
}
laya := &alerts.Laya{URL: *url}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SCENARIO\tLAYA ACTION\tHUMAN\tURGENCY\tEXPECTED\tMATCH")
judged, actionOK, humanOK := 0, 0, 0
for _, s := range scenarios {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
v, err := laya.Classify(ctx, s.Alert)
cancel()
if err != nil {
fmt.Fprintf(tw, "%s\terror: %v\t\t\t\t\n", s.Name, err)
continue
}
exp, match := "-", "-"
if s.Expect != nil {
judged++
a, h := v.Action == s.Expect.Action, (v.Human >= 0.5) == s.Expect.Human
if a {
actionOK++
}
if h {
humanOK++
}
exp = fmt.Sprintf("%s, human=%v", s.Expect.Action, s.Expect.Human)
match = fmt.Sprintf("action %s, human %s", mark(a), mark(h))
}
fmt.Fprintf(tw, "%s\t%s %.0f%%\t%.0f%%\t%s %.0f%%\t%s\t%s\n", s.Name, v.Action, v.ActionConfidence*100, v.Human*100, v.Urgency, v.UrgencyConf*100, exp, match)
if *send {
a := s.Alert
a.Test, a.Laya = true, v
for _, n := range notifiers {
sctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := n.Send(sctx, a); err != nil {
fmt.Fprintf(os.Stderr, "%s: %s: %v\n", s.Name, n.Name(), err)
}
cancel()
}
}
}
tw.Flush()
if judged > 0 {
fmt.Printf("\nagreement over %d judged scenarios: action %d/%d, needs-human %d/%d\n", judged, actionOK, judged, humanOK, judged)
}
}
// parse accepts a scenario list or a raw GET /api/alerts response.
func parse(raw []byte) ([]Scenario, error) {
var list []Scenario
if err := json.Unmarshal(raw, &list); err == nil {
return list, nil
}
var st alerts.Status
if err := json.Unmarshal(raw, &st); err != nil {
return nil, fmt.Errorf("neither a scenario list nor an /api/alerts response: %w", err)
}
for _, a := range st.Alerts {
list = append(list, Scenario{Name: a.ID + " " + a.Spike.Service, Alert: a})
}
return list, nil
}
func mark(ok bool) string {
if ok {
return "ok"
}
return "MISS"
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
FROM python:3.12-slim
# Create non-root user with UID 1000 matching Kubernetes pod securityContext
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m -d /app appuser
WORKDIR /app
ENV HOME=/app
ENV NEEDLE_PORT=5005
ENV NEEDLE_HOST=0.0.0.0
ENV NEEDLE_TELEMETRY=0
# Install dependencies
RUN pip install --no-cache-dir cactus-needle
# Pre-cache model and shared library into /app/.cache as user 1000
RUN python -c "import needle; needle.Needle()"
COPY server.py /app/server.py
# Ensure UID 1000 owns /app and all caches
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 5005
CMD ["python", "/app/server.py"]
-42
View File
@@ -1,42 +0,0 @@
"""Deterministic baseline for the cause categories. Needle has to beat this."""
import re
PROBE = re.compile(r"(^|/)(\.env|\.git|wp-|wp/|wp$|wordpress|blog/wp|phpmyadmin|cgi-bin|\.aws|\.ssh|xmlrpc|userfiles|@fs/|etc/passwd)", re.I)
DEP = re.compile(r"dial|connection refused|eof|no such host|upstream|unavailable|broken pipe|reset by peer", re.I)
CAP = re.compile(r"timeout|deadline exceeded|pool|memory limit|oom|too many|queue full|exhaust", re.I)
BUG = re.compile(r"panic|nil pointer|index out of range|typeerror|referenceerror|deadlock|unhandled|chunkloaderror|segfault", re.I)
LIFE = re.compile(r"healthz|readyz|/ping|retry|cancel|keepalive|vital|\bcls\b|ttfb|fcp|lcp|snapshot|replay|unchanged|session\.load", re.I)
def fields(text):
d = {}
for part in text.split(" | "):
k, _, v = part.partition(": ")
d[k.lower()] = v
return d
def classify(text):
f = fields(text)
status = int(f.get("status") or 0)
route = f.get("route", "")
blob = " ".join([f.get("title", ""), f.get("message", ""), route])
if PROBE.search(route):
return "scanner_probe"
if BUG.search(blob):
return "service_defect"
if CAP.search(blob):
return "capacity_saturation"
if DEP.search(blob):
return "dependency_unreachable"
if status == 401:
return "client_credential_issue"
if status in (502, 503, 504):
return "dependency_unreachable"
if status >= 500:
return "service_defect"
if status == 404:
return "stale_reference"
if LIFE.search(blob) or status in (200, 202, 204, 301, 302, 304, 308, 499) or (status == 0 and f.get("kind") in ("span", "log")):
return "expected_lifecycle"
return "needs_review"
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""Evaluate the Needle sidecar against a reviewed golden set.
Usage: run_eval.py [--url http://127.0.0.1:5005] [--golden eval/golden.jsonl] [--json out.json]
Each golden row: {"id", "expected", "text"}; `expected` is a sidecar action name
including "needs_review" for the fallback.
"""
import argparse, json, os, sys, time, urllib.request
from collections import Counter, defaultdict
HERE = os.path.dirname(os.path.abspath(__file__))
def classify(url, text):
req = urllib.request.Request(
url.rstrip("/") + "/classify",
data=json.dumps({"text": text}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", default=os.environ.get("OTEL_NEEDLE_ADDR", "http://127.0.0.1:5005"))
ap.add_argument("--golden", default=os.path.join(HERE, "golden.jsonl"))
ap.add_argument("--json", help="write per-row results here")
ap.add_argument("--min-conf", type=float, default=0.0,
help="predictions below this confidence count as needs_review")
a = ap.parse_args()
rows = [json.loads(l) for l in open(a.golden) if l.strip()]
results, confusion = [], Counter()
lat = []
for row in rows:
try:
out = classify(a.url, row["text"])
except Exception as e:
print(f"sidecar error on {row['id']}: {e}", file=sys.stderr)
sys.exit(2)
pred = out.get("action", "needs_review")
conf = out.get("confidence", 0.0)
if conf < a.min_conf:
pred = "needs_review"
lat.append(out.get("latency_ms", 0.0))
confusion[(row["expected"], pred)] += 1
results.append({**row, "predicted": pred, "confidence": conf, "ok": pred == row["expected"]})
labels = sorted({r["expected"] for r in results} | {r["predicted"] for r in results})
print(f"{'label':<18}{'prec':>7}{'recall':>8}{'support':>9}")
for l in labels:
tp = confusion[(l, l)]
pp = sum(v for (e, p), v in confusion.items() if p == l)
sup = sum(v for (e, p), v in confusion.items() if e == l)
prec = tp / pp if pp else 0.0
rec = tp / sup if sup else 0.0
print(f"{l:<18}{prec:>7.2f}{rec:>8.2f}{sup:>9}")
acc = sum(r["ok"] for r in results) / len(results)
print(f"\naccuracy {acc:.2%} over {len(results)} rows; "
f"median latency {sorted(lat)[len(lat)//2]:.0f}ms")
for src in sorted({r.get("src", "?") for r in results}):
sub = [r for r in results if r.get("src", "?") == src]
print(f" {src:<11} {sum(r['ok'] for r in sub)}/{len(sub)}")
wrong = [r for r in results if not r["ok"]]
if wrong:
print("\nmisses:")
for r in wrong:
print(f" {r['id']:<8} expected={r['expected']:<16} got={r['predicted']:<16} conf={r['confidence']:.2f}")
if a.json:
json.dump(results, open(a.json, "w"), indent=1)
if __name__ == "__main__":
main()
-240
View File
@@ -1,240 +0,0 @@
#!/usr/bin/env python3
"""
Needle 3 AI Telemetry Triage Sidecar Service
Accepts OTel incident summaries and uses Cactus-Compute/needle3 to classify and extract actionable insights.
"""
import os
import sys
import json
import time
import re
import hashlib
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import needle
PORT = int(os.environ.get("NEEDLE_PORT", 5005))
HOST = os.environ.get("NEEDLE_HOST", "0.0.0.0")
# --- Cause-level triage tools ---
# These deliberately do not restate what the event already carries (status
# code, is_error, severity). Each one is a judgement about *why* it happened
# or *who should care*, which is what an alert reader needs.
@needle.tool
def report_scanner_probe(service: str, path: str, status_code: int):
"""Automated scan or exploit crawler requesting sensitive or nonexistent paths such as .env, wp-admin, .git, phpmyadmin. Applies whatever status the probe received, including 500 or 502."""
return {"action": "scanner_probe", "service": service, "path": path, "status_code": status_code}
@needle.tool
def report_dependency_unreachable(service: str, dependency: str, error_message: str):
"""A downstream or upstream service is down, refusing connections, closed the stream (EOF), or returned upstream unavailable."""
return {"action": "dependency_unreachable", "service": service, "dependency": dependency, "error": error_message}
@needle.tool
def report_client_credential_issue(service: str, route: str, reason: str):
"""A caller lacks a valid or unexpired token, for example an unauthenticated client polling an API or a registry pull without login. The caller's fault, not a fault in this service."""
return {"action": "client_credential_issue", "service": service, "route": route, "reason": reason}
@needle.tool
def report_stale_reference(service: str, path: str):
"""Harmless missing resource caused by an old link, removed asset, deleted commit, or dev tooling request."""
return {"action": "stale_reference", "service": service, "path": path}
@needle.tool
def report_service_defect(service: str, error_message: str):
"""A bug in the service's own code: panic, nil dereference, unhandled exception, index out of range, deadlock, or a 500 from a real application route."""
return {"action": "service_defect", "service": service, "error": error_message}
@needle.tool
def report_capacity_saturation(service: str, resource: str, error_message: str):
"""Resource exhaustion or overload: timeouts, connection pool exhausted, memory limit exceeded, OOM, queue full."""
return {"action": "capacity_saturation", "service": service, "resource": resource, "error": error_message}
@needle.tool
def report_expected_lifecycle(service: str, what: str):
"""Normal behaviour that is not a problem: health and readiness checks, keepalive pings, retries, cancellations, client disconnects, redirects, successful requests, routine info logs, web vitals."""
return {"action": "expected_lifecycle", "service": service, "what": what}
@needle.tool
def report_needs_review(service: str, summary: str):
"""Fallback when none of the other categories clearly applies."""
return {"action": "needs_review", "service": service, "summary": summary}
# --- Redaction: nothing PII-bearing should reach the model or the cache ---
_REDACTIONS = [
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "<email>"),
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "<ip>"),
(re.compile(r"\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b"), "<uuid>"),
(re.compile(r"\beyJ[\w-]+\.[\w-]+\.[\w-]+\b"), "<jwt>"),
(re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
(re.compile(r"([?&][\w.-]+=)[^&\s|]+"), r"\1<v>"),
]
def redact(text: str) -> str:
for rx, sub in _REDACTIONS:
text = rx.sub(sub, text)
return text[:1000]
# Cache keyed on the redacted text: repeated signatures cost nothing.
CACHE_MAX = int(os.environ.get("NEEDLE_CACHE_MAX", 2000))
_cache = {}
_stats = {"hits": 0, "misses": 0}
# Initialize Needle Agent once
print(f"Loading Needle 3 model (Cactus-Compute/needle3)...")
t0 = time.perf_counter()
agent = needle.Needle(tools=[
report_scanner_probe,
report_dependency_unreachable,
report_client_credential_issue,
report_stale_reference,
report_service_defect,
report_capacity_saturation,
report_expected_lifecycle,
report_needs_review,
])
print(f"Needle 3 loaded in {(time.perf_counter() - t0):.2f}s")
class NeedleHandler(BaseHTTPRequestHandler):
def _send_cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Otel-Agent-Token")
def do_OPTIONS(self):
self.send_response(204)
self._send_cors()
self.end_headers()
def do_GET(self):
if self.path == "/health" or self.path == "/api/needle/status":
self.send_response(200)
self._send_cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
resp = {
"status": "ok",
"available": True,
"model": "Cactus-Compute/needle3",
"cache": {**_stats, "size": len(_cache)},
"tools": [
"scanner_probe", "dependency_unreachable", "client_credential_issue", "stale_reference",
"service_defect", "capacity_saturation", "expected_lifecycle", "needs_review"
]
}
self.wfile.write(json.dumps(resp).encode("utf-8"))
else:
self.send_response(404)
self._send_cors()
self.end_headers()
def do_POST(self):
if self.path in ("/classify", "/api/needle/classify"):
content_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_len)
try:
data = json.loads(body) if body else {}
except Exception as e:
self.send_response(400)
self._send_cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
return
text = data.get("text", "").strip()
# If text is not provided, build from structured fields:
if not text:
parts = []
if data.get("service"):
parts.append(f"Service: {data['service']}")
if data.get("kind"):
parts.append(f"Kind: {data['kind']}")
if data.get("title"):
parts.append(f"Title: {data['title']}")
if data.get("message"):
parts.append(f"Message: {data['message']}")
if data.get("url"):
parts.append(f"URL: {data['url']}")
if data.get("status_code"):
parts.append(f"Status: {data['status_code']}")
text = " | ".join(parts)
if not text:
self.send_response(400)
self._send_cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": "Missing 'text' or telemetry fields"}).encode("utf-8"))
return
text = redact(text)
key = hashlib.sha256(text.encode()).hexdigest()
cached = _cache.get(key)
if cached is not None:
_stats["hits"] += 1
out = {**cached, "cached": True}
self.send_response(200)
self._send_cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(out).encode("utf-8"))
return
_stats["misses"] += 1
t_start = time.perf_counter()
agent.reset()
res = agent.run(text, strict=False)
latency_ms = (time.perf_counter() - t_start) * 1000
results = res.get("results", [])
first_result = results[0] if results and isinstance(results[0], dict) else {}
action = first_result.get("action", "needs_review")
calls = res.get("function_calls", [])
tool_name = calls[0].get("name") if calls else "triage_tool"
out = {
"success": True,
"input_text": text,
"action": action,
"tool": tool_name,
"arguments": first_result,
"confidence": float(res.get("confidence", 0.0)),
"reasoning": res.get("reasoning", ""),
"latency_ms": round(latency_ms, 2),
"model": "Cactus-Compute/needle3"
}
if len(_cache) >= CACHE_MAX:
_cache.pop(next(iter(_cache)))
_cache[key] = out
self.send_response(200)
self._send_cors()
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(out).encode("utf-8"))
else:
self.send_response(404)
self._send_cors()
self.end_headers()
def log_message(self, format, *args):
# Concise logging
print(f"[needle-sidecar] {args[0]} {args[1]} -> {args[2]}")
def run():
server = ThreadingHTTPServer((HOST, PORT), NeedleHandler)
print(f"[needle-sidecar] Listening on http://{HOST}:{PORT}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down needle sidecar...")
server.server_close()
if __name__ == "__main__":
run()
-2
View File
@@ -490,8 +490,6 @@ func main() {
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap) uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps))) uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps)))
uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks))) uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
// Protected API & WebSocket routes // Protected API & WebSocket routes
uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory))) uiMux.Handle("/api/history", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleHistory)))
+7 -49
View File
@@ -1,16 +1,13 @@
// spike-alerts replays the deterministic spike rule over a bounded window of // spike-alerts replays the deterministic spike rule over a bounded window of
// normalized_events and, optionally, asks the Needle sidecar what kind of // normalized_events. It only reads ClickHouse.
// failure each top pattern is. It only reads ClickHouse.
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"strings" "strings"
"time" "time"
@@ -36,7 +33,6 @@ func main() {
minExcess := flag.Int("min-excess", 5, "minimum excess errors to alert") minExcess := flag.Int("min-excess", 5, "minimum excess errors to alert")
multiple := flag.Float64("multiple", 3, "pattern must be novel or >= multiple * its baseline") multiple := flag.Float64("multiple", 3, "pattern must be novel or >= multiple * its baseline")
cooldown := flag.Duration("cooldown", 30*time.Minute, "suppress repeats of a pattern for this long unless its count doubles") cooldown := flag.Duration("cooldown", 30*time.Minute, "suppress repeats of a pattern for this long unless its count doubles")
needle := flag.String("needle", "", "Needle sidecar URL (dormant: it is not accurate enough to gate alerts); empty skips it")
showMuted := flag.Bool("show-muted", false, "also report non-actionable categories (probes, stale references, expected lifecycle)") showMuted := flag.Bool("show-muted", false, "also report non-actionable categories (probes, stale references, expected lifecycle)")
asJSON := flag.Bool("json", false, "emit JSON lines instead of text") asJSON := flag.Bool("json", false, "emit JSON lines instead of text")
flag.Parse() flag.Parse()
@@ -75,7 +71,6 @@ func main() {
log.Printf("bad-actor events (ignored for alerts, kept as data): %v of %d", badActors, len(events)) log.Printf("bad-actor events (ignored for alerts, kept as data): %v of %d", badActors, len(events))
log.Printf("loaded %d error events", len(events)) log.Printf("loaded %d error events", len(events))
cache := map[string]classification{}
first := start.Add(time.Duration(*baselineWindows) * *window) first := start.Add(time.Duration(*baselineWindows) * *window)
suppress := features.NewSuppressor(*cooldown) suppress := features.NewSuppressor(*cooldown)
muted := map[triage.Category]int{} muted := map[triage.Category]int{}
@@ -87,24 +82,11 @@ func main() {
} }
for _, spike := range suppress.Filter(detected) { for _, spike := range suppress.Filter(detected) {
fired++ fired++
var cls []classification
for _, p := range spike.Patterns {
c := classification{}
if *needle != "" {
if hit, ok := cache[p.Signature]; ok {
c = hit
} else {
c = classify(*needle, needleText(p.Example))
cache[p.Signature] = c
}
}
cls = append(cls, c)
}
if *asJSON { if *asJSON {
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"spike": spike, "classifications": cls}) _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"spike": spike})
continue continue
} }
printSpike(spike, cls) printSpike(spike)
} }
} }
for c, n := range muted { for c, n := range muted {
@@ -113,28 +95,7 @@ func main() {
log.Printf("%d alert(s) between %s and %s", fired, first.Format(time.RFC3339), end.Format(time.RFC3339)) log.Printf("%d alert(s) between %s and %s", fired, first.Format(time.RFC3339), end.Format(time.RFC3339))
} }
type classification struct { func eventText(e features.Event) string {
Action string `json:"action"`
Confidence float64 `json:"confidence"`
Cached bool `json:"cached"`
Err string `json:"error,omitempty"`
}
func classify(base, text string) classification {
body, _ := json.Marshal(map[string]string{"text": text})
resp, err := (&http.Client{Timeout: 60 * time.Second}).Post(strings.TrimRight(base, "/")+"/classify", "application/json", bytes.NewReader(body))
if err != nil {
return classification{Action: "?", Err: err.Error()}
}
defer resp.Body.Close()
var out classification
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return classification{Action: "?", Err: err.Error()}
}
return out
}
func needleText(e features.Event) string {
parts := []string{"Service: " + e.Service, "Kind: " + e.Kind} parts := []string{"Service: " + e.Service, "Kind: " + e.Kind}
if e.SpanName != "" { if e.SpanName != "" {
parts = append(parts, "Title: "+e.SpanName) parts = append(parts, "Title: "+e.SpanName)
@@ -151,17 +112,14 @@ func needleText(e features.Event) string {
return strings.Join(parts, " | ") return strings.Join(parts, " | ")
} }
func printSpike(s features.Spike, cls []classification) { func printSpike(s features.Spike) {
fmt.Printf("\n%s %s errors=%d (baseline %.1f/window, excess %.0f)\n", s.WindowEnd.Format("01-02 15:04"), s.Service, s.ErrorCount, s.BaselineMean, s.Excess) fmt.Printf("\n%s %s errors=%d (baseline %.1f/window, excess %.0f)\n", s.WindowEnd.Format("01-02 15:04"), s.Service, s.ErrorCount, s.BaselineMean, s.Excess)
for i, p := range s.Patterns { for _, p := range s.Patterns {
tag := "rising" tag := "rising"
if p.Novel { if p.Novel {
tag = "NEW" tag = "NEW"
} }
what := string(alerts.TriageOf(p.Example).Category) what := string(alerts.TriageOf(p.Example).Category)
if cls[i].Action != "" {
what += fmt.Sprintf(" [needle:%s %.2f]", cls[i].Action, cls[i].Confidence)
}
clients := "" clients := ""
if p.Clients > 0 { if p.Clients > 0 {
clients = fmt.Sprintf(" [%d client%s]", p.Clients, map[bool]string{true: "", false: "s"}[p.Clients == 1]) clients = fmt.Sprintf(" [%d client%s]", p.Clients, map[bool]string{true: "", false: "s"}[p.Clients == 1])
@@ -169,7 +127,7 @@ func printSpike(s features.Spike, cls []classification) {
if p.Example.UAClass != "" && uaclass.IsBot(p.Example.UAClass) { if p.Example.UAClass != "" && uaclass.IsBot(p.Example.UAClass) {
clients += " [" + p.Example.UAClass + "]" clients += " [" + p.Example.UAClass + "]"
} }
fmt.Printf(" %-6s x%-4d base %-6.1f %-24s %s%s\n", tag, p.Count, p.BaselineMean, what, needleText(p.Example), clients) fmt.Printf(" %-6s x%-4d base %-6.1f %-24s %s%s\n", tag, p.Count, p.BaselineMean, what, eventText(p.Example), clients)
} }
} }
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -1,7 +1,7 @@
# Error detection model # Error detection model
> Latest status, decisions and next steps: [STATUS.md](STATUS.md). Where they > Latest status, decisions and next steps: [STATUS.md](STATUS.md). Where they
> differ (ClickHouse is now a LoadBalancer; Needle is dormant), STATUS.md wins. > differ (ClickHouse is now a LoadBalancer; Needle is removed), STATUS.md wins.
## Current handoff (2026-09-18) ## Current handoff (2026-09-18)
+13 -22
View File
@@ -10,7 +10,6 @@ unless stated. Greenfield, single user: radical changes are fine.
patched live. `docs/ML.md` still says "keep ClusterIP"; that is superseded. patched live. `docs/ML.md` still says "keep ClusterIP"; that is superseded.
- Credentials are in `.env` (gitignored, mode 600). Load with - Credentials are in `.env` (gitignored, mode 600). Load with
`set -a; . ./.env; set +a`. `clickhouse-client` is installed locally. `set -a; . ./.env; set +a`. `clickhouse-client` is installed locally.
- `.env` deliberately does not enable Needle; pass `-needle URL` explicitly.
## What exists now ## What exists now
@@ -21,7 +20,7 @@ unless stated. Greenfield, single user: radical changes are fine.
| 404/410 are not errors | `isErrorStatus` in `internal/normalization/normalizer.go` | Done. Rows stored before this still count 404s as errors. | | 404/410 are not errors | `isErrorStatus` in `internal/normalization/normalizer.go` | Done. Rows stored before this still count 404s as errors. |
| Backfill | `cmd/normalized-backfill` | Ran 2026-09-19 12:00 to ~21:00 UTC. Queries need `FINAL` (duplicates until merge). | | Backfill | `cmd/normalized-backfill` | Ran 2026-09-19 12:00 to ~21:00 UTC. Queries need `FINAL` (duplicates until merge). |
| Spike rule | `internal/features/spike.go` | Excess-error rule, trimmed-mean baseline, `Suppressor` (cooldown, 2x escalation). Tested. | | Spike rule | `internal/features/spike.go` | Excess-error rule, trimmed-mean baseline, `Suppressor` (cooldown, 2x escalation). Tested. |
| Replay CLI | `cmd/spike-alerts` | Replays a range; mutes non-actionable categories; Needle optional and off. | | Replay CLI | `cmd/spike-alerts` | Replays a range; mutes non-actionable categories. |
| Cause categories | `internal/triage` | Deterministic rules, first match wins. Tested. | | Cause categories | `internal/triage` | Deterministic rules, first match wins. Tested. |
| Rules scoring | `cmd/triage-eval` | Scores a labelled JSONL; also prepares rows for labelling. | | Rules scoring | `cmd/triage-eval` | Scores a labelled JSONL; also prepares rows for labelling. |
| Offender report | `cmd/bad-actors` | Read-only. Ranks IPs from raw nginx spans, marks `[BLOCK]` / `[INTERNAL]`. | | Offender report | `cmd/bad-actors` | Read-only. Ranks IPs from raw nginx spans, marks `[BLOCK]` / `[INTERNAL]`. |
@@ -35,14 +34,14 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
## Findings worth remembering ## Findings worth remembering
- **Needle scored 10% on the 40-row golden set** (about chance for 8 classes). - **Needle was removed (2026-11-01).** It scored 10% on the 40-row golden set (about
Reversing the tool order changed its answers wholesale (15%, 34/40 `service_defect`), chance for 8 classes), picked by tool position rather than content, and its
so it picks by position, not content. Confidence is uncorrelated with confidence was uncorrelated with correctness. Sidecar, proxy handlers, UI and k8s
correctness. Decision: **Needle stays dormant.** Intended later use: a gate that container are gone; the labelled sets live in `cmd/triage-eval/data/`.
decides whether a larger model should act on an issue (affects anyone? fixable by - **Edge 4xx never pages.** nginx 4xx (registry 401 challenges, `/oauth2/auth`,
a smart model?). Do not use it for categorization. scanner 405s) is dropped in `FilterActionable`; nginx 5xx still alerts.
- **Rules scored 39/40 on the golden set, but that is a fit** (written after seeing - **Rules scored 39/40 on the golden set, but that is a fit** (written after seeing
the rows). A fresh 60-row holdout is in `cmd/needle-sidecar/eval/holdout.jsonl` the rows). A fresh 60-row holdout is in `cmd/triage-eval/data/holdout.jsonl`
(from 2026-09-18, unseen); it still needs your `expected` labels. Known (from 2026-09-18, unseen); it still needs your `expected` labels. Known
probe-like paths that still land in `stale_reference`: `/ssl/server.key`, probe-like paths that still land in `stale_reference`: `/ssl/server.key`,
`/_debugbar/open`, `/debug/pprof`, `/actuator/*`, `/graphql`, `/server-info`, `/_debugbar/open`, `/debug/pprof`, `/actuator/*`, `/graphql`, `/server-info`,
@@ -78,7 +77,7 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
1. Deploy the collector so normalized rows stay current; verify with 1. Deploy the collector so normalized rows stay current; verify with
`SELECT max(timestamp) FROM otel.normalized_events`. `SELECT max(timestamp) FROM otel.normalized_events`.
2. Label `holdout.jsonl` (fill `expected`), run 2. Label `holdout.jsonl` (fill `expected`), run
`go run ./cmd/triage-eval -input cmd/needle-sidecar/eval/holdout.jsonl`, fix `go run ./cmd/triage-eval -input cmd/triage-eval/data/holdout.jsonl`, fix
the rule families that miss. Then re-export a fresh holdout so scoring stays the rule families that miss. Then re-export a fresh holdout so scoring stays
honest. honest.
3. Warm-up guard in `DetectSpikes`: require baseline data before alerting (the 3. Warm-up guard in `DetectSpikes`: require baseline data before alerting (the
@@ -164,7 +163,7 @@ Categories: `scanner_probe`, `dependency_unreachable`, `client_credential_issue`
```bash ```bash
set -a; . ./.env; set +a set -a; . ./.env; set +a
# replay the spike rule over a range (Needle off) # replay the spike rule over a range
go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00Z -baseline-windows 12 go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00Z -baseline-windows 12
# add -show-muted to see probes / stale / lifecycle patterns too # add -show-muted to see probes / stale / lifecycle patterns too
@@ -172,12 +171,7 @@ go run ./cmd/spike-alerts -since 2026-09-19T12:00:00Z -until 2026-09-19T20:30:00
go run ./cmd/bad-actors -since 2026-09-19T08:00:00Z -until 2026-09-19T20:30:00Z go run ./cmd/bad-actors -since 2026-09-19T08:00:00Z -until 2026-09-19T20:30:00Z
# score the rules against labelled rows # score the rules against labelled rows
go run ./cmd/triage-eval -input cmd/needle-sidecar/eval/holdout.jsonl go run ./cmd/triage-eval -input cmd/triage-eval/data/holdout.jsonl
# Needle eval (needs the sidecar running; venv was in scratch and is gone)
pip install cactus-needle # python 3.12 recommended
NEEDLE_PORT=5005 NEEDLE_HOST=127.0.0.1 python cmd/needle-sidecar/server.py
python cmd/needle-sidecar/eval/run_eval.py
``` ```
Backfill in small windows only (10 to 30 minutes over the LAN); one-hour windows Backfill in small windows only (10 to 30 minutes over the LAN); one-hour windows
@@ -187,14 +181,11 @@ through a `kubectl port-forward` hung and dropped.
Everything is uncommitted. Files changed by this work: `internal/{storage, Everything is uncommitted. Files changed by this work: `internal/{storage,
normalization,features,triage,honeypot,blocker}`, `cmd/{spike-alerts, normalization,features,triage,honeypot,blocker}`, `cmd/{spike-alerts,
triage-eval,bad-actors,normalized-backfill,server,needle-sidecar}`, triage-eval,bad-actors,normalized-backfill,server}`,
`k8s/{clickhouse,deployment}.yaml`, `.gitignore`. `k8s/{clickhouse,deployment}.yaml`, `.gitignore`.
**Not from this work; check before committing:** `AGENTS.md`, **Not from this work; check before committing:** `AGENTS.md`,
`internal/collector/{collector_test,issue_extract}.go`, `otel-ui/src/**` `internal/collector/{collector_test,issue_extract}.go`, `otel-ui/src/**` (including a deleted `GaugeMeter.tsx`).
(including a deleted `GaugeMeter.tsx`). Also tracked `__pycache__/*.pyc` files
under `cmd/needle-sidecar` show as modified; they should be untracked and
gitignored.
## IP details (MaxMind) ## IP details (MaxMind)
+29
View File
@@ -0,0 +1,29 @@
# Deploying go-otel with k8s-helper
Gitea Actions builds and pushes versioned and `latest` `matst80/otel-debug`
images on pushes to `main`, uploads the matching source maps, and then sends a
signed reconcile webhook. The agent applies the explicit `k8s/kustomization.yaml`
resource list into namespace `monitoring`.
## One-time setup
The k8s-helper GitOps CRDs and agent must be installed. The GitSource reuses
`dev/gitea-k8s-helper`, whose token must have read access to this repository.
Configure a Gitea push-only webhook for this repo at
`https://ui.k6n.net/api/gitops/webhooks/gitea`, using the same secret as
`K8SHELPER_GITOPS_WEBHOOK_SECRET` on the dashboard. Add that value as the Gitea
Actions secret `GITOPS_WEBHOOK_SECRET` too; a user- or organization-level
secret can be shared by other repos.
The runner uses its existing Docker login. Bootstrap once with
`kubectl apply -f gitops/` after confirming the monitoring namespace and the
`otel-sourcemaps` PVC already exist. The PVC is deliberately excluded from the
managed kustomization because its lifecycle was called out for separate review.
The ClickHouse manifests, auth-dev resources, and ingress-catchall work are also
excluded; this kustomization lists only the receiver Deployment, Service, and
Ingress resources. Do not run Flux and k8s-helper against the same resources at
the same time; check for an active `flux-system/go-otel` Kustomization before
bootstrapping this controller.
Prune is initially disabled. Inspect the GitOps inventory and applied revision
before changing it.
+14
View File
@@ -0,0 +1,14 @@
apiVersion: gitops.k6n.net/v1alpha1
kind: Deployment
metadata:
name: go-otel
namespace: dev
spec:
sourceRef:
kind: GitSource
name: go-otel
path: ./k8s
targetNamespace: monitoring
prune: false
wait: true
deletionPolicy: Orphan
+16
View File
@@ -0,0 +1,16 @@
# Keep GitOps CRs outside the path they manage.
apiVersion: gitops.k6n.net/v1alpha1
kind: GitSource
metadata:
name: go-otel
namespace: dev
spec:
url: https://git.k6n.net/mats/go-otel.git
ref:
branch: main
interval: 5m
secretRef:
name: gitea-k8s-helper
ignore: |
.gitea/
docs/
-36
View File
@@ -141,45 +141,9 @@
Clear Logs Clear Logs
</button> </button>
</div> </div>
<div class="data-section" style="border-left: 4px solid #10b981;">
<h2 style="border-bottom-color: #10b981; color: #065f46;">Needle 3 AI Telemetry Triage</h2>
<p style="font-size: 13px; color: #666; margin-top: 0;">Test on-device telemetry classification with Cactus-Compute/needle3:</p>
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
<input id="needle-input" type="text" style="flex: 1; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;" value="User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1" />
<button id="needle-btn" style="background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 4px; font-weight: bold; cursor: pointer;" onclick="classifyWithNeedle()">
Classify with Needle 3
</button>
</div>
<div id="needle-result" class="data-content" style="background: #ecfdf5; border: 1px solid #a7f3d0; min-height: 40px; color: #064e3b;">
Click "Classify with Needle 3" to test the sidecar...
</div>
</div>
</div> </div>
<script> <script>
async function classifyWithNeedle() {
const input = document.getElementById("needle-input").value;
const resultDiv = document.getElementById("needle-result");
const btn = document.getElementById("needle-btn");
if (!input.trim()) return;
btn.textContent = "Classifying...";
btn.disabled = true;
try {
const resp = await fetch("/api/needle/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: input }),
});
const data = await resp.json();
resultDiv.innerHTML = `<pre style="margin: 0; font-family: monospace;">${JSON.stringify(data, null, 2)}</pre>`;
} catch (err) {
resultDiv.textContent = "Error: " + err;
} finally {
btn.textContent = "Classify with Needle 3";
btn.disabled = false;
}
}
let ws; let ws;
let tracesCount = 0; let tracesCount = 0;
let metricsCount = 0; let metricsCount = 0;
+8 -1
View File
@@ -11,6 +11,13 @@ func TriageOf(e features.Event) triage.Result {
IsTimeout: e.IsTimeout, IsCancellation: e.IsCancellation, IsRetry: e.IsRetry}) IsTimeout: e.IsTimeout, IsCancellation: e.IsCancellation, IsRetry: e.IsRetry})
} }
// edgeClientError reports a 4xx seen at the nginx edge: registry auth
// challenges, /oauth2/auth subrequests, 405s from scanners. These are the
// caller's problem and never page, even next to a real 5xx.
func edgeClientError(e features.Event) bool {
return e.Service == "nginx" && e.StatusCode >= 400 && e.StatusCode < 500
}
// FilterActionable keeps only patterns whose category should page a human // FilterActionable keeps only patterns whose category should page a human
// (unless keepMuted), recomputing each spike's excess so a probe storm cannot // (unless keepMuted), recomputing each spike's excess so a probe storm cannot
// alert by itself. muted, when non-nil, counts what was dropped per category. // alert by itself. muted, when non-nil, counts what was dropped per category.
@@ -21,7 +28,7 @@ func FilterActionable(spikes []features.Spike, keepMuted bool, muted map[triage.
sp.Excess = 0 sp.Excess = 0
for _, p := range sp.Patterns { for _, p := range sp.Patterns {
c := TriageOf(p.Example).Category c := TriageOf(p.Example).Category
if !keepMuted && !c.Actionable() { if !keepMuted && (!c.Actionable() || edgeClientError(p.Example)) {
if muted != nil { if muted != nil {
muted[c]++ muted[c]++
} }
+180
View File
@@ -0,0 +1,180 @@
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)) + "%" }
+123
View File
@@ -0,0 +1,123 @@
package alerts
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.tornberg.me/go-otel/internal/features"
)
// layaReply is the shape laya.k6n.net/predict returned when probed.
const layaReply = `{"model":"laya-rl-agent","answers":{
"remediation_action":{"type":"choice","choice":"check_capacity","probabilities":{"ignore":0.1,"restart_deployment":0.12,"check_capacity":0.62,"fix_dependency":0.1,"investigate_code":0.06},"confidence":0.3,"answer_confidence":0.62},
"requires_human_attention":{"type":"noul","noul":0.7123,"confidence":0.7123,"answer_confidence":0.7123},
"urgency":{"type":"score","score":1.03,"legend":{"0":"cosmetic","1":"degraded performance","2":"service outage"},"probabilities":{"0":0.11,"1":0.75,"2":0.14},"confidence":0.32,"answer_confidence":0.75}},
"usage":{"input_tokens":141,"output_tokens":0}}`
func layaServer(t *testing.T, status int, body string, got *map[string]any) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/predict" {
t.Errorf("path %q", r.URL.Path)
}
if got != nil {
_ = json.NewDecoder(r.Body).Decode(got)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func timeoutAlert() Alert {
now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
return NewAlert(features.Spike{Service: "codesearch", WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, ErrorCount: 15, BaselineMean: 0, Excess: 15,
Patterns: []features.PatternDelta{{Signature: "t", Count: 15, Excess: 15, Novel: true,
Example: features.Event{Service: "codesearch", Kind: "span", SpanName: "upload", Message: "context deadline exceeded", IsTimeout: true}}}}, now)
}
func TestLayaClassifySendsAlertTextAndParsesVerdict(t *testing.T) {
var req map[string]any
srv := layaServer(t, 200, layaReply, &req)
a := timeoutAlert()
v, err := (&Laya{URL: srv.URL + "/"}).Classify(context.Background(), a)
if err != nil {
t.Fatal(err)
}
if req["state"] != a.Text("") {
t.Fatalf("state is not the alert text:\n%v", req["state"])
}
qs := req["questions"].(map[string]any)
for _, k := range []string{"remediation_action", "requires_human_attention", "urgency"} {
if qs[k] == nil {
t.Fatalf("question %s missing", k)
}
}
if v.Action != "check_capacity" || v.ActionConfidence != 0.62 || v.Human != 0.7123 || v.Urgency != "degraded performance" || v.UrgencyConf != 0.75 {
t.Fatalf("verdict %+v", v)
}
if got := v.Summary(); got != "check_capacity 62% · human 71% · degraded performance 75%" {
t.Fatalf("summary %q", got)
}
if got := v.RunnersUp(); !strings.HasPrefix(got, "restart_deployment 12%, ") {
t.Fatalf("runners-up %q", got)
}
}
func TestLayaErrors(t *testing.T) {
for name, tc := range map[string]struct {
status int
body string
}{
"http": {502, "bad gateway"},
"json": {200, "not json"},
"missing": {200, `{"answers":{"urgency":{}}}`},
} {
srv := layaServer(t, tc.status, tc.body, nil)
if _, err := (&Laya{URL: srv.URL}).Classify(context.Background(), timeoutAlert()); err == nil {
t.Errorf("%s: want error", name)
}
}
}
func TestLayaVerdictInNotifications(t *testing.T) {
a := timeoutAlert()
a.Laya = &LayaVerdict{Action: "restart_deployment", ActionConfidence: 0.8, ActionProbs: map[string]float64{"restart_deployment": 0.8, "ignore": 0.2}, Human: 0.4, Urgency: "service outage", UrgencyConf: 0.6}
h := a.HTML("https://otel.example", telegramMax)
if !strings.Contains(h, "🤖 <b>laya</b> <i>(shadow, no action taken)</i>: <code>restart_deployment</code> 80% · human 40% · service outage 60%") ||
!strings.Contains(h, "other: ignore 20%") {
t.Fatalf("html:\n%s", h)
}
if txt := a.Text(""); !strings.Contains(txt, "laya (shadow, no action taken): restart_deployment 80%") {
t.Fatalf("text:\n%s", txt)
}
a.Laya = nil
if strings.Contains(a.HTML("", telegramMax), "laya") {
t.Fatal("no verdict must render no laya line")
}
}
// A laya outage must never cost a page; the alert just goes out without a verdict.
func TestRunnerAttachesVerdictAndSurvivesLayaFailure(t *testing.T) {
for _, tc := range []struct {
status int
want bool
}{{200, true}, {500, false}} {
srv := layaServer(t, tc.status, layaReply, nil)
f := &fake{}
r := newRunner(nil, f)
r.laya = &Laya{URL: srv.URL}
r.deliver(context.Background(), timeoutAlert())
if len(f.sent) != 1 {
t.Fatalf("status %d: sent %d", tc.status, len(f.sent))
}
if got := f.sent[0].Laya != nil; got != tc.want {
t.Fatalf("status %d: verdict attached=%v", tc.status, got)
}
}
}
+13
View File
@@ -29,6 +29,9 @@ type Alert struct {
Spike features.Spike `json:"spike"` Spike features.Spike `json:"spike"`
Patterns []PatternReport `json:"patterns"` Patterns []PatternReport `json:"patterns"`
Test bool `json:"test,omitempty"` Test bool `json:"test,omitempty"`
// Laya is the laya model's shadow verdict, when it was asked. Advisory
// only: nothing is acted on or suppressed because of it.
Laya *LayaVerdict `json:"laya,omitempty"`
} }
// PatternReport is a spike pattern with its triage verdict, ready to show. // PatternReport is a spike pattern with its triage verdict, ready to show.
@@ -214,6 +217,9 @@ func (a Alert) Text(baseURL string) string {
window := a.Spike.WindowEnd.Sub(a.Spike.WindowStart).Round(time.Minute) window := a.Spike.WindowEnd.Sub(a.Spike.WindowStart).Round(time.Minute)
fmt.Fprintf(&b, "%s: %s is failing (%d errors in the last %s, normally about %.0f)\n", fmt.Fprintf(&b, "%s: %s is failing (%d errors in the last %s, normally about %.0f)\n",
prefix, a.Spike.Service, a.Spike.ErrorCount, window, a.Spike.BaselineMean) prefix, a.Spike.Service, a.Spike.ErrorCount, window, a.Spike.BaselineMean)
if a.Laya != nil {
fmt.Fprintf(&b, "laya (shadow, no action taken): %s\n", a.Laya.Summary())
}
base := strings.TrimRight(baseURL, "/") base := strings.TrimRight(baseURL, "/")
for i, p := range a.Patterns { for i, p := range a.Patterns {
fmt.Fprintf(&b, "\n%d. %s\n", i+1, p.What) fmt.Fprintf(&b, "\n%d. %s\n", i+1, p.What)
@@ -282,6 +288,13 @@ func (a Alert) HTML(baseURL string, max int) string {
if base != "" { if base != "" {
foot = fmt.Sprintf("\n<a href=\"%s\">All issues</a>", esc(base+"/issues")) foot = fmt.Sprintf("\n<a href=\"%s\">All issues</a>", esc(base+"/issues"))
} }
if a.Laya != nil {
head += fmt.Sprintf("🤖 <b>laya</b> <i>(shadow, no action taken)</i>: <code>%s</code> %s · human %s · %s %s\n",
esc(a.Laya.Action), pct(a.Laya.ActionConfidence), pct(a.Laya.Human), esc(a.Laya.Urgency), pct(a.Laya.UrgencyConf))
if r := a.Laya.RunnersUp(); r != "" {
head += "<i> other: " + esc(r) + "</i>\n"
}
}
var b strings.Builder var b strings.Builder
b.WriteString(head) b.WriteString(head)
for i, p := range a.Patterns { for i, p := range a.Patterns {
+28
View File
@@ -34,6 +34,9 @@ type Config struct {
Muted func(issueID string) bool Muted func(issueID string) bool
WebhookURL, WebhookSecret string WebhookURL, WebhookSecret string
AODURL string AODURL string
// LayaURL, when set, asks laya (POST {LayaURL}/predict) for a shadow
// verdict on every alert and shows it in the notification.
LayaURL string
} }
func ConfigFromEnv() Config { func ConfigFromEnv() Config {
@@ -54,6 +57,7 @@ func ConfigFromEnv() Config {
WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"), WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"),
WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"), WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"),
AODURL: os.Getenv("OTEL_ALERT_AOD_URL"), AODURL: os.Getenv("OTEL_ALERT_AOD_URL"),
LayaURL: os.Getenv("OTEL_ALERT_LAYA_URL"),
} }
} }
@@ -106,6 +110,7 @@ type Runner struct {
dataSince func(ctx context.Context) time.Time dataSince func(ctx context.Context) time.Time
notifiers []Notifier notifiers []Notifier
resolver Resolver // optional: ingress -> service -> deployment for edge errors resolver Resolver // optional: ingress -> service -> deployment for edge errors
laya *Laya // optional: shadow verdict attached to each alert
sup *features.Suppressor sup *features.Suppressor
mu sync.Mutex mu sync.Mutex
@@ -137,6 +142,10 @@ func Start(ctx context.Context, cfg Config, conn driver.Conn, database string, r
}, },
dataSince: func(ctx context.Context) time.Time { return DataSince(ctx, conn, database) }, dataSince: func(ctx context.Context) time.Time { return DataSince(ctx, conn, database) },
} }
if cfg.LayaURL != "" {
r.laya = &Laya{URL: cfg.LayaURL}
log.Printf("alerts: laya shadow verdicts from %s", cfg.LayaURL)
}
names := make([]string, 0, len(notifiers)) names := make([]string, 0, len(notifiers))
for _, n := range notifiers { for _, n := range notifiers {
names = append(names, n.Name()) names = append(names, n.Name())
@@ -239,6 +248,7 @@ func (r *Runner) deliver(ctx context.Context, a Alert) {
if pr, ok := r.resolver.(PodResolver); ok { if pr, ok := r.resolver.(PodResolver); ok {
a.EnrichPods(ctx, pr) a.EnrichPods(ctx, pr)
} }
r.askLaya(ctx, &a)
r.mu.Lock() r.mu.Lock()
r.recent = append(r.recent, a) r.recent = append(r.recent, a)
if len(r.recent) > maxRecent { if len(r.recent) > maxRecent {
@@ -261,6 +271,23 @@ func (r *Runner) deliver(ctx context.Context, a Alert) {
} }
} }
// askLaya attaches laya's shadow verdict. It is bounded so a slow model
// delays a page by seconds at most, and a failure only costs the verdict.
func (r *Runner) askLaya(ctx context.Context, a *Alert) {
if r.laya == nil {
return
}
lctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
v, err := r.laya.Classify(lctx, *a)
if err != nil {
log.Printf("alerts: laya verdict for %s failed: %v", a.ID, err)
return
}
a.Laya = v
log.Printf("alerts: laya[shadow] %s: %s", a.ID, v.Summary())
}
// Status is the JSON served by GET /api/alerts. // Status is the JSON served by GET /api/alerts.
type Status struct { type Status struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -305,6 +332,7 @@ func (r *Runner) HandleTest(w http.ResponseWriter, req *http.Request) {
Example: features.Event{Service: "alert-test", Kind: "span", StatusCode: 500, Route: "/api/example", Message: "this is a test alert"}, Example: features.Event{Service: "alert-test", Kind: "span", StatusCode: 500, Route: "/api/example", Message: "this is a test alert"},
}}}, now) }}}, now)
a.Test = true a.Test = true
r.askLaya(req.Context(), &a)
results := map[string]string{} results := map[string]string{}
for _, n := range r.notifiers { for _, n := range r.notifiers {
if err := n.Send(req.Context(), a); err != nil { if err := n.Send(req.Context(), a); err != nil {
-105
View File
@@ -13,7 +13,6 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -1007,110 +1006,6 @@ func (c *Collector) HandleStatsEndpoints(w http.ResponseWriter, r *http.Request)
}) })
} }
// needleAddr returns the configured or default address for the Needle 3 sidecar.
func needleAddr() string {
addr := os.Getenv("OTEL_NEEDLE_ADDR")
if addr == "" {
addr = "http://127.0.0.1:5005"
}
return strings.TrimRight(addr, "/")
}
// HandleNeedleStatus checks if the Needle 3 sidecar is reachable and reports its health.
func (c *Collector) HandleNeedleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
target := needleAddr() + "/health"
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(target)
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"available": false,
"error": fmt.Sprintf("Needle sidecar unreachable at %s: %v", target, err),
})
return
}
defer resp.Body.Close()
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// HandleNeedleClassify proxies telemetry triage requests to the Needle 3 sidecar.
func (c *Collector) HandleNeedleClassify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
// If the payload specifies an issue_id and no text, look up the issue
var reqMap map[string]interface{}
if err := json.Unmarshal(body, &reqMap); err == nil {
if issueID, ok := reqMap["issue_id"].(string); ok && issueID != "" {
if issue, ok := c.GetIssue(issueID); ok {
var promptParts []string
promptParts = append(promptParts, fmt.Sprintf("Service: %s", issue.Service))
promptParts = append(promptParts, fmt.Sprintf("Title: %s", issue.Title))
promptParts = append(promptParts, fmt.Sprintf("Kind: %s", issue.Kind))
if len(issue.Samples) > 0 {
latest := issue.Samples[len(issue.Samples)-1]
if latest.Message != "" {
promptParts = append(promptParts, fmt.Sprintf("Message: %s", latest.Message))
}
if latest.URL != "" {
promptParts = append(promptParts, fmt.Sprintf("URL: %s", latest.URL))
}
if latest.HTTPStatus > 0 {
promptParts = append(promptParts, fmt.Sprintf("Status: %d", latest.HTTPStatus))
}
if latest.HTTPRoute != "" {
promptParts = append(promptParts, fmt.Sprintf("Route: %s", latest.HTTPRoute))
}
}
reqMap["text"] = strings.Join(promptParts, " | ")
reqMap["service"] = issue.Service
reqMap["title"] = issue.Title
body, _ = json.Marshal(reqMap)
}
}
}
target := needleAddr() + "/classify"
client := &http.Client{Timeout: 60 * time.Second}
proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
http.Error(w, "failed to create proxy request", http.StatusInternalServerError)
return
}
proxyReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(proxyReq)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("Needle sidecar unreachable at %s: %v", target, err),
})
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// stampFrontendClient fills in client.address and user_agent.original on // stampFrontendClient fills in client.address and user_agent.original on
// browser-originated log records (source=frontend) from the ingest request, // browser-originated log records (source=frontend) from the ingest request,
// since the browser cannot know its own public IP. Values the SDK already // since the browser cannot know its own public IP. Values the SDK already
+6 -48
View File
@@ -15,6 +15,9 @@ spec:
metadata: metadata:
labels: labels:
app: otel-debug-receiver app: otel-debug-receiver
annotations:
k8s-helper.io/git-url: "https://git.k6n.net/mats/go-otel"
k8s-helper.io/git-ssh: "git@git.k6n.net:mats/go-otel.git"
spec: spec:
serviceAccountName: otel-debug-receiver serviceAccountName: otel-debug-receiver
securityContext: securityContext:
@@ -138,6 +141,9 @@ spec:
# Optional Always-On Display session API. # Optional Always-On Display session API.
- name: OTEL_ALERT_AOD_URL - name: OTEL_ALERT_AOD_URL
value: "" value: ""
# laya shadow verdicts: shown in each notification, never acted on.
- name: OTEL_ALERT_LAYA_URL
value: "https://laya.k6n.net"
- name: OTEL_STORE_WORKERS - name: OTEL_STORE_WORKERS
value: "4" value: "4"
# Optional rollout observation. The watcher is read-only and uses the # Optional rollout observation. The watcher is read-only and uses the
@@ -167,8 +173,6 @@ spec:
value: "15m" value: "15m"
- name: OTEL_HONEYPOT_TTL - name: OTEL_HONEYPOT_TTL
value: "1h" value: "1h"
- name: OTEL_NEEDLE_ADDR
value: "http://127.0.0.1:5005"
# ~80% of the memory limit so the GC works harder before an OOMKill. # ~80% of the memory limit so the GC works harder before an OOMKill.
- name: GOMEMLIMIT - name: GOMEMLIMIT
value: "800MiB" value: "800MiB"
@@ -196,58 +200,12 @@ spec:
limits: limits:
memory: "1Gi" memory: "1Gi"
cpu: "500m" cpu: "500m"
- name: needle-sidecar
image: matst80/needle-sidecar:latest
imagePullPolicy: Always
ports:
- containerPort: 5005
name: needle-http
env:
- name: HOME
value: "/app"
- name: NEEDLE_PORT
value: "5005"
- name: NEEDLE_HOST
value: "0.0.0.0"
- name: NEEDLE_TELEMETRY
value: "0"
livenessProbe:
httpGet:
path: /health
port: needle-http
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: needle-http
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
memory: "512Mi"
cpu: "200m"
limits:
memory: "2Gi"
cpu: "4000m"
volumes: volumes:
- name: log-dir - name: log-dir
emptyDir: {} emptyDir: {}
- name: sourcemap-dir - name: sourcemap-dir
persistentVolumeClaim: persistentVolumeClaim:
claimName: otel-sourcemaps claimName: otel-sourcemaps
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: otel-sourcemaps
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
--- ---
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
+120
View File
@@ -0,0 +1,120 @@
# OIDC Registration in ecommerce namespace
apiVersion: v1
kind: Secret
metadata:
name: flux-dashboard-oidc-client-secret
namespace: ecommerce
type: Opaque
stringData:
secret: "f9f44c88d9fda647e4abbb4479f1116611ecc14624bd67ec"
---
apiVersion: auth.k6n.net/v1alpha1
kind: AuthClient
metadata:
name: flux-dashboard
namespace: ecommerce
labels:
auth.k6n.net/server: auth
spec:
clientID: flux-dashboard
displayName: Flux GitOps Dashboard
description: Web UI for Flux CD (Weave GitOps)
redirectURIs:
- https://flux.k6n.net/oauth2/callback
scopes:
- openid
- profile
- email
- groups
secretRef:
name: flux-dashboard-oidc-client-secret
key: secret
---
# Weave GitOps OIDC Secret in flux-system
apiVersion: v1
kind: Secret
metadata:
name: oidc-auth
namespace: flux-system
type: Opaque
stringData:
clientID: "flux-dashboard"
clientSecret: "f9f44c88d9fda647e4abbb4479f1116611ecc14624bd67ec"
issuerURL: "https://users.k6n.net"
redirectURL: "https://flux.k6n.net/oauth2/callback"
claimUsername: "email"
claimGroups: "roles"
---
# Weave GitOps Helm OCI Source and HelmRelease
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: weave-gitops
namespace: flux-system
spec:
type: oci
url: oci://ghcr.io/weaveworks/charts
interval: 1h
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: weave-gitops
namespace: flux-system
spec:
interval: 10m
chart:
spec:
chart: weave-gitops
version: "4.0.36"
sourceRef:
kind: HelmRepository
name: weave-gitops
values:
envVars:
- name: KUBERNETES_SERVICE_HOST
value: "10.10.1.102"
- name: KUBERNETES_SERVICE_PORT
value: "6443"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- serber
# Use existing oidc-auth secret created above
oidcSecret:
create: false
adminUser:
create: false
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: flux.k6n.net
paths:
- path: /
pathType: ImplementationSpecific
tls:
- hosts:
- flux.k6n.net
secretName: flux-k6n-tls
---
# ClusterRoleBinding so authenticated users can view/manage GitOps resources
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: weave-gitops-admin
subjects:
- kind: Group
name: admin
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
+28
View File
@@ -0,0 +1,28 @@
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: go-otel
namespace: flux-system
spec:
interval: 1m
url: ssh://git@git.k6n.net/mats/go-otel.git
ref:
branch: main
secretRef:
name: go-otel-auth
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: go-otel
namespace: flux-system
spec:
interval: 5m
targetNamespace: monitoring
sourceRef:
kind: GitRepository
name: go-otel
path: "./k8s"
prune: true
wait: true
timeout: 3m
+173
View File
@@ -0,0 +1,173 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-catchall
namespace: ingress-nginx
data:
nginx.conf: |
worker_processes 1;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
error_page 403 =404 /index.html;
# Only GET is intentionally accepted by this catch-all.
# The "if" is safe here because it only performs a return.
# Serve robots.txt directly with Disallow: /
location = /robots.txt {
default_type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
# Deliberately trigger an internal 403 so the static error page is
# returned with the externally visible status still set to 404.
location / {
if ($request_method != GET) {
add_header Allow GET always;
return 405;
}
deny all;
}
# Keep the error document internal so /index.html also follows the
# catch-all policy instead of becoming a successful public endpoint.
location = /index.html {
internal;
allow all;
}
}
}
index.html: |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 2rem;
color: #e8ecf2;
background: #0d1014;
font: 16px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main { width: min(100%, 42rem); }
.code {
margin: 0 0 .75rem;
color: #7b8cff;
font: 700 clamp(4rem, 14vw, 8rem)/.9 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: -.08em;
}
h1 { margin: 0 0 .75rem; font-size: clamp(1.5rem, 4vw, 2.25rem); }
p { margin: 0; max-width: 34rem; color: #98a3b2; }
code { color: #e8ecf2; }
</style>
</head>
<body>
<main>
<p class="code">404</p>
<h1>There is nothing here.</h1>
<p>The requested resource does not exist on this ingress endpoint.</p>
</main>
</body>
</html>
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-catchall
namespace: ingress-nginx
labels:
app.kubernetes.io/name: ingress-catchall
app.kubernetes.io/part-of: ingress-nginx
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: ingress-catchall
template:
metadata:
labels:
app.kubernetes.io/name: ingress-catchall
app.kubernetes.io/part-of: ingress-nginx
spec:
automountServiceAccountToken: false
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginx:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
runAsNonRoot: true
runAsUser: 101
volumeMounts:
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
readOnly: true
- name: config
mountPath: /usr/share/nginx/html/index.html
subPath: index.html
readOnly: true
readinessProbe:
tcpSocket:
port: http
periodSeconds: 5
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: config
configMap:
name: ingress-catchall
---
apiVersion: v1
kind: Service
metadata:
name: ingress-catchall
namespace: ingress-nginx
labels:
app.kubernetes.io/name: ingress-catchall
app.kubernetes.io/part-of: ingress-nginx
spec:
selector:
app.kubernetes.io/name: ingress-catchall
ports:
- name: http
port: 80
targetPort: http
+4
View File
@@ -85,6 +85,10 @@ metadata:
name: otel-debug-ingress name: otel-debug-ingress
namespace: monitoring namespace: monitoring
annotations: annotations:
k8s-helper.io/name: "Go OTel"
k8s-helper.io/description: "OpenTelemetry debugging dashboard"
k8s-helper.io/group: "Observability"
k8s-helper.io/icon: "activity"
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
# OIDC protection via the platform auth service (users.k6n.net): # OIDC protection via the platform auth service (users.k6n.net):
# /oauth2/auth resolves the session cookie / bearer token and answers # /oauth2/auth resolves the session cookie / bearer token and answers
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: otel-sourcemaps
namespace: monitoring
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
@@ -16,7 +16,6 @@ import {
ScrollText, ScrollText,
ShieldBan, ShieldBan,
Bot, Bot,
Sparkles,
Loader2, Loader2,
X, X,
} from "lucide-react" } from "lucide-react"
@@ -38,7 +37,6 @@ import { useSubRoute } from "../lib/routes"
import { CopyRef } from "./common/CopyRef" import { CopyRef } from "./common/CopyRef"
import { IpButton } from "./common/IpButton" import { IpButton } from "./common/IpButton"
import { IssueOccurrences } from "./IssueOccurrences" import { IssueOccurrences } from "./IssueOccurrences"
import { NeedleTestModal } from "./NeedleTestModal"
import { FilterBar, type FilterChip } from "./FilterBar" import { FilterBar, type FilterChip } from "./FilterBar"
import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class" import { CRAWLER_CLASSES, UA_CLASSES, uaClassLabel } from "../lib/ua-class"
import type { Issue, IssueSample } from "../lib/types" import type { Issue, IssueSample } from "../lib/types"
@@ -94,16 +92,6 @@ interface DetectorTestResult {
error?: string error?: string
} }
interface NeedleResult {
action?: string
tool?: string
confidence?: number
reasoning?: string
latency_ms?: number
arguments?: Record<string, unknown>
error?: string
}
const statusTabs: { value: StatusFilter; label: string }[] = [ const statusTabs: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "open", label: "Open" }, { value: "open", label: "Open" },
@@ -315,26 +303,8 @@ const IssueCard = ({
detectedIPByAddress: Map<string, DetectedIP> detectedIPByAddress: Map<string, DetectedIP>
}) => { }) => {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
const [needleLoading, setNeedleLoading] = useState(false)
const { sources, loading: sourcesLoading, error: sourcesError } = useIssueSources(issue.id, expanded) const { sources, loading: sourcesLoading, error: sourcesError } = useIssueSources(issue.id, expanded)
const handleNeedleTriage = async () => {
setNeedleLoading(true)
try {
const response = await authFetch("/api/needle/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issue_id: issue.id }),
})
setNeedleResult(await response.json())
} catch (err) {
setNeedleResult({ error: err instanceof Error ? err.message : String(err) })
} finally {
setNeedleLoading(false)
}
}
const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug } const meta = kindMeta[issue.kind] ?? { label: issue.kind, Icon: Bug }
const tone = issueSeverityTone(issue) const tone = issueSeverityTone(issue)
const latestSample = issue.samples[issue.samples.length - 1] const latestSample = issue.samples[issue.samples.length - 1]
@@ -505,57 +475,6 @@ const IssueCard = ({
)} )}
</div> </div>
<div className="space-y-2">
<button
type="button"
onClick={() => void handleNeedleTriage()}
disabled={needleLoading}
title="Classify this issue using local Needle 3 model"
className="inline-flex h-8 items-center gap-2 rounded-[9px] border border-ds-line-strong px-3 text-[12px] font-medium text-ds-text-2 hover:text-ds-text disabled:opacity-50"
>
{needleLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
)}
{needleLoading ? "Needle 3 triaging…" : "Needle 3 triage"}
</button>
{needleResult && (
<div className="space-y-2 rounded-[12px] border border-ds-line bg-ds-surface-2 p-3 text-[12px]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ds-line pb-2">
<span className="flex flex-wrap items-center gap-2">
<span className="text-[10.5px] font-bold uppercase tracking-wider text-ds-text-3">Needle 3 action</span>
<span className="rounded-[6px] border border-ds-line-strong px-2 py-0.5 font-[family-name:var(--font-data)] text-ds-text">
{needleResult.action || needleResult.tool || "Processed"}
</span>
</span>
{needleResult.confidence !== undefined && (
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
confidence{" "}
<span className={needleResult.confidence >= 0.6 ? "text-ds-ok" : "text-ds-warn"}>
{(needleResult.confidence * 100).toFixed(0)}%
</span>
{needleResult.latency_ms ? ` (${needleResult.latency_ms}ms)` : ""}
</span>
)}
</div>
{needleResult.reasoning && <p className="italic text-ds-text-2">&ldquo;{needleResult.reasoning}&rdquo;</p>}
{needleResult.arguments && Object.keys(needleResult.arguments).length > 0 && (
<div className="flex flex-wrap gap-1.5 font-[family-name:var(--font-data)] text-[11px]">
{Object.entries(needleResult.arguments).map(([k, v]) => (
<span key={k} className="rounded-[6px] border border-ds-line bg-ds-surface px-2 py-0.5 text-ds-text-2">
<span className="text-ds-text-3">{k}:</span> {String(v)}
</span>
))}
</div>
)}
{needleResult.error && (
<p className="font-[family-name:var(--font-data)] text-[11px] text-ds-critical">{needleResult.error}</p>
)}
</div>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3"> <p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">
@@ -750,7 +669,6 @@ export const IssuesVisualizer = ({
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all") const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [kindFilter, setKindFilter] = useState<KindFilter>("all") const [kindFilter, setKindFilter] = useState<KindFilter>("all")
const [groupBy, setGroupBy] = useState<GroupBy>("none") const [groupBy, setGroupBy] = useState<GroupBy>("none")
const [needleModalOpen, setNeedleModalOpen] = useState(false)
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null) const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
const [detectorTestLoading, setDetectorTestLoading] = useState(false) const [detectorTestLoading, setDetectorTestLoading] = useState(false)
const [excludeSpamIPs, setExcludeSpamIPs] = useState(false) const [excludeSpamIPs, setExcludeSpamIPs] = useState(false)
@@ -1119,15 +1037,6 @@ export const IssuesVisualizer = ({
<Bot className="h-3.5 w-3.5" aria-hidden="true" /> <Bot className="h-3.5 w-3.5" aria-hidden="true" />
{crawlersHidden ? "Crawlers hidden" : "Hide crawlers"} {crawlersHidden ? "Crawlers hidden" : "Hide crawlers"}
</button> </button>
<button
type="button"
onClick={() => setNeedleModalOpen(true)}
title="Open interactive Needle 3 telemetry classifier"
className="inline-flex h-9 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
Test Needle 3
</button>
<button <button
type="button" type="button"
onClick={() => void runDetectorTest()} onClick={() => void runDetectorTest()}
@@ -1295,7 +1204,6 @@ export const IssuesVisualizer = ({
</aside> </aside>
</div> </div>
<NeedleTestModal isOpen={needleModalOpen} onClose={() => setNeedleModalOpen(false)} />
</div> </div>
) )
} }
-236
View File
@@ -1,236 +0,0 @@
import React, { useState } from "react"
import { X, Sparkles, Loader2, Cpu, CheckCircle2, AlertTriangle } from "lucide-react"
import { authFetch } from "../lib/auth"
interface NeedleTestModalProps {
isOpen: boolean
onClose: () => void
}
interface NeedleResponse {
success?: boolean
action?: string
tool?: string
confidence?: number
reasoning?: string
latency_ms?: number
arguments?: Record<string, unknown>
error?: string
}
const PRESETS = [
{
label: "Frontend RUM Crash",
service: "eventsourced-ai-web",
text: "User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1",
},
{
label: "Security CVE Probe",
service: "nginx",
text: "Alert security team: scanner metabase-cve-2026-72898-detect/1.0 probed target /api/session/properties with status code 503",
},
{
label: "Web Vital Metric",
service: "storefront-web",
text: "Store RUM metric on service storefront-web: record TTFB of 84 ms",
},
{
label: "Benign 404 Route",
service: "git.k6n.net",
text: "Client requested missing git resource: 404 on path /mats/go-cart-actor/blame/commit/81569b on host git.k6n.net",
},
{
label: "Database Pool Deadlock",
service: "checkout-service",
text: "Critical incident: Escalate severe backend database timeout on service checkout-service: deadlocked while acquiring connection pool",
},
]
export const NeedleTestModal: React.FC<NeedleTestModalProps> = ({ isOpen, onClose }) => {
const [inputText, setInputText] = useState(PRESETS[0].text)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<NeedleResponse | null>(null)
if (!isOpen) return null
const handleClassify = async () => {
if (!inputText.trim()) return
setLoading(true)
setResult(null)
try {
const resp = await authFetch("/api/needle/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: inputText }),
})
const data = await resp.json()
setResult(data)
} catch (err) {
setResult({ error: err instanceof Error ? err.message : String(err) })
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ds-bg/80 p-4 animate-fade-in">
<div className="relative flex flex-col w-full max-w-2xl rounded-[14px] border border-ds-ok/30 bg-ds-bg/95 p-6 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-ds-line pb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-ds-ok/15 border border-ds-ok/30 text-ds-ok">
<Cpu className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold text-ds-text flex items-center gap-2">
Needle 3 AI Telemetry Classifier
<span className="rounded-full bg-ds-ok/15 border border-ds-ok/30 px-2 py-0.5 text-[10px] font-mono text-ds-ok">
Cactus-Compute
</span>
</h2>
<p className="text-xs text-ds-text-2">
On-device ~35MB foundation model executing real-time tool calling and extraction on local CPU.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Preset Selector */}
<div className="mt-4">
<label className="text-[11px] font-bold uppercase tracking-wider text-ds-text-2">
Quick Telemetry Presets (from ClickHouse)
</label>
<div className="flex flex-wrap gap-2 mt-2">
{PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() => {
setInputText(p.text)
setResult(null)
}}
className={`rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-all ${
inputText === p.text
? "border-ds-ok bg-ds-ok/15 text-ds-ok"
: "border-ds-line bg-ds-surface-2 text-ds-text-2 hover:border-ds-line-strong hover:bg-ds-fill"
}`}
>
{p.label}
</button>
))}
</div>
</div>
{/* Input Textarea */}
<div className="mt-4">
<label className="text-[11px] font-bold uppercase tracking-wider text-ds-text-2">
Telemetry Event / Log / Trace Summary
</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
rows={3}
className="mt-1.5 w-full rounded-xl border border-ds-line bg-ds-surface-2 p-3 font-mono text-xs text-ds-text placeholder:text-ds-text-3 focus:border-ds-ok/50 focus:outline-none focus:ring-1 focus:ring-ds-ok/40"
placeholder="Type or paste telemetry event..."
/>
</div>
{/* Classify Button */}
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={handleClassify}
disabled={loading || !inputText.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-ds-surface-2 px-4 py-2 text-xs font-bold text-ds-on-accent transition-all hover:opacity-95 active:scale-95 disabled:opacity-50"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Classifying on CPU...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Classify with Needle 3
</>
)}
</button>
</div>
{/* Results Area */}
{result && (
<div className="mt-5 rounded-xl border border-ds-ok/30 bg-ds-ok/15 p-4 space-y-3">
<div className="flex items-center justify-between border-b border-ds-ok/20 pb-3">
<div className="flex items-center gap-2">
{result.error ? (
<AlertTriangle className="h-4 w-4 text-ds-critical" />
) : (
<CheckCircle2 className="h-4 w-4 text-ds-ok" />
)}
<span className="text-xs font-bold uppercase tracking-wider text-ds-text-2">
{result.error ? "Error" : "Triage Result"}
</span>
{result.action && (
<span className="rounded-lg bg-ds-ok/15 border border-ds-ok/40 px-2 py-0.5 font-mono text-xs font-bold text-ds-ok">
{result.action}
</span>
)}
</div>
{result.confidence !== undefined && (
<div className="flex items-center gap-3 text-xs font-mono">
<span className="text-ds-text-2">
Confidence:{" "}
<span className="font-bold text-ds-ok">
{(result.confidence * 100).toFixed(0)}%
</span>
</span>
{result.latency_ms !== undefined && (
<span className="text-ds-text-3">({result.latency_ms}ms)</span>
)}
</div>
)}
</div>
{result.reasoning && (
<p className="text-xs text-ds-text-2 italic">
&ldquo;{result.reasoning}&rdquo;
</p>
)}
{result.arguments && Object.keys(result.arguments).length > 0 && (
<div className="space-y-1.5 pt-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-ds-text-2">
Extracted Typed Arguments:
</span>
<div className="grid grid-cols-2 gap-2 font-mono text-xs">
{Object.entries(result.arguments).map(([key, value]) => (
<div
key={key}
className="rounded-lg border border-ds-line bg-ds-surface-2 p-2 text-ds-text break-all"
>
<span className="text-ds-text-3">{key}: </span>
<span className="text-ds-ok font-semibold">{String(value)}</span>
</div>
))}
</div>
</div>
)}
{result.error && (
<div className="rounded-lg bg-ds-critical-bg border border-ds-critical/20 p-3 text-xs text-ds-critical">
{result.error}
</div>
)}
</div>
)}
</div>
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Use pre-configured virtual environment or fall back to local .venv
VENV_DIR="/home/mats/.gemini/antigravity-cli/brain/8e7a2c5c-81aa-487e-81b1-bf0cf790d055/scratch/needle_venv"
if [ ! -d "${VENV_DIR}" ]; then
VENV_DIR="${ROOT_DIR}/.venv"
fi
if [ ! -f "${VENV_DIR}/bin/python" ]; then
echo "Setting up virtual environment at ${VENV_DIR}..."
python3 -m venv "${VENV_DIR}"
"${VENV_DIR}/bin/pip" install --upgrade pip
"${VENV_DIR}/bin/pip" install cactus-needle pydantic
fi
echo "Running Needle 3 OTel Verification Suite..."
"${VENV_DIR}/bin/python" "${SCRIPT_DIR}/test_needle_classifier.py"
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""
Test and Verification Suite for Cactus-Compute/needle3 with OpenTelemetry data.
Tests:
1. Action/Tool Dispatch Accuracy (Ground Truth Verification)
2. Field Extraction & Grounding
3. Confidence Calibrations (Act vs. Escalate)
4. Semantic Vector Embeddings (Issue Clustering)
5. Execution Performance & Latency (Tokens/sec)
"""
import sys
import time
import json
import math
import subprocess
try:
import needle
except ImportError:
print("Error: cactus-needle not found. Run with the virtualenv python:")
print(" /home/mats/.gemini/antigravity-cli/brain/8e7a2c5c-81aa-487e-81b1-bf0cf790d055/scratch/needle_venv/bin/python " + __file__)
sys.exit(1)
# --- Define Triage Tools ---
@needle.tool
def report_client_javascript_crash(service: str, message: str, url: str):
"""Report client-side JavaScript crash or error logged by browser RUM."""
return {"action": "client_js_crash", "service": service, "message": message, "url": url}
@needle.tool
def alert_vulnerability_probe(scanner: str, target: str, status_code: int):
"""Alert on malicious vulnerability scans or exploit crawlers."""
return {"action": "security_alert", "scanner": scanner, "target": target, "status_code": status_code}
@needle.tool
def record_frontend_web_vital(service: str, metric_name: str, duration_ms: int):
"""Record RUM performance metrics like TTFB or FCP."""
return {"action": "record_vital", "service": service, "metric": metric_name, "ms": duration_ms}
@needle.tool
def log_missing_resource_404(path: str, host: str):
"""Log benign 404 Not Found requests for missing pages or git commits."""
return {"action": "log_404", "path": path, "host": host}
@needle.tool
def escalate_backend_outage(service: str, error_message: str):
"""Escalate severe backend database timeout or service crash to on-call."""
return {"action": "escalate_outage", "service": service, "error": error_message}
def run_verification():
print("=" * 70)
print(" NEEDLE 3 OPENTELEMETRY CLASSIFIER - VERIFICATION SUITE")
print("=" * 70)
# 1. Initialize Agent
print("\n[Step 1] Initializing Needle 3 Engine...")
t0 = time.perf_counter()
agent = needle.Needle(tools=[
report_client_javascript_crash,
alert_vulnerability_probe,
record_frontend_web_vital,
log_missing_resource_404,
escalate_backend_outage
])
init_ms = (time.perf_counter() - t0) * 1000
print(f"✓ Needle 3 Engine initialized in {init_ms:.1f}ms")
# 2. Ground-Truth Test Cases (from actual ClickHouse telemetry)
test_cases = [
{
"name": "Frontend Browser RUM Crash",
"input": "User reported client browser crash on service eventsourced-ai-web: Script error on url https://event-agent.k6n.net/#session=angry-koala-1",
"expected_tool": "report_client_javascript_crash",
"expected_fields": ["service", "url"]
},
{
"name": "Malicious CVE Exploit Probe",
"input": "Alert security team: scanner metabase-cve-2026-72898-detect/1.0 probed target /api/session/properties with status code 503",
"expected_tool": "alert_vulnerability_probe",
"expected_fields": ["target", "status_code"]
},
{
"name": "Web Vital RUM Metric",
"input": "Store RUM metric on service storefront-web: record TTFB of 84 ms",
"expected_tool": "record_frontend_web_vital",
"expected_fields": ["service", "metric", "ms"]
},
{
"name": "Nginx Benign 404 Route",
"input": "Client requested missing git resource: 404 on path /mats/go-cart-actor/blame/commit/81569b on host git.k6n.net",
"expected_tool": "log_missing_resource_404",
"expected_fields": ["path", "host"]
},
{
"name": "Critical Database Outage",
"input": "Critical incident: Escalate severe backend database timeout on service checkout-service: deadlocked while acquiring connection pool",
"expected_tool": "escalate_backend_outage",
"expected_fields": ["service"]
}
]
print("\n[Step 2] Testing Classification & Tool Dispatch Accuracy...")
passed = 0
total = len(test_cases)
latencies = []
for i, tc in enumerate(test_cases, 1):
agent.reset()
t_start = time.perf_counter()
res = agent.run(tc["input"], strict=False)
dur_ms = (time.perf_counter() - t_start) * 1000
latencies.append(dur_ms)
results = res.get("results", [])
matched_action = results[0].get("action") if results and isinstance(results[0], dict) else None
# Tool name to action map
expected_action = tc["expected_tool"].replace("report_", "").replace("alert_", "").replace("record_", "").replace("log_", "").replace("escalate_", "")
# Check match
is_match = False
if results and any(tc["expected_tool"] in str(c) or matched_action for c in results):
is_match = True
status_str = "PASS" if is_match else "FAIL"
if is_match:
passed += 1
print(f"\n Test {i}/{total}: {tc['name']} -> [{status_str}] ({dur_ms:.1f}ms)")
print(f" Confidence: {res.get('confidence', 0.0):.3f}")
print(f" Reasoning : {res.get('reasoning')}")
print(f" Dispatched: {results}")
print("\n" + "-" * 70)
print(f"Accuracy Score: {passed}/{total} ({passed/total*100:.0f}%) | Avg Latency: {sum(latencies)/len(latencies):.1f}ms")
print("-" * 70)
# 3. Embedding & Semantic Search Verification
print("\n[Step 3] Testing Semantic Vector Embeddings for Near-Match Clustering...")
emb_e1 = agent.embed("database connection timeout after 3000ms")
emb_e2 = agent.embed("postgres pool exhausted: connection timed out")
emb_e3 = agent.embed("invalid JWT signature token expired")
def cosine_similarity(v1, v2):
dot = sum(a * b for a, b in zip(v1, v2))
m1 = math.sqrt(sum(a * a for a in v1))
m2 = math.sqrt(sum(b * b for b in v2))
return dot / (m1 * m2)
sim_similar = cosine_similarity(emb_e1, emb_e2)
sim_different = cosine_similarity(emb_e1, emb_e3)
print(f" Vector Dimensions: {len(emb_e1)}")
print(f" Similarity between 2 DB timeouts : {sim_similar:.4f}")
print(f" Similarity between DB and Auth : {sim_different:.4f}")
if sim_similar > sim_different:
print("✓ Semantic clustering validated: related issues score higher similarity.")
else:
print("✗ Warning: semantic separation failed.")
print("\n" + "=" * 70)
print(" VERIFICATION COMPLETE")
print("=" * 70)
if __name__ == "__main__":
run_verification()