Files
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

189 lines
6.5 KiB
Go

// Command benchmark-fixture pushes a large, realistically varied
// synthetic dataset directly to ingest's gRPC endpoint, batched, so
// Phase 2's "modest dataset" query-latency benchmark
// (/docs/phase-2-runbook.md) has real data to measure against instead of
// an asserted number. Distinct from /hack/windows-fixture: that one
// sends a handful of realistic Windows events to test pipeline
// *correctness*; this one sends a lot of Linux-shaped events to test
// query *performance* at volume.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"math/rand"
"os"
"sync"
"sync/atomic"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
logsv1 "github.com/cairnobs/cairnobs/proto/sentry/logs/v1"
)
var (
services = []string{"api", "web", "worker", "db", "auth"}
hosts = []string{"host-01", "host-02", "host-03", "host-04", "host-05", "host-06", "host-07", "host-08"}
severites = []logsv1.Severity{
logsv1.Severity_SEVERITY_DEBUG,
logsv1.Severity_SEVERITY_INFO,
logsv1.Severity_SEVERITY_INFO,
logsv1.Severity_SEVERITY_INFO,
logsv1.Severity_SEVERITY_WARN,
logsv1.Severity_SEVERITY_ERROR,
}
// A mix of messages, some containing terms worth full-text
// searching for (connection refused, timeout) so the benchmark's
// text-search-plus-aggregation case has real matches to find, not
// just structured rows.
messages = []string{
"request completed successfully",
"connection refused by upstream",
"request timeout after 30s",
"cache miss, falling back to database",
"connection refused: too many open connections",
"user authentication succeeded",
"slow query detected: timeout approaching",
"health check passed",
"retrying after connection refused error",
"scheduled job completed",
}
)
func main() {
addr := flag.String("addr", "localhost:4317", "ingest gRPC address")
caFile := flag.String("ca", "../dev-certs/out/ca.pem", "CA cert path")
certFile := flag.String("cert", "../dev-certs/out/client.pem", "client cert path")
keyFile := flag.String("key", "../dev-certs/out/client-key.pem", "client key path")
count := flag.Int("count", 1_000_000, "total number of records to generate")
batchSize := flag.Int("batch-size", 1000, "records per PushBatch call")
concurrency := flag.Int("concurrency", 16, "concurrent PushBatch calls in flight")
timeSpread := flag.Duration("time-spread", 0, "spread record timestamps uniformly at random across [now-spread, now] instead of all landing at ~now -- 0 (default) preserves the original all-at-now behavior the volume benchmark wants; a real duration (e.g. 6h) is for building a demo/exploration dataset with a real time axis")
includeFatal := flag.Bool("include-fatal", false, "include a low-frequency FATAL severity in the mix (off by default -- the volume benchmark's severity mix is deliberately unchanged unless asked for)")
flag.Parse()
recordSeverities := severites
if *includeFatal {
// Triple the existing 6-entry pool and append FATAL once, so it
// lands at roughly 1-in-19 -- rare relative to ERROR, matching
// how a real incident's FATAL/critical rate compares to its
// error rate, not a coin-flip mix.
recordSeverities = append(append(append([]logsv1.Severity{}, severites...), severites...), severites...)
recordSeverities = append(recordSeverities, logsv1.Severity_SEVERITY_FATAL)
}
tlsConf, err := loadTLSConfig(*caFile, *certFile, *keyFile)
if err != nil {
fmt.Fprintln(os.Stderr, "loading TLS config:", err)
os.Exit(1)
}
// One shared connection: gRPC multiplexes concurrent RPCs over HTTP/2
// streams on a single connection, so concurrency here comes from
// concurrent PushBatch calls, not from opening more connections.
conn, err := grpc.NewClient(*addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsConf)))
if err != nil {
fmt.Fprintln(os.Stderr, "dialing ingest:", err)
os.Exit(1)
}
defer conn.Close()
client := logsv1.NewLogIngestClient(conn)
numBatches := (*count + *batchSize - 1) / *batchSize
batchIndexes := make(chan int, numBatches)
for i := 0; i < numBatches; i++ {
batchIndexes <- i
}
close(batchIndexes)
var sent atomic.Int64
var wg sync.WaitGroup
start := time.Now()
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for batchIdx := range batchIndexes {
offset := batchIdx * *batchSize
n := *batchSize
if remaining := *count - offset; remaining < n {
n = remaining
}
records := make([]*logsv1.LogRecord, n)
for i := range records {
records[i] = randomRecord(recordSeverities, *timeSpread)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
resp, err := client.PushBatch(ctx, &logsv1.PushBatchRequest{
BatchId: fmt.Sprintf("benchmark-%d", batchIdx),
Records: records,
})
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "PushBatch %d failed: %v\n", batchIdx, err)
os.Exit(1)
}
total := sent.Add(int64(resp.GetAccepted()))
if total%int64(*batchSize*50) < int64(*batchSize) {
elapsed := time.Since(start)
rate := float64(total) / elapsed.Seconds()
fmt.Printf("sent %d/%d (%.0f records/sec)\n", total, *count, rate)
}
}
}()
}
wg.Wait()
elapsed := time.Since(start)
total := sent.Load()
fmt.Printf("done: %d records in %s (%.0f records/sec)\n", total, elapsed, float64(total)/elapsed.Seconds())
}
func randomRecord(recordSeverities []logsv1.Severity, timeSpread time.Duration) *logsv1.LogRecord {
ts := time.Now()
if timeSpread > 0 {
ts = ts.Add(-time.Duration(rand.Int63n(int64(timeSpread))))
}
return &logsv1.LogRecord{
TimestampUnixNano: ts.UnixNano(),
Host: hosts[rand.Intn(len(hosts))],
Service: services[rand.Intn(len(services))],
Severity: recordSeverities[rand.Intn(len(recordSeverities))],
Message: messages[rand.Intn(len(messages))],
Attributes: map[string]string{
"status": fmt.Sprintf("%d", []int{200, 200, 200, 301, 404, 500, 503}[rand.Intn(7)]),
"latency_ms": fmt.Sprintf("%d", rand.Intn(2000)),
},
}
}
func loadTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) {
caPEM, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("reading CA cert %s: %w", caFile, err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("no valid certificates found in %s", caFile)
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading client cert/key: %w", err)
}
return &tls.Config{
RootCAs: caPool,
Certificates: []tls.Certificate{cert},
}, nil
}