Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction with a pluggable opt-in cloud adapter, schema grounding, and a shared cost/safety guard every AI-suggested query is assessed against -- compiling to and executing through the same unchanged Phase 2 IR/ compiler and Phase 4 tenant scoping as a hand-written query, no parallel execution path. Track A (built into the query bar): inline ghost-text autocomplete, "Explain this query", "Fix this query" with a diff view, and a rule-based "Optimize" suggestion. Track B: natural-language-to-query translation, always a separate review step from execution, with `sentryctl query --nl` requiring explicit confirmation to run. Every accepted/dismissed translate-fix-optimize interaction is logged into the same append-only audit_log table Phase 4 built. Two real product bugs were found and fixed via live browser verification (a Svelte effect re-running on every keystroke that silently cancelled the ghost-text debounce; a ghost-text widget positioned at document offset 0 instead of the cursor), and a real costguard logic bug (unbounded-aggregation vs. raw-row) was caught by its own test suite. New integration tests wire a real Ollama client through the real HTTP handler against a mock server matching Ollama's wire contract (hack/mock-ollama), keeping model-quality verification out of CI as a disclosed, periodic human-run check instead. See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
package grounding
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sentry/sentry/api/ai/provider"
|
|
"github.com/sentry/sentry/api/querylang/executor"
|
|
)
|
|
|
|
// routingFakeRunner returns a canned result keyed by a substring match
|
|
// against the SQL text -- grounding.Refresh issues several structurally
|
|
// different queries in sequence (services, attribute keys, then one
|
|
// per candidate enum field), unlike executor's tests where a single
|
|
// fixed result/err per call is enough.
|
|
type routingFakeRunner struct {
|
|
byContains []struct {
|
|
substr string
|
|
result *executor.Result
|
|
err error
|
|
}
|
|
calls int
|
|
}
|
|
|
|
func (r *routingFakeRunner) on(substr string, result *executor.Result) *routingFakeRunner {
|
|
r.byContains = append(r.byContains, struct {
|
|
substr string
|
|
result *executor.Result
|
|
err error
|
|
}{substr, result, nil})
|
|
return r
|
|
}
|
|
|
|
func (r *routingFakeRunner) RunSQL(_ context.Context, sql string) (*executor.Result, error) {
|
|
r.calls++
|
|
for _, rule := range r.byContains {
|
|
if strings.Contains(sql, rule.substr) {
|
|
if rule.err != nil {
|
|
return nil, rule.err
|
|
}
|
|
return rule.result, nil
|
|
}
|
|
}
|
|
// Unmatched queries (most of the per-field example queries in a
|
|
// small test fixture) come back empty, same as a field with no data
|
|
// -- not an error, matching sampleFieldExamples' "unusable -> not
|
|
// enum-like" treatment.
|
|
return &executor.Result{Columns: []string{"v"}, Rows: nil}, nil
|
|
}
|
|
|
|
func strResult(col string, vals ...string) *executor.Result {
|
|
rows := make([][]any, len(vals))
|
|
for i, v := range vals {
|
|
rows[i] = []any{v}
|
|
}
|
|
return &executor.Result{Columns: []string{col}, Rows: rows}
|
|
}
|
|
|
|
func TestRefreshPopulatesServicesAndFields(t *testing.T) {
|
|
runner := (&routingFakeRunner{}).
|
|
on("FROM logs WHERE timestamp > now() - INTERVAL 604800 SECOND GROUP BY service", strResult("service", "api", "web", "worker")).
|
|
on("mapKeys(attributes)", strResult("attr_key", "status", "latency_ms")).
|
|
on("`severity`", strResult("v", "INFO", "WARN", "ERROR")).
|
|
on("attributes['status']", strResult("v", "200", "404", "500"))
|
|
|
|
svc := New(runner)
|
|
if err := svc.Refresh(context.Background()); err != nil {
|
|
t.Fatalf("Refresh: %v", err)
|
|
}
|
|
|
|
got := svc.Current()
|
|
if len(got.Services) != 3 || got.Services[0] != "api" {
|
|
t.Errorf("Services = %v, want [api web worker]", got.Services)
|
|
}
|
|
|
|
var severity, status *provider.FieldInfo
|
|
for i := range got.Fields {
|
|
f := &got.Fields[i]
|
|
switch f.Name {
|
|
case "severity":
|
|
severity = f
|
|
case "status":
|
|
status = f
|
|
}
|
|
}
|
|
if severity == nil || len(severity.Examples) != 3 {
|
|
t.Errorf("severity field = %+v, want 3 examples", severity)
|
|
}
|
|
if status == nil || len(status.Examples) != 3 {
|
|
t.Errorf("status field = %+v, want 3 examples", status)
|
|
}
|
|
|
|
// Static fields with no configured example rule (host, message,
|
|
// timestamp, record_id) should still be present, just with no
|
|
// examples -- Refresh must not drop them.
|
|
names := make(map[string]bool, len(got.Fields))
|
|
for _, f := range got.Fields {
|
|
names[f.Name] = true
|
|
}
|
|
for _, want := range []string{"timestamp", "host", "message", "record_id"} {
|
|
if !names[want] {
|
|
t.Errorf("static field %q missing from Fields", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRefreshFailureLeavesPreviousSnapshot(t *testing.T) {
|
|
good := (&routingFakeRunner{}).
|
|
on("GROUP BY service", strResult("service", "api"))
|
|
svc := New(good)
|
|
if err := svc.Refresh(context.Background()); err != nil {
|
|
t.Fatalf("first Refresh: %v", err)
|
|
}
|
|
first := svc.Current()
|
|
|
|
svc.runner = &erroringRunner{}
|
|
if err := svc.Refresh(context.Background()); err == nil {
|
|
t.Fatal("expected Refresh to fail with an erroring runner")
|
|
}
|
|
|
|
after := svc.Current()
|
|
if len(after.Services) != len(first.Services) || after.Services[0] != first.Services[0] {
|
|
t.Errorf("Current() after a failed Refresh = %+v, want unchanged snapshot %+v", after, first)
|
|
}
|
|
}
|
|
|
|
type erroringRunner struct{}
|
|
|
|
func (erroringRunner) RunSQL(context.Context, string) (*executor.Result, error) {
|
|
return nil, context.DeadlineExceeded
|
|
}
|
|
|
|
func TestFieldExampleCapExcludesHighCardinalityFields(t *testing.T) {
|
|
many := make([]string, maxExamplesPerField+1)
|
|
for i := range many {
|
|
many[i] = string(rune('a' + i%26))
|
|
}
|
|
runner := (&routingFakeRunner{}).
|
|
on("GROUP BY service", strResult("service", "api")).
|
|
on("mapKeys(attributes)", strResult("attr_key", "trace_id")).
|
|
on("attributes['trace_id']", strResult("v", many...))
|
|
|
|
svc := New(runner)
|
|
if err := svc.Refresh(context.Background()); err != nil {
|
|
t.Fatalf("Refresh: %v", err)
|
|
}
|
|
for _, f := range svc.Current().Fields {
|
|
if f.Name == "trace_id" && len(f.Examples) != 0 {
|
|
t.Errorf("high-cardinality field trace_id got %d examples, want 0 (not treated as enum-like)", len(f.Examples))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStartRefreshingRunsOnInterval(t *testing.T) {
|
|
runner := (&routingFakeRunner{}).on("GROUP BY service", strResult("service", "api"))
|
|
svc := New(runner)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
svc.StartRefreshing(ctx, 10*time.Millisecond, nil)
|
|
|
|
// The immediate synchronous refresh should have already happened by
|
|
// the time StartRefreshing returns.
|
|
if got := svc.Current().Services; len(got) != 1 {
|
|
t.Fatalf("Current() immediately after StartRefreshing = %v, want [api]", got)
|
|
}
|
|
}
|