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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-11-01 11:40:53 +01:00
1 parent e046bd86d1
commit 51a8412ae1
22 files changed
+29 -1162

No files matched your search

-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}" \
-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)
-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]++
} }
-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
-36
View File
@@ -173,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"
@@ -202,40 +200,6 @@ 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: {}
@@ -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()