Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)

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.
This commit is contained in:
2026-08-16 18:06:27 -07:00
parent 661568085e
commit 7d316f92db
37 changed files with 5230 additions and 20 deletions
+514
View File
@@ -0,0 +1,514 @@
// Package aiapi is Track A's HTTP surface: complete, explain, fix, and
// optimize, each a thin wrapper around api/ai/router dispatching to
// whichever provider.Provider is configured for that operation. Mirrors
// queryapi's shape deliberately (same auth wrapper, same request-size
// cap, same error-response shape) since this is the same kind of
// endpoint -- a JSON-in, JSON-out operation gated by the same RoleViewer
// requirement /query uses, nothing AI-specific about the transport.
//
// What this package does NOT do: execute a query. Every operation here
// returns text (a suggestion, an explanation, a fix) for the client to
// review -- running anything still goes through the unchanged POST
// /query endpoint (queryapi.Handler), never through here. See
// /docs/phase-7-ai-design.md for why that split is load-bearing, not
// incidental.
package aiapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/sentry/sentry/api/ai/costguard"
"github.com/sentry/sentry/api/ai/provider"
"github.com/sentry/sentry/api/ai/router"
"github.com/sentry/sentry/api/authz"
"github.com/sentry/sentry/api/internal/querylang/ir"
"github.com/sentry/sentry/api/internal/querylang/planner"
)
// SchemaContextSource resolves the calling tenant's grounding data.
// Core wires a tenant-agnostic adapter around one grounding.Service;
// enterprise-api wires one around groundingregistry that reads the
// tenant from request context -- same "interface in core, tenant-aware
// implementation supplied by whoever constructs the handler" shape as
// queryapi.AuditLogger and dashboards.PermissionStore.
type SchemaContextSource interface {
SchemaContext(ctx context.Context) provider.SchemaContext
}
// InteractionLogger records a translate/fix/optimize suggestion's
// accept-or-dismiss outcome into the Phase 4 audit trail (task 12) --
// same nil-by-default, fail-open shape as queryapi.AuditLogger: a
// single-tenant deployment with no enterprise/ configured just doesn't
// log these, same as it doesn't log query executions today.
// enterprise/internal/audit supplies the real implementation, writing
// into the same append-only audit_log table query executions use
// (a new event_type, not a new table -- see
// metadata/migrations/0036_add_ai_interaction_event_type.sql).
//
// Deliberately not wired into Complete (ghost-text): that operation
// fires on every keystroke pause, and logging each one at the same
// weight as a deliberate Fix/Optimize/Translate review would drown the
// signal task 12 actually wants (real accept/reject decisions) in
// high-frequency noise. Not wired into Explain either -- it produces no
// suggestion to accept or reject, so "accepted vs. rejected" doesn't
// apply to it. Both are named scope boundaries, not oversights.
type InteractionLogger interface {
LogInteraction(ctx context.Context, entry InteractionEntry) error
}
type InteractionEntry struct {
// Operation is "translate", "fix", or "optimize" -- the three flows
// that produce a suggestion a user explicitly accepts or dismisses.
Operation string
Input string
Output string
// Confidence is empty for fix/optimize (provider.Confidence only
// applies to Translate/Fix results in a way the frontend surfaces
// today -- Optimize's phrasing has no confidence concept).
Confidence string
// Accepted is false for a dismissed suggestion; Output/FinalQuery
// still carry what was offered, since a rejected suggestion is
// itself useful signal (task 12: "useful data for improving
// grounding/prompting later").
Accepted bool
// Edited is only meaningful when Accepted -- did the user change
// the suggested text before using it. False, not omitted, when
// Accepted is false (there's nothing to have edited).
Edited bool
FinalQuery string
}
// completeTimeout is deliberately tight -- task 5's "low enough latency
// to feel responsive" requirement. A slow or hung provider must not
// stall the query bar; the frontend's fallback to deterministic
// autocomplete (Phase 2/5) kicks in on any error, including a timeout,
// so a short timeout here fails fast toward that fallback rather than
// making the user wait to find out AI completion isn't going to work
// this time.
const completeTimeout = 1500 * time.Millisecond
// Explain/Fix/Optimize are user-initiated (a button press, not
// as-you-type), so a more generous budget is the right tradeoff --
// correctness/quality over latency here, unlike Complete.
const operationTimeout = 15 * time.Second
type Handler struct {
logger *slog.Logger
router *router.Router
schema SchemaContextSource
authz authz.Authorizer
interactions InteractionLogger
}
// interactions may be nil -- see InteractionLogger's doc comment.
func NewHandler(logger *slog.Logger, r *router.Router, schema SchemaContextSource, authorizer authz.Authorizer, interactions InteractionLogger) *Handler {
return &Handler{logger: logger, router: r, schema: schema, authz: authorizer, interactions: interactions}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /ai/complete", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleComplete))
mux.HandleFunc("POST /ai/explain", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleExplain))
mux.HandleFunc("POST /ai/fix", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleFix))
mux.HandleFunc("POST /ai/optimize", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleOptimize))
mux.HandleFunc("POST /ai/translate", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleTranslate))
mux.HandleFunc("POST /ai/log-interaction", authz.RequireRoleOrService(h.authz, authz.RoleViewer, h.handleLogInteraction))
}
const maxBodyBytes = 1 << 20 // 1 MiB, same cap queryapi uses -- these bodies are smaller still
type errorResponse struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
// ---- complete ----
type completeRequest struct {
QueryPrefix string `json:"queryPrefix"`
Language string `json:"language"`
}
type completeResponse struct {
Suggestion string `json:"suggestion"`
}
func (h *Handler) handleComplete(w http.ResponseWriter, r *http.Request) {
var req completeRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.QueryPrefix) == "" {
writeJSON(w, completeResponse{})
return
}
ctx, cancel := context.WithTimeout(r.Context(), completeTimeout)
defer cancel()
result, err := h.router.For(router.OpComplete).Complete(ctx, provider.CompleteRequest{
QueryPrefix: req.QueryPrefix,
Language: orDefault(req.Language, "spl"),
Schema: h.schema.SchemaContext(r.Context()),
})
if err != nil {
// Complete's whole point is to degrade gracefully -- a failed or
// slow completion is not worth a scary error response the query
// bar has to handle specially. An empty suggestion is exactly
// what "no good completion available right now" looks like to
// the frontend's fallback logic (task 5).
h.logger.Warn("ai complete failed", "error", err)
writeJSON(w, completeResponse{})
return
}
writeJSON(w, completeResponse{Suggestion: result.Suggestion})
}
// ---- explain ----
type explainRequest struct {
Query string `json:"query"`
Language string `json:"language"`
OriginalIntent string `json:"originalIntent"`
}
type explainResponse struct {
Explanation string `json:"explanation"`
}
func (h *Handler) handleExplain(w http.ResponseWriter, r *http.Request) {
var req explainRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.Query) == "" {
writeError(w, http.StatusBadRequest, "query must not be empty")
return
}
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
defer cancel()
result, err := h.router.For(router.OpExplain).Explain(ctx, provider.ExplainRequest{
Query: req.Query,
Language: orDefault(req.Language, "spl"),
OriginalIntent: req.OriginalIntent,
})
if err != nil {
writeError(w, http.StatusBadGateway, "explain failed: "+err.Error())
return
}
writeJSON(w, explainResponse{Explanation: result.Explanation})
}
// ---- fix ----
type fixRequest struct {
Query string `json:"query"`
Language string `json:"language"`
ParseError string `json:"parseError"`
ExecutionError string `json:"executionError"`
}
type fixResponse struct {
SuggestedQuery string `json:"suggestedQuery"`
Explanation string `json:"explanation"`
Confidence string `json:"confidence"`
// Blocked mirrors costguard's assessment on the *suggested* query --
// task 4's stricter AI-track treatment: a reject-level suggestion is
// still shown (so the user understands what was tried and why it's
// not being offered outright) but the frontend must not present a
// plain accept-and-run action for it. CostWarnings is empty unless
// Blocked, or the suggestion has a lesser (warn-level) concern worth
// surfacing.
Blocked bool `json:"blocked"`
CostWarnings []string `json:"costWarnings,omitempty"`
}
func (h *Handler) handleFix(w http.ResponseWriter, r *http.Request) {
var req fixRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.Query) == "" {
writeError(w, http.StatusBadRequest, "query must not be empty")
return
}
if req.ParseError == "" && req.ExecutionError == "" {
writeError(w, http.StatusBadRequest, "parseError or executionError must be set")
return
}
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
defer cancel()
lang := orDefault(req.Language, "spl")
result, err := h.router.For(router.OpFix).Fix(ctx, provider.FixRequest{
Query: req.Query,
Language: lang,
ParseError: req.ParseError,
ExecutionError: req.ExecutionError,
Schema: h.schema.SchemaContext(r.Context()),
})
if err != nil {
writeError(w, http.StatusBadGateway, "fix failed: "+err.Error())
return
}
resp := fixResponse{
SuggestedQuery: result.SuggestedQuery,
Explanation: result.Explanation,
Confidence: string(result.Confidence),
}
if resp.SuggestedQuery != "" {
if plan, err := planner.Compile(resp.SuggestedQuery, planner.Language(lang), time.Now()); err == nil {
if assessment := costguard.Assess(plan); assessment.Level != costguard.LevelOK {
resp.CostWarnings = assessment.Reasons
resp.Blocked = assessment.Level == costguard.LevelReject
}
}
// A suggested query that itself fails to compile is left
// unassessed rather than treated as an error -- an unusual
// outcome (the model produced something that doesn't parse) the
// frontend can still show as a suggestion text, just without a
// cost assessment attached to it.
}
writeJSON(w, resp)
}
// ---- optimize ----
type optimizeRequest struct {
Query string `json:"query"`
Language string `json:"language"`
}
type optimizeResponse struct {
// Findings is always populated when costguard has anything to say --
// rule-based, instant, no model call needed to produce this part.
Findings []string `json:"findings"`
// Phrased is the AI-phrased version of Findings (task 8: "AI layer
// used mainly to phrase the suggestion clearly"). Empty if the
// provider is unavailable or fails -- graceful degradation, same as
// Complete: the raw Findings are still useful on their own, this is
// an enhancement layered on top, not a dependency.
Phrased string `json:"phrased"`
// SuggestedQuery is a mechanical rewrite, not model-generated --
// only populated for the one case this package can safely rewrite
// unambiguously (a missing time range; see suggestFix below). Other
// findings (an overly large time range, an unfiltered free-text
// search) get text-only guidance, honestly, rather than a guessed
// rewrite.
SuggestedQuery string `json:"suggestedQuery,omitempty"`
}
func (h *Handler) handleOptimize(w http.ResponseWriter, r *http.Request) {
var req optimizeRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.Query) == "" {
writeError(w, http.StatusBadRequest, "query must not be empty")
return
}
lang := orDefault(req.Language, "spl")
plan, err := planner.Compile(req.Query, planner.Language(lang), time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, "query does not compile: "+err.Error())
return
}
assessment := costguard.Assess(plan)
resp := optimizeResponse{Findings: assessment.Reasons}
if assessment.Level == costguard.LevelOK {
writeJSON(w, resp)
return
}
resp.SuggestedQuery = suggestMechanicalFix(req.Query, plan)
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
defer cancel()
if result, err := h.router.For(router.OpExplain).Explain(ctx, provider.ExplainRequest{
Query: req.Query,
Language: lang,
RuleFindings: assessment.Reasons,
}); err != nil {
h.logger.Warn("ai optimize phrasing failed", "error", err)
} else {
resp.Phrased = result.Explanation
}
writeJSON(w, resp)
}
// ---- translate (Track B, task 9) ----
type translateRequest struct {
NLQuery string `json:"nlQuery"`
}
type translateResponse struct {
Query string `json:"query"`
Confidence string `json:"confidence"`
LowConfidenceReason string `json:"lowConfidenceReason,omitempty"`
// Compiles is false when Query is non-empty but doesn't actually
// parse as pipe syntax -- a real, honest outcome (the model
// produced something invalid), not folded into "low confidence"
// since a model can be confident and still wrong about syntax.
// CompileError is set only then.
Compiles bool `json:"compiles"`
CompileError string `json:"compileError,omitempty"`
// Blocked/CostWarnings mirror handleFix's same-named fields exactly
// -- task 9's explicit requirement that translation results run
// through the shared cost guard "before returning it," same
// treatment as an AI-suggested fix gets, not a lesser one.
Blocked bool `json:"blocked"`
CostWarnings []string `json:"costWarnings,omitempty"`
}
func (h *Handler) handleTranslate(w http.ResponseWriter, r *http.Request) {
var req translateRequest
if !decodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.NLQuery) == "" {
writeError(w, http.StatusBadRequest, "nlQuery must not be empty")
return
}
ctx, cancel := context.WithTimeout(r.Context(), operationTimeout)
defer cancel()
result, err := h.router.For(router.OpTranslate).Translate(ctx, provider.TranslateRequest{
NLQuery: req.NLQuery,
Schema: h.schema.SchemaContext(r.Context()),
})
if err != nil {
writeError(w, http.StatusBadGateway, "translation failed: "+err.Error())
return
}
resp := translateResponse{
Query: result.Query,
Confidence: string(result.Confidence),
LowConfidenceReason: result.LowConfidenceReason,
}
if resp.Query != "" {
// Always pipe syntax -- provider.TranslateResult's own doc
// comment requires this (task 9: "prefer this over raw SQL...
// narrower, safer surface"), so this always compiles as SPL,
// never auto-detected/SQL.
plan, compileErr := planner.Compile(resp.Query, planner.SPL, time.Now())
if compileErr != nil {
resp.Compiles = false
resp.CompileError = compileErr.Error()
} else {
resp.Compiles = true
if assessment := costguard.Assess(plan); assessment.Level != costguard.LevelOK {
resp.CostWarnings = assessment.Reasons
resp.Blocked = assessment.Level == costguard.LevelReject
}
}
}
writeJSON(w, resp)
}
// suggestMechanicalFix handles exactly one case: no time bound at all.
// Prepending "earliest=-1h " is always syntactically safe (another
// AND'd base-search filter term, same as any other) and semantically
// the single most common real fix for this specific finding -- not
// attempted for any other finding (a too-large span, an unindexed
// free-text pattern), which don't have one unambiguous correct rewrite.
// Checked against the plan directly (not against costguard's Reasons
// text), so this stays correct even if that phrasing changes later.
func suggestMechanicalFix(originalQuery string, plan *ir.Plan) string {
if plan.RawSQL != "" {
return "" // no safe generic rewrite for arbitrary SQL
}
hasTimeBound := plan.TimeRange != nil && (!plan.TimeRange.From.IsZero() || !plan.TimeRange.To.IsZero())
if hasTimeBound {
return ""
}
return "earliest=-1h " + originalQuery
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
// ---- log-interaction (task 12) ----
type logInteractionRequest struct {
Operation string `json:"operation"`
Input string `json:"input"`
Output string `json:"output"`
Confidence string `json:"confidence"`
Accepted bool `json:"accepted"`
Edited bool `json:"edited"`
FinalQuery string `json:"finalQuery"`
}
var validInteractionOps = map[string]bool{"translate": true, "fix": true, "optimize": true}
// handleLogInteraction is called by the frontend at the moment a user
// takes a terminal action on a suggestion (accept-and-use or dismiss) --
// see InteractionLogger's doc comment for why this is a single
// frontend-reported event rather than a backend-correlated
// generation-plus-outcome pair. Fail-open, same posture
// queryapi.Handler.logAudit uses: a write failure here is logged
// server-side and otherwise ignored, never surfaced as an error to a
// user who just clicked a button -- audit-trail completeness is a real
// requirement, but it shouldn't be able to break the query bar.
func (h *Handler) handleLogInteraction(w http.ResponseWriter, r *http.Request) {
var req logInteractionRequest
if !decodeBody(w, r, &req) {
return
}
if !validInteractionOps[req.Operation] {
writeError(w, http.StatusBadRequest, `operation must be one of "translate", "fix", "optimize"`)
return
}
if h.interactions != nil {
err := h.interactions.LogInteraction(r.Context(), InteractionEntry{
Operation: req.Operation,
Input: req.Input,
Output: req.Output,
Confidence: req.Confidence,
Accepted: req.Accepted,
Edited: req.Edited,
FinalQuery: req.FinalQuery,
})
if err != nil {
h.logger.Error("ai interaction audit log write failed", "error", err)
}
}
w.WriteHeader(http.StatusNoContent)
}
+362
View File
@@ -0,0 +1,362 @@
package aiapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/sentry/sentry/api/ai/provider"
"github.com/sentry/sentry/api/ai/router"
)
type fakeProvider struct {
translateResult provider.TranslateResult
completeResult provider.CompleteResult
explainResult provider.ExplainResult
fixResult provider.FixResult
err error
gotExplainReq provider.ExplainRequest
}
func (f *fakeProvider) Translate(context.Context, provider.TranslateRequest) (provider.TranslateResult, error) {
return f.translateResult, f.err
}
func (f *fakeProvider) Complete(context.Context, provider.CompleteRequest) (provider.CompleteResult, error) {
return f.completeResult, f.err
}
func (f *fakeProvider) Explain(_ context.Context, req provider.ExplainRequest) (provider.ExplainResult, error) {
f.gotExplainReq = req
return f.explainResult, f.err
}
func (f *fakeProvider) Fix(context.Context, provider.FixRequest) (provider.FixResult, error) {
return f.fixResult, f.err
}
type fakeSchemaSource struct{}
func (fakeSchemaSource) SchemaContext(context.Context) provider.SchemaContext {
return provider.SchemaContext{Services: []string{"api"}}
}
func newTestHandler(p *fakeProvider) *Handler {
r := router.New(p)
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
}
type bytesDiscard struct{}
func (bytesDiscard) Write(p []byte) (int, error) { return len(p), nil }
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encoding request body: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleCompleteReturnsSuggestion(t *testing.T) {
p := &fakeProvider{completeResult: provider.CompleteResult{Suggestion: " | stats count"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp completeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Suggestion != " | stats count" {
t.Errorf("Suggestion = %q", resp.Suggestion)
}
}
func TestHandleCompleteDegradesGracefullyOnProviderError(t *testing.T) {
p := &fakeProvider{err: errors.New("provider down")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 even on provider failure (graceful degradation), body = %s", rec.Code, rec.Body.String())
}
var resp completeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Suggestion != "" {
t.Errorf("Suggestion = %q, want empty on provider failure", resp.Suggestion)
}
}
func TestHandleCompleteEmptyPrefixSkipsProviderCall(t *testing.T) {
p := &fakeProvider{err: errors.New("should not be called")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: " "})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestHandleExplainReturnsExplanation(t *testing.T) {
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "counts errors per host"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "severity=ERROR | stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp explainResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Explanation == "" {
t.Error("expected a non-empty explanation")
}
}
func TestHandleExplainEmptyQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: ""})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleExplainProviderErrorIsBadGateway(t *testing.T) {
p := &fakeProvider{err: errors.New("model unavailable")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "service=api"})
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", rec.Code)
}
}
func TestHandleFixMissingErrorFieldsIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "service=api"})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (neither parseError nor executionError set)", rec.Code)
}
}
func TestHandleFixAssessesSuggestedQueryCost(t *testing.T) {
// The provider suggests a fix that aggregates with no time bound --
// costguard should reject it (an aggregation gets no implicit row
// cap the way a raw-row fetch does), and the handler must mark it
// Blocked rather than silently offering it as runnable. Needs a real
// leading pipe stage -- bare words with no "|" parse as free-text
// search terms, not an aggregation, per the query grammar.
p := &fakeProvider{fixResult: provider.FixResult{
SuggestedQuery: "service=api | stats count by host",
Explanation: "removed the invalid field reference",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "bogus_field=1 | stats count by host", ParseError: "unknown field"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp fixResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Blocked {
t.Errorf("resp = %+v, want Blocked=true for an unbounded aggregation suggestion", resp)
}
if len(resp.CostWarnings) == 0 {
t.Error("expected non-empty CostWarnings")
}
}
func TestHandleFixBoundedSuggestionIsNotBlocked(t *testing.T) {
p := &fakeProvider{fixResult: provider.FixResult{
SuggestedQuery: "earliest=-1h | stats count by host",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "stats count by host", ParseError: "no time range"})
var resp fixResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Blocked {
t.Errorf("resp = %+v, want Blocked=false for a properly time-bounded suggestion", resp)
}
}
func TestHandleOptimizeNoFindingsForBoundedQuery(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "earliest=-1h severity=ERROR | stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) != 0 || resp.Phrased != "" {
t.Errorf("resp = %+v, want no findings for a bounded query", resp)
}
}
func TestHandleOptimizeSuggestsMechanicalFixForMissingTimeRange(t *testing.T) {
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "add a time range to avoid scanning everything"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) == 0 {
t.Error("expected at least one finding for an unbounded aggregation")
}
if resp.SuggestedQuery != "earliest=-1h stats count by host" {
t.Errorf("SuggestedQuery = %q", resp.SuggestedQuery)
}
if resp.Phrased == "" {
t.Error("expected a phrased explanation from the (fake) provider")
}
if len(p.gotExplainReq.RuleFindings) == 0 {
t.Error("expected Explain to have been called with RuleFindings set")
}
}
func TestHandleOptimizeDegradesGracefullyWhenPhraseFails(t *testing.T) {
p := &fakeProvider{err: errors.New("model down")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 even when phrasing fails, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) == 0 {
t.Error("Findings should still be populated (rule-based, no model needed) even if phrasing fails")
}
if resp.Phrased != "" {
t.Errorf("Phrased = %q, want empty when the provider fails", resp.Phrased)
}
}
func TestHandleOptimizeInvalidQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "| stats"})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 for an uncompilable query", rec.Code)
}
}
// ---- translate ----
func TestHandleTranslateEmptyNLQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: " "})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleTranslateProviderErrorIsBadGateway(t *testing.T) {
p := &fakeProvider{err: errors.New("model unavailable")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors in the last hour"})
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", rec.Code)
}
}
func TestHandleTranslateBoundedQueryCompilesCleanly(t *testing.T) {
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "earliest=-1h severity=ERROR | stats count by service",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors per service in the last hour"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Compiles || resp.CompileError != "" {
t.Errorf("resp = %+v, want Compiles=true, no CompileError", resp)
}
if resp.Blocked || len(resp.CostWarnings) != 0 {
t.Errorf("resp = %+v, want no cost warnings for a time-bounded query", resp)
}
if resp.Confidence != string(provider.ConfidenceHigh) {
t.Errorf("Confidence = %q", resp.Confidence)
}
}
func TestHandleTranslateUnboundedQueryIsBlocked(t *testing.T) {
// The model produced a syntactically valid but unbounded aggregation
// -- task 9's explicit requirement that translation results run
// through the same cost guard AI-suggested fixes do.
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "severity=ERROR | stats count by service",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors by service"})
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Compiles {
t.Fatalf("resp = %+v, want Compiles=true", resp)
}
if !resp.Blocked || len(resp.CostWarnings) == 0 {
t.Errorf("resp = %+v, want Blocked=true with cost warnings for an unbounded aggregation", resp)
}
}
func TestHandleTranslateNonCompilingQueryIsHonestlyReported(t *testing.T) {
// The model returned something that doesn't actually parse -- a
// real, distinct outcome from low confidence (a confident model can
// still produce invalid syntax); the handler must say so plainly,
// not silently drop it or crash.
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "| stats count",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "something odd"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (a non-compiling suggestion is a reportable outcome, not an HTTP error), body = %s", rec.Code, rec.Body.String())
}
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Compiles || resp.CompileError == "" {
t.Errorf("resp = %+v, want Compiles=false with a CompileError", resp)
}
}
func TestHandleTranslateLowConfidenceCarriesReason(t *testing.T) {
p := &fakeProvider{translateResult: provider.TranslateResult{
Confidence: provider.ConfidenceLow,
LowConfidenceReason: "not sure what 'weird stuff' refers to",
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "show me weird stuff"})
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Query != "" {
t.Errorf("Query = %q, want empty for a low-confidence non-answer", resp.Query)
}
if resp.Confidence != string(provider.ConfidenceLow) || resp.LowConfidenceReason == "" {
t.Errorf("resp = %+v, want low confidence with a reason", resp)
}
if resp.Compiles {
t.Errorf("resp = %+v, want Compiles=false when Query is empty", resp)
}
}
+256
View File
@@ -0,0 +1,256 @@
// Phase 7 task 13: end-to-end wiring tests distinct from handler_test.go
// and ai/provider/ollama's own tests. Those two files each cover one
// layer in isolation -- handler_test.go's fakeProvider satisfies
// provider.Provider directly, bypassing HTTP/JSON/prompt construction
// entirely; ollama_test.go exercises ollama.Client's wire-format parsing
// against a stub server, but never through aiapi.Handler's actual HTTP
// routes. Neither proves the seam between them actually works: a real
// *ollama.Client wired through *router.Router into a real *Handler,
// driven by real HTTP requests against the registered routes, with real
// planner.Compile/costguard.Assess in the loop.
//
// No live Ollama or model is needed or used -- mockOllamaServer stands
// in for Ollama's real /api/chat wire contract (same technique used for
// this phase's live browser verification, see /docs/phase-7-ai-design.md,
// just returning a fixed canned response instead of one selected by
// inspecting the prompt) with a deterministic canned JSON body, which is
// exactly what makes this suite fast and safe to run in CI -- see the
// "CI testability" section of the design doc for why testing against a
// real model is deliberately kept out of this suite instead.
package aiapi
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/sentry/sentry/api/ai/provider/ollama"
"github.com/sentry/sentry/api/ai/router"
)
// jsonBody marshals v for use as an http.Post body -- the integration
// tests below drive real HTTP requests against a real httptest.Server
// (not handler_test.go's doRequest/ResponseRecorder shortcut), since the
// point of this file is proving the routes are actually reachable over
// real HTTP, not just that Handler's methods dispatch correctly.
func jsonBody(t *testing.T, v any) io.Reader {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
return bytes.NewReader(b)
}
type fakeInteractionLogger struct {
entries []InteractionEntry
}
func (f *fakeInteractionLogger) LogInteraction(_ context.Context, entry InteractionEntry) error {
f.entries = append(f.entries, entry)
return nil
}
// mockOllamaServer returns an httptest.Server that answers any
// POST /api/chat with the given assistant message content, matching
// Ollama's real response envelope shape byte-for-byte (see
// ollama.go's chatResponse).
func mockOllamaServer(t *testing.T, content string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("unexpected request to %s, want /api/chat", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"message": map[string]string{"role": "assistant", "content": content},
})
}))
t.Cleanup(srv.Close)
return srv
}
func newIntegrationHandler(t *testing.T, ollamaContent string) *Handler {
t.Helper()
mock := mockOllamaServer(t, ollamaContent)
client := ollama.New(mock.URL, "test-model")
r := router.New(client)
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
}
// TestIntegrationTranslateEndToEnd proves the full path -- HTTP request
// in, real ollama.Client HTTP call out to the mock, real JSON parsing,
// real planner.Compile, real costguard.Assess, HTTP response out --
// works for a query that should pass cleanly (time-bounded, no
// aggregation).
func TestIntegrationTranslateEndToEnd(t *testing.T) {
h := newIntegrationHandler(t, `{"query":"earliest=-1h severity=ERROR","confidence":"high"}`)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
jsonBody(t, map[string]string{"nlQuery": "errors in the last hour"}))
if err != nil {
t.Fatalf("POST /ai/translate: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got translateResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.Query != "earliest=-1h severity=ERROR" {
t.Errorf("query = %q", got.Query)
}
if !got.Compiles {
t.Error("compiles = false, want true -- this query is valid pipe syntax")
}
if got.Blocked {
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
}
if got.Confidence != "high" {
t.Errorf("confidence = %q, want high", got.Confidence)
}
}
// TestIntegrationTranslateBlockedByCostGuard proves costguard is
// actually reached through the full HTTP stack, not just unit-tested
// against costguard.Assess in isolation -- an unbounded aggregation
// (stats, no time filter) must come back Blocked.
func TestIntegrationTranslateBlockedByCostGuard(t *testing.T) {
h := newIntegrationHandler(t, `{"query":"severity=ERROR | stats count by service","confidence":"high"}`)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
jsonBody(t, map[string]string{"nlQuery": "error count by service"}))
if err != nil {
t.Fatalf("POST /ai/translate: %v", err)
}
defer resp.Body.Close()
var got translateResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if !got.Compiles {
t.Fatalf("compiles = false, want true: %s", got.CompileError)
}
if !got.Blocked {
t.Error("blocked = false, want true -- unbounded aggregation should be rejected by costguard")
}
if len(got.CostWarnings) == 0 {
t.Error("costWarnings is empty, want at least one reason")
}
}
// TestIntegrationFixEndToEnd proves the same seam for /ai/fix.
func TestIntegrationFixEndToEnd(t *testing.T) {
h := newIntegrationHandler(t, `{"suggested_query":"earliest=-1h severity=ERROR","explanation":"added a time bound","confidence":"high"}`)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Post(srv.URL+"/ai/fix", "application/json", jsonBody(t, map[string]string{
"query": "severity=ERROR",
"language": "spl",
"executionError": "query timed out: no time range specified",
}))
if err != nil {
t.Fatalf("POST /ai/fix: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got fixResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.SuggestedQuery != "earliest=-1h severity=ERROR" {
t.Errorf("suggestedQuery = %q", got.SuggestedQuery)
}
if got.Blocked {
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
}
}
// TestIntegrationCompleteEndToEnd proves the same seam for /ai/complete
// (Track A's ghost-text autocomplete).
func TestIntegrationCompleteEndToEnd(t *testing.T) {
h := newIntegrationHandler(t, `{"suggestion":" severity=ERROR"}`)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Post(srv.URL+"/ai/complete", "application/json", jsonBody(t, map[string]string{
"queryPrefix": "service=api ",
"language": "spl",
}))
if err != nil {
t.Fatalf("POST /ai/complete: %v", err)
}
defer resp.Body.Close()
var got completeResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.Suggestion != " severity=ERROR" {
t.Errorf("suggestion = %q", got.Suggestion)
}
}
// TestIntegrationLogInteractionEndToEnd proves /ai/log-interaction's
// full HTTP decode+validate+dispatch path, using a fake InteractionLogger
// (an in-process Go fake is the right seam here, not another mock HTTP
// server -- the real implementation is enterprise/internal/audit, which
// needs a live Postgres and is covered by that package's own tests).
func TestIntegrationLogInteractionEndToEnd(t *testing.T) {
logger := &fakeInteractionLogger{}
r := router.New(&fakeProvider{})
h := NewHandler(slog.New(slog.NewTextHandler(bytesDiscard{}, nil)), r, fakeSchemaSource{}, nil, logger)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Post(srv.URL+"/ai/log-interaction", "application/json", jsonBody(t, map[string]any{
"operation": "translate",
"input": "errors in the last hour",
"output": "earliest=-1h severity=ERROR",
"confidence": "high",
"accepted": true,
"edited": false,
"finalQuery": "earliest=-1h severity=ERROR",
}))
if err != nil {
t.Fatalf("POST /ai/log-interaction: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want 204", resp.StatusCode)
}
if len(logger.entries) != 1 {
t.Fatalf("got %d logged entries, want 1", len(logger.entries))
}
if logger.entries[0].Operation != "translate" || !logger.entries[0].Accepted {
t.Errorf("logged entry = %+v", logger.entries[0])
}
}