Files
cairnobs/api/ai/costguard/costguard.go
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

142 lines
5.8 KiB
Go

// Package costguard is the shared cost/safety check task 4 asked for --
// no such mechanism existed anywhere in Phase 2/3's compiler before this
// (confirmed by reading planner.go/sql.go before writing this: plan.TimeRange
// can be entirely unset, and nothing downstream rejects that). Built here
// as a standalone, pure function operating on the same ir.Plan every
// query -- hand-written or AI-generated -- already compiles to, so there
// is exactly one cost check, not one per code path.
//
// This package does not decide what a caller *does* with a Reject-level
// Assessment -- see /docs/phase-7-ai-design.md's "Cost/safety guard"
// section for how the AI tracks and the existing /query handler each
// apply this differently (AI suggestions withhold a Reject-level
// suggestion from being offered as directly runnable; the existing
// /query handler surfaces the same assessment as a non-blocking warning,
// deliberately not a new hard block on hand-written queries this phase
// didn't set out to change).
package costguard
import (
"regexp"
"strings"
"time"
"github.com/cairnobs/cairnobs/api/internal/querylang/ir"
)
type Level string
const (
LevelOK Level = "ok"
LevelWarn Level = "warn"
LevelReject Level = "reject"
)
type Assessment struct {
Level Level
Reasons []string
}
// maxReasonableSpan and the two below are first-pass heuristic
// thresholds, not benchmarked against a production-scale ClickHouse
// cluster -- this environment's own data is far smaller than what these
// numbers are meant to guard against. Flagged explicitly in
// /docs/phase-7-ai-design.md rather than presented as tuned. Revisit
// once there's real cluster-size data to check them against.
const maxReasonableSpan = 90 * 24 * time.Hour
// rawSQLTimestampRe is a best-effort, deliberately loose check for
// *some* mention of the timestamp column in a raw SQL statement's WHERE
// clause -- not a real SQL parser. A false negative here (a query that
// does filter by time in a way this regex doesn't recognize) just means
// an unnecessary Warn, not a Reject, so being loose-but-safe is the
// right failure direction. Raw SQL genuinely can't get the same
// structural guarantee the IR-based checks below get, and this package
// says so rather than pretending otherwise.
var rawSQLTimestampRe = regexp.MustCompile(`(?i)\btimestamp\b\s*[<>=]`)
// Assess evaluates one compiled plan. Never returns an error -- a plan
// that reached this point already parsed successfully; this is a
// judgment call about cost, not a correctness check.
func Assess(plan *ir.Plan) Assessment {
if plan.RawSQL != "" {
return assessRawSQL(plan.RawSQL)
}
return assessIR(plan)
}
func assessRawSQL(sql string) Assessment {
if rawSQLTimestampRe.MatchString(sql) {
return Assessment{Level: LevelOK}
}
return Assessment{
Level: LevelWarn,
Reasons: []string{
"no obvious timestamp filter found in this raw SQL -- this is a best-effort text check, not a real parse, so it may be wrong in either direction, but if this query has no time bound it could scan the full table's history",
},
}
}
func assessIR(plan *ir.Plan) Assessment {
var reasons []string
level := LevelOK
hasTimeBound := plan.TimeRange != nil && (!plan.TimeRange.From.IsZero() || !plan.TimeRange.To.IsZero())
if !hasTimeBound {
switch {
case plan.Aggregation != nil:
// Unlike a raw-row query, an aggregation gets no implicit
// row cap from the executor regardless of plan.Limit --
// see executor/sql.go's buildSQL: the defaultRowLimit
// safety net only applies `else if plan.Aggregation ==
// nil`. An unbounded aggregation is never merely
// "capped but slow" the way a raw-row fetch is.
level = LevelReject
reasons = append(reasons, "no time range filter, and this query aggregates -- every matching row across the table's entire history must be scanned to compute the aggregate, regardless of how small the output is")
default:
// A raw-row query with no explicit Limit still gets
// executor/sql.go's defaultRowLimit=100 safety net applied
// automatically -- it is not actually unbounded output,
// just potentially an expensive scan to find those rows
// without a time bound to narrow the search. Confirmed by
// reading buildSQL directly, not assumed: this is the same
// risk level whether plan.Limit is nil or explicitly set,
// so both cases share one Warn, not a Reject for one and a
// Warn for the other.
level = LevelWarn
reasons = append(reasons, "no time range filter -- results are capped (explicitly, or by the default 100-row limit), but ClickHouse may still need to scan well beyond that many rows to find them without a time bound to narrow the search")
}
if len(plan.TextSearch) > 0 {
reasons = append(reasons, "the free-text search stage is bounded by the existing 5,000-record Tantivy prefilter cap regardless of time range, which partially limits how bad this is, but doesn't remove the underlying ClickHouse-side cost")
}
} else if !plan.TimeRange.From.IsZero() && !plan.TimeRange.To.IsZero() {
span := plan.TimeRange.To.Sub(plan.TimeRange.From)
if span > maxReasonableSpan {
level = maxLevel(level, LevelWarn)
reasons = append(reasons, "time range spans more than 90 days -- this may be slow depending on data volume")
}
}
return Assessment{Level: level, Reasons: reasons}
}
func maxLevel(a, b Level) Level {
rank := map[Level]int{LevelOK: 0, LevelWarn: 1, LevelReject: 2}
if rank[b] > rank[a] {
return b
}
return a
}
// Summary renders an Assessment as one human-readable line, for
// embedding in an AI-suggestion response or a /query warnings entry --
// one shared rendering so the two callers don't independently invent
// slightly different phrasing for the same underlying reasons.
func Summary(a Assessment) string {
if a.Level == LevelOK || len(a.Reasons) == 0 {
return ""
}
return strings.Join(a.Reasons, "; ")
}