From 996fba144b30cf7150768ebd2041dee6221e3376 Mon Sep 17 00:00:00 2001
From: david <58231312+davseby@users.noreply.github.com>
Date: Wed, 26 Aug 2026 17:00:22 +0300
Subject: [PATCH] add built-in demo prometheus to core
---
.github/workflows/qa-datagen.yml | 74 ++
CLAUDE.md | 4 +-
Makefile | 9 +-
datagen/.golangci.yml | 324 +++++++++
datagen/Makefile | 26 +
datagen/cmd/main.go | 2 +
datagen/cmd/main_test.go | 21 +
datagen/go.mod | 44 +-
datagen/go.sum | 115 +++-
datagen/internal/demodata/demodata.go | 205 ++++--
datagen/internal/demodata/demodata_test.go | 262 +++++++
datagen/internal/gen/manager.go | 153 ++--
datagen/internal/gen/manager_test.go | 303 ++++++++
datagen/internal/mariademo/mariademo.go | 73 +-
datagen/internal/mariademo/mariademo_test.go | 566 +++++++++++++++
datagen/internal/mockmetrics/rand.go | 23 +-
datagen/internal/mockmetrics/rand_test.go | 250 +++++++
datagen/internal/pgdemo/pgdemo.go | 64 +-
datagen/internal/pgdemo/pgdemo_test.go | 547 +++++++++++++++
datagen/internal/server/server.go | 10 +-
datagen/internal/server/server_test.go | 206 ++++++
docker/demo/prometheus/prometheus.yaml | 13 -
docker/docker-compose.dev.yaml | 16 -
docker/env/core.example.env | 1 -
e2e/docker-compose.yaml | 8 +-
server/CLAUDE.md | 25 +-
server/core/.golangci.yml | 15 +
server/core/cmd/core/main.go | 7 +-
server/core/go.mod | 140 ++--
server/core/go.sum | 469 +++++++------
.../_mock/meilisearch_index_manager.go | 39 +-
.../_mock/meilisearch_service_manager.go | 651 +++++++++++++++++-
server/core/internal/apps/github/content.go | 2 +-
.../core/internal/apps/github/content_test.go | 2 +-
server/core/internal/apps/github/manager.go | 30 +-
.../core/internal/apps/github/manager_test.go | 23 +-
server/core/internal/apps/slack/manager.go | 3 -
.../internal/assistant/provider/gemini.go | 12 +
.../assistant/provider/gemini_test.go | 20 +-
.../core/internal/datasource/demo/client.go | 261 +++++++
.../internal/datasource/demo/client_test.go | 465 +++++++++++++
server/core/internal/datasource/demo/demo.go | 28 +
.../internal/datasource/demo/demo_test.go | 31 +
.../internal/datasource/demo/queryable.go | 245 +++++++
.../datasource/demo/queryable_test.go | 279 ++++++++
.../core/internal/datasource/demo/registry.go | 556 +++++++++++++++
.../internal/datasource/demo/registry_test.go | 330 +++++++++
.../core/internal/datasource/demo/series.go | 215 ++++++
.../internal/datasource/demo/series_test.go | 283 ++++++++
.../datasource/processor/postgresql.go | 18 +-
.../datasource/processor/postgresql_test.go | 24 +-
.../datasource/processor/prometheus.go | 7 +-
server/core/internal/datasource/runner.go | 33 +-
.../core/internal/datasource/runner_test.go | 59 +-
.../internal/server/internal/github/http.go | 2 +-
.../core/internal/server/internal/org/http.go | 47 +-
.../internal/server/internal/org/http_test.go | 27 +-
.../internal/server/internal/slack/event.go | 2 +-
.../internal/server/internal/slack/http.go | 41 +-
.../server/internal/slack/http_test.go | 12 +
server/core/internal/server/router.go | 2 +-
server/core/internal/server/server.go | 4 -
server/core/internal/server/server_test.go | 9 +-
server/core/internal/storage/client_test.go | 55 +-
server/core/internal/storage/object.go | 16 +-
server/core/pkg/sentryutil/sentryutil.go | 3 +
server/core/pkg/sentryutil/sentryutil_test.go | 1 +
67 files changed, 7108 insertions(+), 704 deletions(-)
create mode 100644 .github/workflows/qa-datagen.yml
create mode 100644 datagen/.golangci.yml
create mode 100644 datagen/cmd/main_test.go
create mode 100644 datagen/internal/demodata/demodata_test.go
create mode 100644 datagen/internal/gen/manager_test.go
create mode 100644 datagen/internal/mariademo/mariademo_test.go
create mode 100644 datagen/internal/mockmetrics/rand_test.go
create mode 100644 datagen/internal/pgdemo/pgdemo_test.go
create mode 100644 datagen/internal/server/server_test.go
delete mode 100644 docker/demo/prometheus/prometheus.yaml
create mode 100644 server/core/internal/datasource/demo/client.go
create mode 100644 server/core/internal/datasource/demo/client_test.go
create mode 100644 server/core/internal/datasource/demo/demo.go
create mode 100644 server/core/internal/datasource/demo/demo_test.go
create mode 100644 server/core/internal/datasource/demo/queryable.go
create mode 100644 server/core/internal/datasource/demo/queryable_test.go
create mode 100644 server/core/internal/datasource/demo/registry.go
create mode 100644 server/core/internal/datasource/demo/registry_test.go
create mode 100644 server/core/internal/datasource/demo/series.go
create mode 100644 server/core/internal/datasource/demo/series_test.go
diff --git a/.github/workflows/qa-datagen.yml b/.github/workflows/qa-datagen.yml
new file mode 100644
index 00000000..6ece2e50
--- /dev/null
+++ b/.github/workflows/qa-datagen.yml
@@ -0,0 +1,74 @@
+name: QA
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - datagen/**
+ - .github/workflows/qa-datagen.yml
+ pull_request:
+ branches:
+ - main
+ paths:
+ - datagen/**
+ - .github/workflows/qa-datagen.yml
+
+# a newer push supersedes the still-running workflow of the stale commit
+# and cancels it. The group carries a hardcoded per-file prefix because
+# every QA workflow shares the display name "QA", so github.workflow
+# would put them all in one group and let a run of one workflow cancel
+# an in-flight run of another.
+concurrency:
+ group: qa-datagen-${{ github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ lint:
+ name: Linting datagen
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the code
+ uses: actions/checkout@v7
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: datagen/go.mod
+ cache-dependency-path: datagen/go.sum
+
+ - name: Installing dependencies
+ run: go mod download
+ working-directory: datagen
+
+ - name: Run golangci-lint
+ uses: golangci/golangci-lint-action@v9
+ with:
+ version: v2.13
+ working-directory: datagen
+
+ test:
+ name: Unit testing datagen
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out the code
+ uses: actions/checkout@v7
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: datagen/go.mod
+ cache-dependency-path: datagen/go.sum
+
+ - name: Installing dependencies
+ run: go mod download
+ working-directory: datagen
+
+ # the pgdemo and mariademo suites run against throwaway containers,
+ # which the ubuntu runners provide a docker daemon for out of the box.
+ - name: Run unit tests
+ run: make check-coverage
+ working-directory: datagen
diff --git a/CLAUDE.md b/CLAUDE.md
index 3985cc5e..f11577c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,7 +77,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only.
- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the docker-compose stack it drives. Not shipped; it exercises the composed product through a real backend built from this repo. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md).
- `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails.
-- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres/prometheus).
+- `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres).
**One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file. The exception is `web/packages/lezer-promql/`, which is a vendored upstream fork and keeps the ignore file it ships with.
@@ -93,7 +93,7 @@ make start # build images + run the dev stack in the background
make dev # backend containers + web dev server on the host (hot reload, :3000)
make stop # stop the dev stack
-make lint # fix lint/format/type issues in web, auth-realtime, core, e2e
+make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e
make check-lint # the same gates, verification only
make e2e # one-shot: build, run playwright, tear the e2e stack down
diff --git a/Makefile b/Makefile
index 4ba77344..153426cd 100644
--- a/Makefile
+++ b/Makefile
@@ -54,15 +54,15 @@ e2e-stack-build:
e2e-stack-stop:
@$(QUIET) "stopping the stack" $(E2E_COMPOSE) down -v
-# lint gates of every component that has one, from one place. Fixing and
-# checking mirror each component's own vocabulary: web, auth-realtime and
-# e2e pair lint/check-lint, and core wraps golangci-lint. Datagen has no
-# lint setup and is deliberately absent.
+# lint gates of every component, from one place. Fixing and checking mirror
+# each component's own vocabulary: web, auth-realtime and e2e pair
+# lint/check-lint, and core and datagen wrap golangci-lint.
.PHONY: lint
lint:
@$(QUIET) "linting web" sh -c 'cd web && pnpm run lint'
@$(QUIET) "linting auth-realtime" sh -c 'cd server/auth-realtime && pnpm run lint'
@$(QUIET) "linting core" sh -c 'cd server/core && make lint'
+ @$(QUIET) "linting datagen" sh -c 'cd datagen && make lint'
@$(QUIET) "linting e2e" sh -c 'cd e2e && pnpm run lint'
.PHONY: check-lint
@@ -70,6 +70,7 @@ check-lint:
@$(QUIET) "checking web" sh -c 'cd web && pnpm run check-lint'
@$(QUIET) "checking auth-realtime" sh -c 'cd server/auth-realtime && pnpm run check-lint'
@$(QUIET) "checking core" sh -c 'cd server/core && make check-lint'
+ @$(QUIET) "checking datagen" sh -c 'cd datagen && make check-lint'
@$(QUIET) "checking e2e" sh -c 'cd e2e && pnpm run check-lint'
.PHONY: setup
diff --git a/datagen/.golangci.yml b/datagen/.golangci.yml
new file mode 100644
index 00000000..b13ab33b
--- /dev/null
+++ b/datagen/.golangci.yml
@@ -0,0 +1,324 @@
+version: "2"
+linters:
+ enable:
+ - arangolint
+ - asasalint
+ - asciicheck
+ - bidichk
+ - bodyclose
+ - canonicalheader
+ - clickhouselint
+ - contextcheck
+ - copyloopvar
+ - cyclop
+ - depguard
+ - dogsled
+ - dupword
+ - durationcheck
+ - embeddedstructfieldcheck
+ - err113
+ - errcheck
+ - errchkjson
+ - errname
+ - errorlint
+ - exhaustive
+ - exptostd
+ - fatcontext
+ - forbidigo
+ - forcetypeassert
+ - ginkgolinter
+ - gocheckcompilerdirectives
+ - gochecknoglobals
+ - gochecknoinits
+ - gochecksumtype
+ - gocognit
+ - goconst
+ - gocritic
+ - gocyclo
+ - godoclint
+ - godot
+ - gomoddirectives
+ - gomodguard_v2
+ - goprintffuncname
+ - gosec
+ - govet
+ - importas
+ - ineffassign
+ - inamedparam
+ - interfacebloat
+ - intrange
+ - iotamixing
+ - loggercheck
+ - maintidx
+ - mirror
+ - misspell
+ - mnd
+ - modernize
+ - musttag
+ - nakedret
+ - nestif
+ - nilerr
+ - nilnesserr
+ - nilnil
+ - noctx
+ - nolintlint
+ - nosprintfhostport
+ - paralleltest
+ - perfsprint
+ - prealloc
+ - predeclared
+ - promlinter
+ - protogetter
+ - reassign
+ - recvcheck
+ - revive
+ - rowserrcheck
+ - sloglint
+ - spancheck
+ - sqlclosecheck
+ - staticcheck
+ - tagalign
+ - tagliatelle
+ - testableexamples
+ - testifylint
+ - thelper
+ - unconvert
+ - unparam
+ - unqueryvet
+ - unused
+ - usestdlibvars
+ - usetesting
+ - wastedassign
+ - whitespace
+ - wsl_v5
+ - zerologlint
+ disable:
+ - containedctx
+ - decorder
+ - dupl
+ - exhaustruct
+ - funcorder
+ - funlen
+ - godox
+ - goheader
+ - gosmopolitan
+ - grouper
+ - iface
+ - ireturn
+ - lll
+ - makezero
+ - nlreturn
+ - noinlineerr
+ - nonamedreturns
+ - testpackage
+ - tparallel
+ - varnamelen
+ - wrapcheck
+ settings:
+ cyclop:
+ max-complexity: 25
+ errcheck:
+ check-blank: true
+ depguard:
+ rules:
+ main:
+ deny:
+ - pkg: gotest.tools/assert
+ - pkg: github.com/bmizerany/assert
+ exhaustive:
+ default-signifies-exhaustive: true
+ goconst:
+ min-occurrences: 5
+ numbers: true
+ gomoddirectives:
+ # datagen builds against the core module in this same repository, so
+ # the local replace is how the two are wired, not a leftover.
+ replace-local: true
+ forbidigo:
+ forbid:
+ # timeutil.Now is mockable; time.Now is not, which makes every
+ # clock-dependent path untestable through it.
+ - pattern: ^time\.Now$
+ msg: use timeutil.Now instead
+ gocritic:
+ disabled-checks:
+ - hugeParam
+ - unnamedResult
+ - sloppyReassign
+ - rangeValCopy
+ enabled-tags:
+ - diagnostic
+ - style
+ - performance
+ - experimental
+ - opinionated
+ govet:
+ disable:
+ - fieldalignment
+ enable-all: true
+ nakedret:
+ max-func-lines: 20
+ paralleltest:
+ ignore-missing: true
+ prealloc:
+ for-loops: true
+ promlinter:
+ strict: true
+ recvcheck:
+ exclusions:
+ - '*.Value'
+ - '*.Scan'
+ revive:
+ enable-all-rules: true
+ rules:
+ - name: unchecked-type-assertion
+ disabled: true
+ - name: import-alias-naming
+ disabled: true
+ - name: unnecessary-stmt
+ disabled: true
+ - name: argument-limit
+ disabled: true
+ - name: function-result-limit
+ disabled: true
+ - name: cyclomatic
+ severity: warning
+ disabled: true
+ - name: line-length-limit
+ severity: warning
+ disabled: true
+ - name: cognitive-complexity
+ severity: warning
+ disabled: true
+ - name: confusing-naming
+ disabled: true
+ - name: confusing-results
+ disabled: true
+ - name: unhandled-error
+ disabled: true
+ - name: max-public-structs
+ disabled: true
+ - name: function-length
+ disabled: true
+ - name: banned-characters
+ disabled: true
+ - name: file-header
+ disabled: true
+ - name: add-constant
+ disabled: true
+ - name: comment-spacings
+ disabled: true
+ - name: unused-receiver
+ disabled: true
+ - name: nested-structs
+ disabled: true
+ - name: flag-parameter
+ disabled: true
+ - name: empty-lines
+ disabled: true
+ - name: if-return
+ disabled: true
+ - name: enforce-switch-style
+ disabled: true
+ rowserrcheck:
+ packages:
+ - github.com/jmoiron/sqlx
+ tagalign:
+ align: false
+ sort: true
+ order:
+ - json
+ - db
+ tagliatelle:
+ case:
+ rules:
+ json: camel
+ testifylint:
+ disable:
+ - require-error
+ thelper:
+ test:
+ begin: false
+ wsl_v5:
+ allow-first-in-block: true
+ exclusions:
+ generated: lax
+ rules:
+ - linters:
+ - forbidigo
+ path: cmd
+ text: fmt.Print
+ - linters:
+ - forbidigo
+ path: _test.go
+ text: time.Now
+ - linters:
+ - gochecknoglobals
+ text: ^_.+
+ - linters:
+ - forcetypeassert
+ - gochecknoinits
+ - maintidx
+ - promlinter
+ - usestdlibvars
+ path: _test.go
+ - linters:
+ - gocritic
+ text: 'unnecessaryDefer: defer'
+ - linters:
+ - err113
+ text: do not define dynamic errors
+ - linters:
+ - gosec
+ path: _test.go
+ text: G112
+ - linters:
+ - gosec
+ text: G115
+ - linters:
+ - musttag
+ text: should be annotated with the `db`
+ - linters:
+ - goconst
+ text: string `(true|false)` has
+ # the gauge tables are tuned sampling parameters read as a block: every
+ # bound, target and spike chance is one row of demo tuning, not a
+ # threshold logic branches on. Naming each would bury the tables, and a
+ # shared constant would tie together gauges that only coincide today.
+ - linters:
+ - goconst
+ - mnd
+ path: internal/gen
+ - linters:
+ - dupword
+ source: '^//go:generate '
+ - linters:
+ - usetesting
+ text: could be replaced by t.Context()
+ - linters:
+ - revive
+ text: Import alias "v1" is redundant
+ - linters:
+ - cyclop
+ - goconst
+ - noctx
+ path: (.+)_test\.go
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+formatters:
+ enable:
+ - gci
+ - gofmt
+ - gofumpt
+ - goimports
+ settings:
+ gofumpt:
+ extra-rules: true
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/datagen/Makefile b/datagen/Makefile
index aeb8c0ac..0272b82b 100644
--- a/datagen/Makefile
+++ b/datagen/Makefile
@@ -1,3 +1,7 @@
+# Minimum total statement coverage. Set from the measured baseline and
+# ratcheted up as suites grow — never lowered.
+COVERAGE_MIN = 92.9
+
# Build the Docker image
# dockers_v2 snapshot builds load into the local daemon with an arch suffix;
# retag so the dev compose stack can reference a stable :dev tag.
@@ -5,3 +9,25 @@
build:
goreleaser release --snapshot --clean
docker tag ghcr.io/oxynote/datagen:dev-amd64 ghcr.io/oxynote/datagen:dev
+
+# Run all tests with the race detector.
+.PHONY: test
+test:
+ @go test -race ./...
+
+# Run all tests with coverage and fail below the minimum.
+.PHONY: check-coverage
+check-coverage:
+ @go test -race -coverprofile=coverage.out ./...
+ @go tool cover -func=coverage.out | tail -n 1 | awk -v min=$(COVERAGE_MIN) \
+ '{ print; if ($$3 + 0 < min) { printf "total coverage is below the %s%% minimum\n", min; exit 1 } }'
+
+# Run the linters and apply automatic fixes.
+.PHONY: lint
+lint:
+ @golangci-lint run --fix --timeout 5m
+
+# Run the linters without modifying anything.
+.PHONY: check-lint
+check-lint:
+ @golangci-lint run --timeout 5m
diff --git a/datagen/cmd/main.go b/datagen/cmd/main.go
index ef909171..6b0f890b 100644
--- a/datagen/cmd/main.go
+++ b/datagen/cmd/main.go
@@ -1,3 +1,5 @@
+// Package main runs the demo data generator: it serves mock engineering
+// metrics for Prometheus to scrape and fills the demo databases with rows.
package main
import (
diff --git a/datagen/cmd/main_test.go b/datagen/cmd/main_test.go
new file mode 100644
index 00000000..a8a2e6c9
--- /dev/null
+++ b/datagen/cmd/main_test.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+func Test_newLogger(t *testing.T) {
+ t.Parallel()
+
+ log := newLogger()
+ require.NotNil(t, log)
+ assert.NotNil(t, log.Handler())
+}
diff --git a/datagen/go.mod b/datagen/go.mod
index c216a09c..a50534a2 100644
--- a/datagen/go.mod
+++ b/datagen/go.mod
@@ -6,27 +6,63 @@ require (
github.com/go-chi/chi/v5 v5.3.2
github.com/go-sql-driver/mysql v1.10.0
github.com/jackc/pgx/v5 v5.10.0
+ github.com/orlangure/gnomock v0.32.0
github.com/oxynote/oxynote/server/core v0.0.0
github.com/prometheus/client_golang v1.24.1
+ github.com/prometheus/client_model v0.6.2
+ github.com/stretchr/testify v1.12.1
+ go.uber.org/goleak v1.3.0
)
replace github.com/oxynote/oxynote/server/core => ../server/core
require (
filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/getsentry/sentry-go v0.46.2 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/docker v28.5.2+incompatible // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/getsentry/sentry-go v0.48.0 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jarcoal/httpmock v1.4.2 // indirect
+ github.com/lib/pq v1.10.9 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
+ go.opentelemetry.io/otel v1.46.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
+ go.opentelemetry.io/otel/metric v1.46.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.46.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
+ go.opentelemetry.io/otel/trace v1.46.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
- golang.org/x/text v0.40.0 // indirect
- google.golang.org/protobuf v1.36.11 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ google.golang.org/grpc v1.83.0 // indirect
+ google.golang.org/protobuf v1.36.12 // indirect
)
diff --git a/datagen/go.sum b/datagen/go.sum
index ef3446df..ff5c6f1c 100644
--- a/datagen/go.sum
+++ b/datagen/go.sum
@@ -1,24 +1,55 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
-github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
+github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -29,19 +60,37 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
-github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
-github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI=
+github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/orlangure/gnomock v0.32.0 h1:96KCsqbDUaKz2nKkGqC0nIlJeCRaSfEpSi4CljBp8zk=
+github.com/orlangure/gnomock v0.32.0/go.mod h1:CpMbwyCmPFpeLrsA5LIUcMrGm7LOf9+2JE+taayrUPc=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -54,24 +103,62 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
+go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
+go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
+go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
+go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
+go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
+go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
+go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
+go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
+go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
+go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
+go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
+go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
-golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
+google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
+gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
diff --git a/datagen/internal/demodata/demodata.go b/datagen/internal/demodata/demodata.go
index 1f6c2c99..090ee9d1 100644
--- a/datagen/internal/demodata/demodata.go
+++ b/datagen/internal/demodata/demodata.go
@@ -1,3 +1,5 @@
+// Package demodata synthesises the deployment, incident and build rows the
+// demo data sources are filled with.
package demodata
import (
@@ -8,46 +10,153 @@ import (
"github.com/oxynote/oxynote/datagen/internal/mockmetrics"
)
+// TickInterval defines how much demo time a single generated tick covers.
const TickInterval = 5 * time.Minute
-var (
- services = []string{"api-gateway", "user-service", "billing-service", "notification-service", "search-service"}
- environments = []string{"production", "staging"}
- severities = []string{"critical", "major", "minor"}
- repositories = []string{"frontend", "backend", "infra", "mobile", "shared-libs"}
- branches = []string{"main", "develop", "feature"}
+// Deployment sampling parameters.
+const (
+ // _deploySuccessRate is the share of deployments that succeed outside
+ // Friday afternoons.
+ _deploySuccessRate = 0.92
+
+ // _deployFridaySuccessRate is that share once Friday afternoon starts.
+ _deployFridaySuccessRate = 0.78
+
+ // _deployRollbackRate is the share of failed deployments rolled back.
+ _deployRollbackRate = 0.6
+
+ // _deployDurationMean and _deployDurationStdDev shape how long a
+ // deployment takes, in seconds.
+ _deployDurationMean = 120
+ _deployDurationStdDev = 45
)
+// Incident sampling parameters.
+const (
+ // _incidentDetectRelStdDev spreads time-to-detect around its severity's
+ // mean, as a fraction of that mean.
+ _incidentDetectRelStdDev = 0.4
+
+ // _incidentResolveRelStdDev does the same for time-to-resolve.
+ _incidentResolveRelStdDev = 0.3
+)
+
+// Build sampling parameters.
+const (
+ // _buildFailureRate is the share of builds that fail any tests at all.
+ _buildFailureRate = 0.15
+
+ // _buildFailedTestsMean is the average number of tests that fail when a
+ // build fails.
+ _buildFailedTestsMean = 3
+
+ // _buildDurationMean and _buildDurationStdDev shape how long a build
+ // takes, in seconds.
+ _buildDurationMean = 180
+ _buildDurationStdDev = 60
+
+ // _buildTestCountMean and _buildTestCountRelStdDev shape how many tests
+ // a build runs.
+ _buildTestCountMean = 850
+ _buildTestCountRelStdDev = 0.1
+
+ // _buildCoverageMean and _buildCoverageStdDev shape reported coverage,
+ // which is then held within [_buildCoverageMin, _buildCoverageMax].
+ _buildCoverageMean = 72
+ _buildCoverageStdDev = 8
+ _buildCoverageMin = 20
+ _buildCoverageMax = 98
+)
+
+var (
+ // _services holds the service names demo rows are attributed to.
+ _services = []string{"api-gateway", "user-service", "billing-service", "notification-service", "search-service"}
+
+ // _environments holds the environments demo deployments target.
+ _environments = []string{"production", "staging"}
+
+ // _severities holds the severity levels demo incidents are raised at.
+ _severities = []string{"critical", "major", "minor"}
+
+ // _repositories holds the repository names demo builds run against.
+ _repositories = []string{"frontend", "backend", "infra", "mobile", "shared-libs"}
+
+ // _branches holds the branch names demo builds run on.
+ _branches = []string{"main", "develop", "feature"}
+)
+
+// Deployment specifies a single demo deployment event.
type Deployment struct {
- Time time.Time
- Service string
- Environment string
+ // Time specifies when the deployment happened.
+ Time time.Time
+
+ // Service specifies the deployed service.
+ Service string
+
+ // Environment specifies the environment deployed to.
+ Environment string
+
+ // DurationSeconds specifies how long the deployment took.
DurationSeconds float64
- Success bool
- Rollback bool
+
+ // Success indicates whether the deployment succeeded.
+ Success bool
+
+ // Rollback indicates whether the deployment was rolled back.
+ Rollback bool
}
+// Incident specifies a single demo incident event.
type Incident struct {
- Time time.Time
- Severity string
- Service string
- TimeToDetectMinutes float64
- TimeToResolveMinutes float64
+ // Time specifies when the incident started.
+ Time time.Time
+
+ // Severity specifies how severe the incident was.
+ Severity string
+
+ // Service specifies the affected service.
+ Service string
+
+ // TimeToDetectMinutes specifies how long the incident went unnoticed.
+ TimeToDetectMinutes float64
+
+ // TimeToResolveMinutes specifies how long the incident took to resolve.
+ TimeToResolveMinutes float64
}
+// BuildMetric specifies a single demo CI build result.
type BuildMetric struct {
- Time time.Time
- Repository string
- Branch string
+ // Time specifies when the build ran.
+ Time time.Time
+
+ // Repository specifies the built repository.
+ Repository string
+
+ // Branch specifies the built branch.
+ Branch string
+
+ // DurationSeconds specifies how long the build took.
DurationSeconds float64
- TestCount int
- TestsFailed int
- CoveragePct float64
+
+ // TestCount specifies how many tests the build ran.
+ TestCount int
+
+ // TestsFailed specifies how many of those tests failed.
+ TestsFailed int
+
+ // CoveragePct specifies the coverage percentage the build reported.
+ CoveragePct float64
}
+// Tick specifies everything generated for a single interval.
type Tick struct {
- Deployments []Deployment
- Incidents []Incident
+ // Deployments holds the deployments generated for the tick.
+ Deployments []Deployment
+
+ // Incidents holds the incidents generated for the tick.
+ Incidents []Incident
+
+ // BuildMetrics holds the build results generated for the tick.
BuildMetrics []BuildMetric
}
@@ -60,6 +169,8 @@ func GenerateTick(r *rand.Rand, t time.Time) Tick {
}
}
+// genDeployments generates the deployments of a single tick, at a rate that
+// peaks during weekday office hours and again on Friday afternoons.
func genDeployments(r *rand.Rand, t time.Time) []Deployment {
hour := t.Hour()
weekday := t.Weekday()
@@ -80,26 +191,28 @@ func genDeployments(r *rand.Rand, t time.Time) []Deployment {
out := make([]Deployment, 0, count)
for range count {
- success := r.Float64() < 0.92
+ success := r.Float64() < _deploySuccessRate
if weekday == time.Friday && hour >= 15 {
- success = r.Float64() < 0.78
+ success = r.Float64() < _deployFridaySuccessRate
}
jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second
out = append(out, Deployment{
Time: t.Add(jitter),
- Service: services[r.Intn(len(services))],
- Environment: environments[r.Intn(len(environments))],
- DurationSeconds: math.Abs(mockmetrics.Normal(r, 120, 45)),
+ Service: _services[r.Intn(len(_services))],
+ Environment: _environments[r.Intn(len(_environments))],
+ DurationSeconds: math.Abs(mockmetrics.Normal(r, _deployDurationMean, _deployDurationStdDev)),
Success: success,
- Rollback: !success && r.Float64() < 0.6,
+ Rollback: !success && r.Float64() < _deployRollbackRate,
})
}
return out
}
+// genIncidents generates the incidents of a single tick, at a rate that rises
+// on weekdays and spikes on Friday afternoons.
func genIncidents(r *rand.Rand, t time.Time) []Incident {
hour := t.Hour()
weekday := t.Weekday()
@@ -116,7 +229,7 @@ func genIncidents(r *rand.Rand, t time.Time) []Incident {
out := make([]Incident, 0, count)
for range count {
- severity := severities[r.Intn(len(severities))]
+ severity := _severities[r.Intn(len(_severities))]
var detectMean, resolveMean float64
@@ -132,17 +245,19 @@ func genIncidents(r *rand.Rand, t time.Time) []Incident {
jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second
out = append(out, Incident{
- Time: t.Add(jitter),
- Severity: severity,
- Service: services[r.Intn(len(services))],
- TimeToDetectMinutes: math.Abs(mockmetrics.Normal(r, detectMean, detectMean*0.4)),
- TimeToResolveMinutes: math.Abs(mockmetrics.Normal(r, resolveMean, resolveMean*0.3)),
+ Time: t.Add(jitter),
+ Severity: severity,
+ Service: _services[r.Intn(len(_services))],
+ TimeToDetectMinutes: math.Abs(mockmetrics.Normal(r, detectMean, detectMean*_incidentDetectRelStdDev)),
+ TimeToResolveMinutes: math.Abs(mockmetrics.Normal(r, resolveMean, resolveMean*_incidentResolveRelStdDev)),
})
}
return out
}
+// genBuildMetrics generates the CI builds of a single tick, at a rate that
+// peaks during weekday office hours.
func genBuildMetrics(r *rand.Rand, t time.Time) []BuildMetric {
hour := t.Hour()
weekday := t.Weekday()
@@ -160,20 +275,24 @@ func genBuildMetrics(r *rand.Rand, t time.Time) []BuildMetric {
for range count {
testsFailed := 0
- if r.Float64() < 0.15 {
- testsFailed = mockmetrics.Poisson(r, 3)
+ if r.Float64() < _buildFailureRate {
+ testsFailed = mockmetrics.Poisson(r, _buildFailedTestsMean)
}
jitter := time.Duration(r.Intn(int(TickInterval.Seconds()))) * time.Second
out = append(out, BuildMetric{
Time: t.Add(jitter),
- Repository: repositories[r.Intn(len(repositories))],
- Branch: branches[r.Intn(len(branches))],
- DurationSeconds: math.Abs(mockmetrics.Normal(r, 180, 60)),
- TestCount: mockmetrics.RandCount(r, 850, 0.1),
+ Repository: _repositories[r.Intn(len(_repositories))],
+ Branch: _branches[r.Intn(len(_branches))],
+ DurationSeconds: math.Abs(mockmetrics.Normal(r, _buildDurationMean, _buildDurationStdDev)),
+ TestCount: mockmetrics.RandCount(r, _buildTestCountMean, _buildTestCountRelStdDev),
TestsFailed: testsFailed,
- CoveragePct: mockmetrics.Clamp(mockmetrics.Normal(r, 72, 8), 20, 98),
+ CoveragePct: mockmetrics.Clamp(
+ mockmetrics.Normal(r, _buildCoverageMean, _buildCoverageStdDev),
+ _buildCoverageMin,
+ _buildCoverageMax,
+ ),
})
}
diff --git a/datagen/internal/demodata/demodata_test.go b/datagen/internal/demodata/demodata_test.go
new file mode 100644
index 00000000..2b567246
--- /dev/null
+++ b/datagen/internal/demodata/demodata_test.go
@@ -0,0 +1,262 @@
+package demodata
+
+import (
+ "math/rand"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+// Reference instants for each rate branch the generators switch on.
+var (
+ // _sunday is outside the working week entirely.
+ _sunday = time.Date(2024, time.January, 7, 12, 0, 0, 0, time.UTC)
+
+ // _tuesdayNight is a weekday outside office hours.
+ _tuesdayNight = time.Date(2024, time.January, 9, 3, 0, 0, 0, time.UTC)
+
+ // _tuesdayOffice is a weekday inside office hours.
+ _tuesdayOffice = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC)
+
+ // _fridayAfternoon is when everything gets worse.
+ _fridayAfternoon = time.Date(2024, time.January, 12, 16, 0, 0, 0, time.UTC)
+)
+
+func Test_GenerateTick(t *testing.T) {
+ t.Parallel()
+
+ // a tick carries whatever each generator produced for the same instant.
+ tick := GenerateTick(mockmetrics.NewRand(1), _tuesdayOffice)
+
+ assert.Equal(t, genDeployments(mockmetrics.NewRand(1), _tuesdayOffice), tick.Deployments)
+
+ // the generators share one source, so replaying them in order is the
+ // only way to reproduce the later two.
+ r := mockmetrics.NewRand(1)
+ genDeployments(r, _tuesdayOffice)
+
+ assert.Equal(t, genIncidents(r, _tuesdayOffice), tick.Incidents)
+ assert.Equal(t, genBuildMetrics(r, _tuesdayOffice), tick.BuildMetrics)
+}
+
+func Test_genDeployments(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Time time.Time
+
+ // MinRows is the number of rows the case must produce across its
+ // draws for the branch to count as exercised.
+ MinRows int
+ }{
+ "Weekend runs at the lowest rate": {
+ Time: _sunday,
+ MinRows: 1,
+ },
+ "Weekday outside office hours": {
+ Time: _tuesdayNight,
+ MinRows: 1,
+ },
+ "Weekday office hours": {
+ Time: _tuesdayOffice,
+ MinRows: 1,
+ },
+ "Friday afternoon": {
+ Time: _fridayAfternoon,
+ MinRows: 1,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ r = mockmetrics.NewRand(1)
+ rows = make([]Deployment, 0, 200)
+ )
+
+ for range 200 {
+ rows = append(rows, genDeployments(r, c.Time)...)
+ }
+
+ require.GreaterOrEqual(t, len(rows), c.MinRows)
+
+ for _, d := range rows {
+ assert.Contains(t, _services, d.Service)
+ assert.Contains(t, _environments, d.Environment)
+ assert.GreaterOrEqual(t, d.DurationSeconds, 0.0)
+ assertJittered(t, c.Time, d.Time)
+
+ // a successful deployment is never rolled back.
+ if d.Success {
+ assert.False(t, d.Rollback)
+ }
+ }
+ })
+ }
+
+ // the branches exist to make busier periods busier, so the row counts
+ // have to come out ordered.
+ assert.Less(t, countDeployments(t, _sunday), countDeployments(t, _tuesdayOffice))
+ assert.Less(t, countDeployments(t, _tuesdayNight), countDeployments(t, _tuesdayOffice))
+ assert.Less(t, countDeployments(t, _tuesdayOffice), countDeployments(t, _fridayAfternoon))
+}
+
+func Test_genIncidents(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Time time.Time
+ }{
+ "Weekend runs at the lowest rate": {Time: _sunday},
+ "Weekday": {Time: _tuesdayOffice},
+ "Friday afternoon": {Time: _fridayAfternoon},
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ r = mockmetrics.NewRand(1)
+ rows = make([]Incident, 0, 500)
+ )
+
+ for range 500 {
+ rows = append(rows, genIncidents(r, c.Time)...)
+ }
+
+ require.NotEmpty(t, rows)
+
+ var seen []string
+
+ for _, i := range rows {
+ assert.Contains(t, _severities, i.Severity)
+ assert.Contains(t, _services, i.Service)
+ assert.GreaterOrEqual(t, i.TimeToDetectMinutes, 0.0)
+ assert.GreaterOrEqual(t, i.TimeToResolveMinutes, 0.0)
+ assertJittered(t, c.Time, i.Time)
+
+ if !slices.Contains(seen, i.Severity) {
+ seen = append(seen, i.Severity)
+ }
+ }
+
+ // every severity carries its own detect/resolve means, so the
+ // draw has to cover all three branches of the switch.
+ assert.ElementsMatch(t, _severities, seen)
+ })
+ }
+
+ // busier periods must come out busier.
+ assert.Less(t, countIncidents(t, _sunday), countIncidents(t, _tuesdayOffice))
+ assert.Less(t, countIncidents(t, _tuesdayOffice), countIncidents(t, _fridayAfternoon))
+}
+
+func Test_genBuildMetrics(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Time time.Time
+ }{
+ "Weekend runs at the lowest rate": {Time: _sunday},
+ "Weekday outside office hours": {Time: _tuesdayNight},
+ "Weekday office hours": {Time: _tuesdayOffice},
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ r = mockmetrics.NewRand(1)
+ rows = make([]BuildMetric, 0, 200)
+ )
+
+ for range 200 {
+ rows = append(rows, genBuildMetrics(r, c.Time)...)
+ }
+
+ require.NotEmpty(t, rows)
+
+ var sawFailures bool
+
+ for _, b := range rows {
+ assert.Contains(t, _repositories, b.Repository)
+ assert.Contains(t, _branches, b.Branch)
+ assert.GreaterOrEqual(t, b.DurationSeconds, 0.0)
+ assert.GreaterOrEqual(t, b.TestCount, 0)
+ assert.GreaterOrEqual(t, b.TestsFailed, 0)
+ assert.GreaterOrEqual(t, b.CoveragePct, float64(_buildCoverageMin))
+ assert.LessOrEqual(t, b.CoveragePct, float64(_buildCoverageMax))
+ assertJittered(t, c.Time, b.Time)
+
+ if b.TestsFailed > 0 {
+ sawFailures = true
+ }
+ }
+
+ // the failing-build branch is rare but has to be reached.
+ assert.True(t, sawFailures)
+ })
+ }
+
+ // busier periods must come out busier.
+ assert.Less(t, countBuilds(t, _sunday), countBuilds(t, _tuesdayOffice))
+ assert.Less(t, countBuilds(t, _tuesdayNight), countBuilds(t, _tuesdayOffice))
+}
+
+// assertJittered checks that a generated row landed inside the tick it was
+// generated for.
+func assertJittered(t *testing.T, tick, got time.Time) {
+ t.Helper()
+
+ assert.False(t, got.Before(tick))
+ assert.True(t, got.Before(tick.Add(TickInterval)))
+}
+
+// countDeployments totals the deployments generated for one instant over
+// enough draws for the rates to separate.
+func countDeployments(t *testing.T, at time.Time) int {
+ t.Helper()
+
+ return countRows(func(r *rand.Rand) int { return len(genDeployments(r, at)) })
+}
+
+// countIncidents does the same for incidents.
+func countIncidents(t *testing.T, at time.Time) int {
+ t.Helper()
+
+ return countRows(func(r *rand.Rand) int { return len(genIncidents(r, at)) })
+}
+
+// countBuilds does the same for builds.
+func countBuilds(t *testing.T, at time.Time) int {
+ t.Helper()
+
+ return countRows(func(r *rand.Rand) int { return len(genBuildMetrics(r, at)) })
+}
+
+// countRows sums what fn generates over a fixed number of draws from a fixed
+// seed, so the totals of two instants are comparable.
+func countRows(fn func(*rand.Rand) int) int {
+ r := mockmetrics.NewRand(1)
+
+ var total int
+
+ for range 2_000 {
+ total += fn(r)
+ }
+
+ return total
+}
diff --git a/datagen/internal/gen/manager.go b/datagen/internal/gen/manager.go
index 7b1d80ab..c2e401ef 100644
--- a/datagen/internal/gen/manager.go
+++ b/datagen/internal/gen/manager.go
@@ -1,3 +1,5 @@
+// Package gen generates the mock engineering-culture metrics the demo
+// Prometheus scrapes.
package gen
import (
@@ -8,12 +10,32 @@ import (
"github.com/oxynote/oxynote/datagen/internal/mockmetrics"
"github.com/oxynote/oxynote/server/core/pkg/metricutil"
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
"github.com/prometheus/client_golang/prometheus"
)
// _genInterval defines how often metrics are refreshed.
const _genInterval = 5 * time.Minute
+// _labelOutcome names the label both outcome-split gauges are broken down by.
+const _labelOutcome = "outcome"
+
+// Sampling parameters of the friday_deploys gauge, whose two buckets are
+// derived from one another instead of being sampled independently.
+const (
+ // _fridayDeployMean is the weekly number of Friday deploys attempted.
+ _fridayDeployMean = 30_000
+
+ // _fridayDeployRelStdDev spreads that count tick to tick.
+ _fridayDeployRelStdDev = 0.15
+
+ // _fridayDeploySuccessMean is the mean fraction of attempts that survive.
+ _fridayDeploySuccessMean = 0.68
+
+ // _fridayDeploySuccessStdDev spreads that fraction tick to tick.
+ _fridayDeploySuccessStdDev = 0.12
+)
+
// continuousMetric holds the running state for a single drifting gauge.
type continuousMetric struct {
// value is the current gauge reading, updated on each tick.
@@ -52,6 +74,9 @@ type Manager struct {
// log is the structured logger.
log *slog.Logger
+ // interval defines how often metrics are refreshed.
+ interval time.Duration
+
// Continuous metric states — these drift smoothly between ticks.
// tempFixAge states by original_comment label — hacks live the longest.
@@ -137,15 +162,17 @@ type Manager struct {
}
// NewManager creates a ready-to-run Manager.
+//
+//nolint:maintidx // a flat table of tuned gauge parameters, not logic
func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager {
newGaugeVec := func(name, help string, labels ...string) metricutil.GaugeVec {
return fc.NewGaugeVec(metricutil.Options{Name: name, Help: help}, labels)
}
return &Manager{
- r: mockmetrics.NewRand(time.Now().UnixNano()),
- log: log,
-
+ r: mockmetrics.NewRand(timeutil.Now().UnixNano()),
+ log: log,
+ interval: _genInterval,
// Temporary fix age by comment type — hacks accumulate and never leave.
tempFixAgeTemp: newContinuous(mockmetrics.GaugeParams{
Min: 365,
@@ -370,7 +397,6 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager {
SpikeStdDev: 30_000,
}),
- // Prometheus gauges.
meetingsCouldbDoc: newGaugeVec(
"meetings_could_be_doc_count",
"Weekly count of meetings that could've been a document.",
@@ -379,12 +405,12 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager {
circleBackCount: newGaugeVec(
"circle_back_count",
`Weekly count of "let's circle back" mentions.`,
- "outcome",
+ _labelOutcome,
),
fridayDeploys: newGaugeVec(
"friday_deploys_count",
"Weekly Friday deploy counts.",
- "outcome",
+ _labelOutcome,
),
featureFlagGrave: newGaugeVec(
"feature_flag_graveyard_size",
@@ -449,16 +475,35 @@ func NewManager(fc metricutil.Factory, log *slog.Logger) *Manager {
}
// Run starts the generation loop. It blocks until ctx is cancelled.
-func (m *Manager) Run(ctx context.Context) error {
- m.generateData()
+func (m *Manager) Run(ctx context.Context) {
+ timeutil.NewPeriodicExec(
+ m.interval,
+ 0,
+ func(context.Context) { m.generateData() },
+ nil,
+ true,
+ ).Start(ctx)
+}
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-time.After(_genInterval):
- m.generateData()
- }
+// countBucket specifies the sampling parameters of one labelled bucket of a
+// gauge that is resampled from scratch on every tick.
+type countBucket struct {
+ // label is the label value the bucket is published under.
+ label string
+
+ // mean is the average count sampled for the bucket each tick.
+ mean float64
+
+ // relStdDev is the bucket's standard deviation as a fraction of mean.
+ relStdDev float64
+}
+
+// setCounts resamples every bucket of a gauge and publishes the results
+// under the given label key.
+func (m *Manager) setCounts(vec metricutil.GaugeVec, key string, bb []countBucket) {
+ for _, b := range bb {
+ vec.With(prometheus.Labels{key: b.label}).
+ Set(float64(mockmetrics.RandCount(m.r, b.mean, b.relStdDev)))
}
}
@@ -466,52 +511,72 @@ func (m *Manager) Run(ctx context.Context) error {
func (m *Manager) generateData() {
r := m.r
- // --- Poisson count metrics (fresh each tick) ---
+ // --- Count metrics (resampled from scratch each tick) ---
- // Lambdas are aggregate weekly totals across ~10k organisations.
+ // Means are aggregate weekly totals across ~10k organisations.
// meetings_could_be_doc: split by the excuse used to justify the meeting.
- m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "alignment"}).Set(float64(mockmetrics.RandCount(r, 42_000, 0.15)))
- m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "bandwidth"}).Set(float64(mockmetrics.RandCount(r, 36_000, 0.15)))
- m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "synergy"}).Set(float64(mockmetrics.RandCount(r, 30_000, 0.15)))
- m.meetingsCouldbDoc.With(prometheus.Labels{"excuse": "optics"}).Set(float64(mockmetrics.RandCount(r, 12_000, 0.20)))
+ m.setCounts(m.meetingsCouldbDoc, "excuse", []countBucket{
+ {label: "alignment", mean: 42_000, relStdDev: 0.15},
+ {label: "bandwidth", mean: 36_000, relStdDev: 0.15},
+ {label: "synergy", mean: 30_000, relStdDev: 0.15},
+ {label: "optics", mean: 12_000, relStdDev: 0.20},
+ })
// circle_back_count: split by what actually happened afterward.
- m.circleBackCount.With(prometheus.Labels{"outcome": "circled_back"}).Set(float64(mockmetrics.RandCount(r, 16_000, 0.20)))
- m.circleBackCount.With(prometheus.Labels{"outcome": "forgotten"}).Set(float64(mockmetrics.RandCount(r, 48_000, 0.15)))
- m.circleBackCount.With(prometheus.Labels{"outcome": "rescheduled_to_circle_back"}).Set(float64(mockmetrics.RandCount(r, 16_000, 0.20)))
+ m.setCounts(m.circleBackCount, _labelOutcome, []countBucket{
+ {label: "circled_back", mean: 16_000, relStdDev: 0.20},
+ {label: "forgotten", mean: 48_000, relStdDev: 0.15},
+ {label: "rescheduled_to_circle_back", mean: 16_000, relStdDev: 0.20},
+ })
- // friday_deploys: outcome (attempted vs successful).
- attempted := mockmetrics.RandCount(r, 30_000, 0.15)
- successRate := mockmetrics.Clamp(mockmetrics.Normal(r, 0.68, 0.12), 0, 1)
+ // friday_deploys: the successful bucket is a fraction of the attempted
+ // one rather than an independent sample, so it cannot join a table.
+ attempted := mockmetrics.RandCount(r, _fridayDeployMean, _fridayDeployRelStdDev)
+ successRate := mockmetrics.Clamp(
+ mockmetrics.Normal(r, _fridayDeploySuccessMean, _fridayDeploySuccessStdDev),
+ 0,
+ 1,
+ )
successful := int(mockmetrics.Clamp(float64(attempted)*successRate, 0, float64(attempted)))
- m.fridayDeploys.With(prometheus.Labels{"outcome": "attempted"}).Set(float64(attempted))
- m.fridayDeploys.With(prometheus.Labels{"outcome": "successful"}).Set(float64(successful))
+
+ m.fridayDeploys.With(prometheus.Labels{_labelOutcome: "attempted"}).Set(float64(attempted))
+ m.fridayDeploys.With(prometheus.Labels{_labelOutcome: "successful"}).Set(float64(successful))
// pizza_fridays: split by how many pizzas were ordered.
- m.pizzaFridays.With(prometheus.Labels{"pizza_count": "1_to_5"}).Set(float64(mockmetrics.RandCount(r, 10_000, 0.15)))
- m.pizzaFridays.With(prometheus.Labels{"pizza_count": "5_to_10"}).Set(float64(mockmetrics.RandCount(r, 7_000, 0.20)))
- m.pizzaFridays.With(prometheus.Labels{"pizza_count": "10_plus"}).Set(float64(mockmetrics.RandCount(r, 3_000, 0.25)))
+ m.setCounts(m.pizzaFridays, "pizza_count", []countBucket{
+ {label: "1_to_5", mean: 10_000, relStdDev: 0.15},
+ {label: "5_to_10", mean: 7_000, relStdDev: 0.20},
+ {label: "10_plus", mean: 3_000, relStdDev: 0.25},
+ })
// ship_it_approvals: split by reviewer's apparent state of mind.
- m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "sober"}).Set(float64(mockmetrics.RandCount(r, 20_000, 0.15)))
- m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "one_beer_in"}).Set(float64(mockmetrics.RandCount(r, 14_000, 0.20)))
- m.shipItApprovals.With(prometheus.Labels{"reviewer_state": "heading_to_the_door"}).Set(float64(mockmetrics.RandCount(r, 6_000, 0.25)))
+ m.setCounts(m.shipItApprovals, "reviewer_state", []countBucket{
+ {label: "sober", mean: 20_000, relStdDev: 0.15},
+ {label: "one_beer_in", mean: 14_000, relStdDev: 0.20},
+ {label: "heading_to_the_door", mean: 6_000, relStdDev: 0.25},
+ })
// prs_merged_friday: split by the deployer's stated confidence level.
- m.prsMergedFriday.With(prometheus.Labels{"confidence": "yolo"}).Set(float64(mockmetrics.RandCount(r, 20_000, 0.15)))
- m.prsMergedFriday.With(prometheus.Labels{"confidence": "fingers_crossed"}).Set(float64(mockmetrics.RandCount(r, 22_500, 0.15)))
- m.prsMergedFriday.With(prometheus.Labels{"confidence": "vacation_starts_monday"}).Set(float64(mockmetrics.RandCount(r, 7_500, 0.20)))
+ m.setCounts(m.prsMergedFriday, "confidence", []countBucket{
+ {label: "yolo", mean: 20_000, relStdDev: 0.15},
+ {label: "fingers_crossed", mean: 22_500, relStdDev: 0.15},
+ {label: "vacation_starts_monday", mean: 7_500, relStdDev: 0.20},
+ })
// secrets_detected: split by severity — nuclear is rare but very bad.
- m.secretsDetected.With(prometheus.Labels{"severity": "normal"}).Set(float64(mockmetrics.RandCount(r, 2_100, 0.25)))
- m.secretsDetected.With(prometheus.Labels{"severity": "critical"}).Set(float64(mockmetrics.RandCount(r, 750, 0.30)))
- m.secretsDetected.With(prometheus.Labels{"severity": "nuclear"}).Set(float64(mockmetrics.RandCount(r, 150, 0.35)))
+ m.setCounts(m.secretsDetected, "severity", []countBucket{
+ {label: "normal", mean: 2_100, relStdDev: 0.25},
+ {label: "critical", mean: 750, relStdDev: 0.30},
+ {label: "nuclear", mean: 150, relStdDev: 0.35},
+ })
// quick_sync_meetings: split by how "quick" they actually were.
- m.quickSyncMeetings.With(prometheus.Labels{"duration": "10m_to_30m"}).Set(float64(mockmetrics.RandCount(r, 33_000, 0.15)))
- m.quickSyncMeetings.With(prometheus.Labels{"duration": "30m_to_1h"}).Set(float64(mockmetrics.RandCount(r, 21_000, 0.15)))
- m.quickSyncMeetings.With(prometheus.Labels{"duration": "1h_plus"}).Set(float64(mockmetrics.RandCount(r, 6_000, 0.25)))
+ m.setCounts(m.quickSyncMeetings, "duration", []countBucket{
+ {label: "10m_to_30m", mean: 33_000, relStdDev: 0.15},
+ {label: "30m_to_1h", mean: 21_000, relStdDev: 0.15},
+ {label: "1h_plus", mean: 6_000, relStdDev: 0.25},
+ })
// --- Continuous drifting metrics ---
diff --git a/datagen/internal/gen/manager_test.go b/datagen/internal/gen/manager_test.go
new file mode 100644
index 00000000..8e57213c
--- /dev/null
+++ b/datagen/internal/gen/manager_test.go
@@ -0,0 +1,303 @@
+package gen
+
+import (
+ "context"
+ "log/slog"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/oxynote/oxynote/server/core/pkg/metricutil"
+ "github.com/prometheus/client_golang/prometheus"
+ dto "github.com/prometheus/client_model/go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+func Test_newContinuous(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Params mockmetrics.GaugeParams
+ Result float64
+ }{
+ "Start within the bounds is kept": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 40},
+ Result: 40,
+ },
+ "Start below the lower bound is raised": {
+ Params: mockmetrics.GaugeParams{Min: 10, Max: 100, Start: 5},
+ Result: 10,
+ },
+ "Start above the upper bound is lowered": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 50, Start: 90},
+ Result: 50,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ m := newContinuous(c.Params)
+ require.NotNil(t, m)
+
+ assert.InDelta(t, c.Result, m.value, 1e-9)
+ assert.Equal(t, c.Params, m.p)
+ })
+ }
+}
+
+func Test_continuousMetric_tick(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Params mockmetrics.GaugeParams
+
+ // Result is the value the single tick must produce exactly, used
+ // where the parameters leave no randomness in play.
+ Result *float64
+
+ // Ticks is how many times to advance when the case asserts over a
+ // run rather than a single step.
+ Ticks int
+ }{
+ "Static parameters hold the value still": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50},
+ Result: new(50.0),
+ },
+ "Drift moves the value by a fixed step": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, DriftPerStep: 2},
+ Result: new(52.0),
+ },
+ "Mean reversion pulls the value toward the target": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, Target: 100, MeanReversion: 0.5},
+ Result: new(75.0),
+ },
+ "Drift is held at the upper bound": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 50, Start: 50, DriftPerStep: 10},
+ Result: new(50.0),
+ },
+ "Drift is held at the lower bound": {
+ Params: mockmetrics.GaugeParams{Min: 10, Max: 100, Start: 10, DriftPerStep: -10},
+ Result: new(10.0),
+ },
+ "Noise keeps the value inside the bounds": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, NoiseStdDev: 40},
+ Ticks: 1_000,
+ },
+ "A certain spike keeps the value inside the bounds": {
+ Params: mockmetrics.GaugeParams{Min: 0, Max: 100, Start: 50, SpikeChance: 1, SpikeStdDev: 80},
+ Ticks: 1_000,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ var (
+ m = newContinuous(c.Params)
+ r = mockmetrics.NewRand(1)
+ )
+
+ if c.Result != nil {
+ v := m.tick(r)
+
+ assert.InDelta(t, *c.Result, v, 1e-9)
+
+ // the returned value is the state the next tick builds on.
+ assert.InDelta(t, v, m.value, 1e-9)
+
+ return
+ }
+
+ for range c.Ticks {
+ v := m.tick(r)
+
+ assert.GreaterOrEqual(t, v, c.Params.Min)
+ assert.LessOrEqual(t, v, c.Params.Max)
+ assert.InDelta(t, v, m.value, 1e-9)
+ }
+ })
+ }
+}
+
+func Test_NewManager(t *testing.T) {
+ t.Parallel()
+
+ log := slog.New(slog.DiscardHandler)
+
+ m := NewManager(newFactory(), log)
+ require.NotNil(t, m)
+
+ assert.NotNil(t, m.r)
+ assert.Equal(t, log, m.log)
+ assert.Equal(t, _genInterval, m.interval)
+
+ // every drifting state and every gauge has to be wired, which the
+ // compiler cannot check for a struct built field by field.
+ v := reflect.ValueOf(*m)
+
+ for i := range v.NumField() {
+ name := v.Type().Field(i).Name
+
+ switch v.Type().Field(i).Type.String() {
+ case "*gen.continuousMetric", "metricutil.GaugeVec":
+ assert.False(t, v.Field(i).IsNil(), name)
+ }
+ }
+}
+
+func Test_Manager_Run(t *testing.T) {
+ t.Parallel()
+
+ fc := newFactory()
+
+ m := NewManager(fc, slog.New(slog.DiscardHandler))
+ m.interval = time.Hour
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ m.Run(ctx)
+ }()
+
+ // the first generation is immediate, so the gauges carry values well
+ // before the hour-long interval could elapse.
+ require.Eventually(t, func() bool {
+ ff, err := fc.Gather()
+
+ return err == nil && len(ff) > 0
+ }, 5*time.Second, 10*time.Millisecond)
+
+ cancel()
+
+ <-stopCh
+}
+
+func Test_Manager_setCounts(t *testing.T) {
+ t.Parallel()
+
+ fc := newFactory()
+ vec := fc.NewGaugeVec(metricutil.Options{Name: "test_counts", Help: "Tracks nothing."}, []string{"kind"})
+
+ m := &Manager{r: mockmetrics.NewRand(1)}
+ m.setCounts(vec, "kind", []countBucket{
+ {label: "first", mean: 1_000, relStdDev: 0.1},
+ {label: "second", mean: 2_000, relStdDev: 0.1},
+ })
+
+ values := gathered(t, fc, "engineering_test_counts")
+ require.Len(t, values, 2)
+
+ assert.InDelta(t, 1_000, values["first"], 500)
+ assert.InDelta(t, 2_000, values["second"], 1_000)
+}
+
+func Test_Manager_generateData(t *testing.T) {
+ t.Parallel()
+
+ fc := newFactory()
+
+ m := NewManager(fc, slog.New(slog.DiscardHandler))
+ m.generateData()
+
+ // every declared gauge has to report at least one labelled value.
+ cc := map[string]int{
+ "engineering_meetings_could_be_doc_count": 4,
+ "engineering_circle_back_count": 3,
+ "engineering_friday_deploys_count": 2,
+ "engineering_feature_flag_graveyard_size": 3,
+ "engineering_pizza_fridays_next_30_days": 3,
+ "engineering_deploy_confidence_index": 3,
+ "engineering_ship_it_approvals_after_5pm_count": 3,
+ "engineering_prs_merged_friday_afternoon_count": 3,
+ "engineering_temporary_fix_age_days": 4,
+ "engineering_stale_branches_total": 3,
+ "engineering_pr_time_to_first_review_minutes": 1,
+ "engineering_hotfixes_per_release": 3,
+ "engineering_secrets_detected_commits_count": 3,
+ "engineering_works_on_my_machine_rate": 3,
+ "engineering_quick_sync_meetings_same_day_count": 3,
+ }
+
+ // these subtests read the registry the rest of this function goes on to
+ // write to, so they stay sequential rather than deferring past it.
+ for name, count := range cc {
+ t.Run(name, func(t *testing.T) {
+ assert.Len(t, gathered(t, fc, name), count)
+ })
+ }
+
+ // friday deploys are derived rather than sampled, so successes can
+ // never outnumber attempts.
+ deploys := gathered(t, fc, "engineering_friday_deploys_count")
+ assert.LessOrEqual(t, deploys["successful"], deploys["attempted"])
+
+ // a second pass moves the drifting gauges on from where they stopped
+ // rather than restarting them.
+ before := gathered(t, fc, "engineering_pr_time_to_first_review_minutes")
+
+ m.generateData()
+
+ after := gathered(t, fc, "engineering_pr_time_to_first_review_minutes")
+
+ assert.NotEqual(t, before, after)
+}
+
+// newFactory creates a metrics factory backed by a registry of its own, so
+// parallel tests never collide over metric names.
+func newFactory() metricutil.Factory {
+ return metricutil.NewFactory(
+ "engineering",
+ prometheus.NewRegistry(),
+ metricutil.WithCustomHost("demo"),
+ )
+}
+
+// gathered collects the current values of one metric family, keyed by the
+// value of the single label that splits it (or the empty string when the
+// family carries no label of its own).
+func gathered(t *testing.T, fc metricutil.Factory, name string) map[string]float64 {
+ t.Helper()
+
+ ff, err := fc.Gather()
+ require.NoError(t, err)
+
+ out := map[string]float64{}
+
+ for _, f := range ff {
+ if f.GetName() != name {
+ continue
+ }
+
+ for _, m := range f.GetMetric() {
+ out[labelValue(m)] = m.GetGauge().GetValue()
+ }
+ }
+
+ return out
+}
+
+// labelValue returns the value of the metric's own label, skipping the host
+// label the factory attaches to everything.
+func labelValue(m *dto.Metric) string {
+ for _, l := range m.GetLabel() {
+ if l.GetName() != "host" {
+ return l.GetValue()
+ }
+ }
+
+ return ""
+}
diff --git a/datagen/internal/mariademo/mariademo.go b/datagen/internal/mariademo/mariademo.go
index 16dff873..6860d12d 100644
--- a/datagen/internal/mariademo/mariademo.go
+++ b/datagen/internal/mariademo/mariademo.go
@@ -1,3 +1,5 @@
+// Package mariademo fills the demo MariaDB data source with generated
+// time-series rows.
package mariademo
import (
@@ -8,26 +10,45 @@ import (
"math/rand"
"time"
+ // the mysql driver registers itself with database/sql on import.
_ "github.com/go-sql-driver/mysql"
"github.com/oxynote/oxynote/datagen/internal/demodata"
"github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
)
+// _backfillDays defines how much history an empty database is filled with.
const _backfillDays = 30
+// _connMaxLifetime bounds how long the single pooled connection is reused.
+const _connMaxLifetime = 30 * time.Second
+
// Generator inserts demo time-series data into MariaDB.
type Generator struct {
- dsn string
- r *rand.Rand
+ // log is the structured logger.
log *slog.Logger
+
+ // dsn addresses the database to fill.
+ dsn string
+
+ // r is the random source used for all row generation.
+ r *rand.Rand
+
+ // interval defines how often a new tick of rows is appended.
+ interval time.Duration
+
+ // backfillDays defines how much history an empty database is filled with.
+ backfillDays int
}
-// NewGenerator creates a new demo data generator for MariaDB.
+// NewGenerator creates a fresh instance of Generator.
func NewGenerator(dsn string, log *slog.Logger) *Generator {
return &Generator{
- dsn: dsn,
- r: mockmetrics.NewRand(time.Now().UnixNano()),
- log: log,
+ log: log,
+ dsn: dsn,
+ r: mockmetrics.NewRand(timeutil.Now().UnixNano()),
+ interval: demodata.TickInterval,
+ backfillDays: _backfillDays,
}
}
@@ -38,13 +59,13 @@ func (g *Generator) Run(ctx context.Context) error {
if err != nil {
return fmt.Errorf("mariademo: open: %w", err)
}
- defer db.Close()
+ defer db.Close() //nolint:errcheck // error provides no meaningful info
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
- db.SetConnMaxLifetime(30 * time.Second)
+ db.SetConnMaxLifetime(_connMaxLifetime)
- if err := db.PingContext(ctx); err != nil {
+ if err = db.PingContext(ctx); err != nil {
return fmt.Errorf("mariademo: ping: %w", err)
}
@@ -54,7 +75,7 @@ func (g *Generator) Run(ctx context.Context) error {
}
if !backfilled {
- g.log.Info("mariademo: backfilling historical data", slog.Int("days", _backfillDays))
+ g.log.Info("mariademo: backfilling historical data", slog.Int("days", g.backfillDays))
if err := g.backfill(ctx, db); err != nil {
return fmt.Errorf("mariademo: backfill: %w", err)
@@ -63,20 +84,22 @@ func (g *Generator) Run(ctx context.Context) error {
g.log.Info("mariademo: backfill complete")
}
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-time.After(demodata.TickInterval):
- now := time.Now().UTC()
-
- if err := g.insertTick(ctx, db, now); err != nil {
+ timeutil.NewPeriodicExec(
+ g.interval,
+ 0,
+ func(ctx context.Context) {
+ if err := g.insertTick(ctx, db, timeutil.Now()); err != nil {
g.log.Error("mariademo: tick insert failed", slog.String("error", err.Error()))
}
- }
- }
+ },
+ nil,
+ false,
+ ).Start(ctx)
+
+ return nil
}
+// needsBackfill returns true if historical data already exists.
func (g *Generator) needsBackfill(ctx context.Context, db *sql.DB) (bool, error) {
var count int
@@ -88,11 +111,12 @@ func (g *Generator) needsBackfill(ctx context.Context, db *sql.DB) (bool, error)
return count > 0, nil
}
+// backfill generates backfillDays worth of historical data.
func (g *Generator) backfill(ctx context.Context, db *sql.DB) error {
- now := time.Now().UTC()
- start := now.AddDate(0, 0, -_backfillDays)
+ now := timeutil.Now()
+ start := now.AddDate(0, 0, -g.backfillDays)
- for t := start; t.Before(now); t = t.Add(demodata.TickInterval) {
+ for t := start; t.Before(now); t = t.Add(g.interval) {
if err := g.insertTick(ctx, db, t); err != nil {
return err
}
@@ -101,6 +125,7 @@ func (g *Generator) backfill(ctx context.Context, db *sql.DB) error {
return nil
}
+// insertTick generates and inserts one tick's worth of data at the given time.
func (g *Generator) insertTick(ctx context.Context, db *sql.DB, t time.Time) error {
tick := demodata.GenerateTick(g.r, t)
@@ -108,7 +133,7 @@ func (g *Generator) insertTick(ctx context.Context, db *sql.DB, t time.Time) err
if err != nil {
return fmt.Errorf("begin tx at %s: %w", t.Format(time.RFC3339), err)
}
- defer tx.Rollback()
+ defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
for _, d := range tick.Deployments {
if _, err := tx.ExecContext(ctx,
diff --git a/datagen/internal/mariademo/mariademo_test.go b/datagen/internal/mariademo/mariademo_test.go
new file mode 100644
index 00000000..a580d0e1
--- /dev/null
+++ b/datagen/internal/mariademo/mariademo_test.go
@@ -0,0 +1,566 @@
+package mariademo
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "io"
+ "log"
+ "log/slog"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ mysqlDriver "github.com/go-sql-driver/mysql"
+ "github.com/orlangure/gnomock"
+ "github.com/oxynote/oxynote/datagen/internal/demodata"
+ "github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/oxynote/oxynote/server/core/pkg/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+const (
+ // _testDB is the database name used in the throwaway container.
+ _testDB = "testdb"
+
+ // _rootUser is the privileged container user.
+ _rootUser = "root"
+
+ // _rootPass is the privileged container password.
+ _rootPass = "rootpass"
+)
+
+// _schema mirrors docker/demo/mariadb/init-demo-db.sql, which is what the
+// demo data source is really created with.
+var _schema = []string{
+ `CREATE TABLE deployments (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ time DATETIME NOT NULL,
+ service VARCHAR(255) NOT NULL,
+ environment VARCHAR(255) NOT NULL,
+ duration_seconds DOUBLE NOT NULL,
+ success BOOLEAN NOT NULL,
+ rollback BOOLEAN NOT NULL DEFAULT FALSE
+ )`,
+ `CREATE TABLE incidents (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ time DATETIME NOT NULL,
+ severity VARCHAR(255) NOT NULL,
+ service VARCHAR(255) NOT NULL,
+ time_to_detect_minutes DOUBLE NOT NULL,
+ time_to_resolve_minutes DOUBLE NOT NULL
+ )`,
+ `CREATE TABLE build_metrics (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ time DATETIME NOT NULL,
+ repository VARCHAR(255) NOT NULL,
+ branch VARCHAR(255) NOT NULL,
+ duration_seconds DOUBLE NOT NULL,
+ test_count INT NOT NULL,
+ tests_failed INT NOT NULL,
+ coverage_pct DOUBLE NOT NULL
+ )`,
+}
+
+// _addr is the address of the throwaway MariaDB container.
+var _addr string
+
+// _tick is a weekday office-hours instant, busy enough that every table gets
+// rows.
+var _tick = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC)
+
+func TestMain(m *testing.M) {
+ // silence the driver's connection noise while the container boots.
+ if err := mysqlDriver.SetLogger(log.New(io.Discard, "", 0)); err != nil {
+ panic("cannot set mysql logger: " + err.Error())
+ }
+
+ container, err := gnomock.StartCustom(
+ "docker.io/library/mariadb:11-noble",
+ gnomock.DefaultTCP(3306),
+ gnomock.WithEnv("MARIADB_ROOT_PASSWORD="+_rootPass),
+ gnomock.WithEnv("MARIADB_DATABASE="+_testDB),
+ gnomock.WithHealthCheck(healthcheck),
+ )
+ if err != nil {
+ panic("cannot set up mariadb: " + err.Error())
+ }
+
+ defer func() {
+ if err = gnomock.Stop(container); err != nil {
+ panic("cannot clean up mariadb: " + err.Error())
+ }
+ }()
+
+ _addr = container.Host + ":" + strconv.Itoa(container.DefaultPort())
+
+ goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
+}
+
+// healthcheck reports whether the container accepts root connections.
+func healthcheck(ctx context.Context, c *gnomock.Container) error {
+ db, err := sql.Open("mysql", fmt.Sprintf(
+ "%s:%s@tcp(%s)/%s",
+ _rootUser,
+ _rootPass,
+ c.Address(gnomock.DefaultPort),
+ _testDB,
+ ))
+ if err != nil {
+ return err
+ }
+
+ defer db.Close() //nolint:errcheck // error provides no meaningful info
+
+ return db.PingContext(ctx)
+}
+
+func Test_NewGenerator(t *testing.T) {
+ t.Parallel()
+
+ lg := slog.New(slog.DiscardHandler)
+
+ g := NewGenerator("user:pass@tcp(host)/db", lg)
+ require.NotNil(t, g)
+
+ assert.Equal(t, lg, g.log)
+ assert.Equal(t, "user:pass@tcp(host)/db", g.dsn)
+ assert.NotNil(t, g.r)
+ assert.Equal(t, demodata.TickInterval, g.interval)
+ assert.Equal(t, _backfillDays, g.backfillDays)
+}
+
+func Test_Generator_Run(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DSN overrides the throwaway database's DSN, for the cases that
+ // must fail before any query runs.
+ DSN string
+
+ // DropTable names a table to remove before the run, so a query
+ // fails against a real connection.
+ DropTable string
+
+ Err error
+ }{
+ "Error returned by sql.Open": {
+ DSN: "not a dsn at all",
+ Err: assert.AnError,
+ },
+ "Error returned by the ping": {
+ DSN: fmt.Sprintf("%s:%s@tcp(127.0.0.1:1)/%s", _rootUser, _rootPass, _testDB),
+ Err: assert.AnError,
+ },
+ "Error returned by needsBackfill": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ "Error returned by backfill": {
+ DropTable: "incidents",
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+ if c.DSN != "" {
+ g.dsn = c.DSN
+ }
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ ctx := t.Context()
+
+ testutil.AssertEqualError(t, c.Err, g.Run(ctx))
+ })
+ }
+
+ // an empty database is backfilled before the tick loop starts.
+ t.Run("Successful run backfills an empty database", func(t *testing.T) {
+ t.Parallel()
+
+ lg, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = lg
+
+ // a wide interval keeps the backfill to a handful of ticks; the
+ // periodic loop then never fires before the context is cancelled.
+ g.interval = 6 * time.Hour
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ // cancelling mid-backfill would abort it, so wait for the run to
+ // announce that it finished.
+ require.Eventually(t, func() bool {
+ require.NoError(t, wr.Flush())
+
+ return strings.Contains(content(), "backfill complete")
+ }, 60*time.Second, 50*time.Millisecond)
+
+ assert.Positive(t, count(t, g.dsn, "deployments"))
+
+ cancel()
+
+ <-stopCh
+ })
+
+ // a populated database skips the backfill and goes straight to
+ // appending a tick per interval.
+ t.Run("Successful run appends to a populated database", func(t *testing.T) {
+ t.Parallel()
+
+ lg, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = lg
+
+ db := open(t, g.dsn)
+ require.NoError(t, g.insertTick(context.Background(), db, _tick))
+
+ seeded := count(t, g.dsn, "deployments")
+ require.Positive(t, seeded)
+
+ g.interval = 10 * time.Millisecond
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ require.Eventually(t, func() bool {
+ return count(t, g.dsn, "deployments") > seeded
+ }, 60*time.Second, 50*time.Millisecond)
+
+ cancel()
+
+ <-stopCh
+
+ require.NoError(t, wr.Flush())
+ assert.NotContains(t, content(), "backfilling historical data")
+ })
+
+ // a failure inside the tick loop is logged and the loop carries on.
+ t.Run("Tick insert failure is logged", func(t *testing.T) {
+ t.Parallel()
+
+ lg, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = lg
+
+ require.NoError(t, g.insertTick(context.Background(), open(t, g.dsn), _tick))
+
+ seeded := count(t, g.dsn, "deployments")
+ require.Positive(t, seeded)
+
+ g.interval = 10 * time.Millisecond
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ // the table can only be pulled out from under the loop once the
+ // loop is running, which its first successful insert proves.
+ require.Eventually(t, func() bool {
+ return count(t, g.dsn, "deployments") > seeded
+ }, 60*time.Second, 50*time.Millisecond)
+
+ exec(t, g.dsn, "DROP TABLE deployments")
+
+ require.Eventually(t, func() bool {
+ require.NoError(t, wr.Flush())
+
+ return strings.Contains(content(), "tick insert failed")
+ }, 60*time.Second, 50*time.Millisecond)
+
+ cancel()
+
+ <-stopCh
+ })
+}
+
+func Test_Generator_needsBackfill(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // Seed indicates whether rows are inserted before the check.
+ Seed bool
+
+ // DropTable removes the table so the query itself fails.
+ DropTable bool
+
+ Result bool
+ Err error
+ }{
+ "Empty database still needs a backfill": {},
+ "Populated database does not": {
+ Seed: true,
+ Result: true,
+ },
+ "Error returned by the query": {
+ DropTable: true,
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+ db := open(t, g.dsn)
+
+ if c.Seed {
+ require.NoError(t, g.insertTick(context.Background(), db, _tick))
+ }
+
+ if c.DropTable {
+ exec(t, g.dsn, "DROP TABLE deployments")
+ }
+
+ res, err := g.needsBackfill(context.Background(), db)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ assert.Equal(t, c.Result, res)
+ })
+ }
+}
+
+func Test_Generator_backfill(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DropTable removes a table so an insert inside the loop fails.
+ DropTable string
+
+ Err error
+ }{
+ "Successful backfill": {},
+ "Error returned by insertTick": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ err := g.backfill(context.Background(), open(t, g.dsn))
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ // one day of ticks covers a stretch of demo history, so every
+ // table has to have been written to.
+ assert.Positive(t, count(t, g.dsn, "deployments"))
+ assert.Positive(t, count(t, g.dsn, "incidents"))
+ assert.Positive(t, count(t, g.dsn, "build_metrics"))
+ })
+ }
+}
+
+func Test_Generator_insertTick(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DropTable removes a table so one of the inserts fails.
+ DropTable string
+
+ // CloseDB indicates the pool should be closed first, so the
+ // transaction cannot even begin.
+ CloseDB bool
+
+ Err error
+ }{
+ "Successful insert": {},
+ "Error returned by the deployment insert": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ "Error returned by the incident insert": {
+ DropTable: "incidents",
+ Err: assert.AnError,
+ },
+ "Error returned by the build insert": {
+ DropTable: "build_metrics",
+ Err: assert.AnError,
+ },
+ "Error returned by BeginTx": {
+ CloseDB: true,
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+ db := open(t, g.dsn)
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ if c.CloseDB {
+ require.NoError(t, db.Close())
+ }
+
+ err := g.insertTick(context.Background(), db, _tick)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ assert.Positive(t, count(t, g.dsn, "deployments"))
+ assert.Positive(t, count(t, g.dsn, "incidents"))
+ assert.Positive(t, count(t, g.dsn, "build_metrics"))
+ })
+ }
+}
+
+// prepGenerator gives a test its own randomly named database in the shared
+// container, already carrying the demo schema.
+func prepGenerator(t *testing.T) *Generator {
+ t.Helper()
+
+ name := "demo_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.Itoa(len(t.Name()))
+
+ exec(t, dsn(_testDB), "CREATE DATABASE "+name)
+
+ for _, q := range _schema {
+ exec(t, dsn(name), q)
+ }
+
+ g := NewGenerator(dsn(name), slog.New(slog.DiscardHandler))
+
+ // a single day keeps the backfill loop to a few hundred ticks instead
+ // of the month the generator runs with in production.
+ g.backfillDays = 1
+
+ // a pinned seed makes the first tick at _tick fill every table, so no
+ // test depends on a sparse table happening to draw a row.
+ g.r = mockmetrics.NewRand(_fullDrawSeed)
+
+ return g
+}
+
+// dsn builds a connection string for one database in the container.
+func dsn(db string) string {
+ return fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", _rootUser, _rootPass, _addr, db)
+}
+
+// open opens a pool that is closed when the test ends.
+func open(t *testing.T, dsn string) *sql.DB {
+ t.Helper()
+
+ db, err := sql.Open("mysql", dsn)
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ // a case that closed the pool itself leaves nothing to do here.
+ db.Close() //nolint:errcheck,gosec // the pool may already be closed
+ })
+
+ require.NoError(t, db.PingContext(context.Background()))
+
+ return db
+}
+
+// exec runs a single statement against the given database.
+func exec(t *testing.T, dsn, query string) {
+ t.Helper()
+
+ db, err := sql.Open("mysql", dsn)
+ require.NoError(t, err)
+
+ defer db.Close() //nolint:errcheck // error provides no meaningful info
+
+ _, err = db.ExecContext(context.Background(), query)
+ require.NoError(t, err)
+}
+
+// count returns the number of rows in a table over a pool of its own, so the
+// polling callers cannot pile up connections.
+func count(t *testing.T, dsn, table string) int {
+ t.Helper()
+
+ db, err := sql.Open("mysql", dsn)
+ require.NoError(t, err)
+
+ defer db.Close() //nolint:errcheck // error provides no meaningful info
+
+ var n int
+
+ require.NoError(t, db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n))
+
+ return n
+}
+
+// bufferLog creates a logger writing into a buffer whose contents the caller
+// can read back once flushed.
+func bufferLog() (*slog.Logger, *testutil.Writer, func() string) {
+ wr, buf := testutil.NewBuffer()
+
+ return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String
+}
+
+// _fullDrawSeed is a seed whose first draw at _tick fills every table, so a
+// test can make any one of the three inserts fail.
+var _fullDrawSeed = fullDraw()
+
+// fullDraw searches for that seed.
+func fullDraw() int64 {
+ for seed := int64(1); ; seed++ {
+ tick := demodata.GenerateTick(mockmetrics.NewRand(seed), _tick)
+ if len(tick.Deployments) > 0 && len(tick.Incidents) > 0 && len(tick.BuildMetrics) > 0 {
+ return seed
+ }
+ }
+}
diff --git a/datagen/internal/mockmetrics/rand.go b/datagen/internal/mockmetrics/rand.go
index 1ae06377..3a197981 100644
--- a/datagen/internal/mockmetrics/rand.go
+++ b/datagen/internal/mockmetrics/rand.go
@@ -1,3 +1,5 @@
+// Package mockmetrics implements the random distributions the demo metrics
+// and demo database rows are sampled from.
package mockmetrics
import (
@@ -10,6 +12,7 @@ func Normal(r *rand.Rand, mean, stddev float64) float64 {
u1 := r.Float64()
u2 := r.Float64()
z := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2)
+
return mean + stddev*z
}
@@ -26,29 +29,36 @@ func Clamp(x, lo, hi float64) float64 {
return x
}
+// _poissonNormalLambda is the lambda above which Poisson switches from
+// Knuth's algorithm to a Normal approximation.
+const _poissonNormalLambda = 50
+
// Poisson returns a Poisson-distributed sample with the given lambda.
-// Knuth's algorithm is used for small lambda; for lambda > 50 a Normal
-// approximation (mean=lambda, stddev=sqrt(lambda)) is used instead, which
-// is accurate and avoids the O(lambda) loop cost at large scale.
+// Knuth's algorithm is used for small lambda; above _poissonNormalLambda a
+// Normal approximation (mean=lambda, stddev=sqrt(lambda)) is used instead,
+// which is accurate and avoids the O(lambda) loop cost at large scale.
func Poisson(r *rand.Rand, lambda float64) int {
if lambda <= 0 {
return 0
}
- if lambda > 50 {
+ if lambda > _poissonNormalLambda {
v := int(math.Round(Normal(r, lambda, math.Sqrt(lambda))))
if v < 0 {
+ // NOCOV: reaching this needs a sample more than sqrt(lambda)
+ // deviations below the mean, which past lambda 50 is a
+ // once-in-a-trillion draw.
return 0
}
return v
}
- L := math.Exp(-lambda)
+ limit := math.Exp(-lambda)
k := 0
p := 1.0
- for p > L {
+ for p > limit {
k++
p *= r.Float64()
}
@@ -71,5 +81,6 @@ func RandCount(r *rand.Rand, mean, relStdDev float64) int {
// NewRand returns a new rand seeded with seed.
func NewRand(seed int64) *rand.Rand {
+ //nolint:gosec // demo data wants a cheap reproducible source, not a cryptographic one
return rand.New(rand.NewSource(seed))
}
diff --git a/datagen/internal/mockmetrics/rand_test.go b/datagen/internal/mockmetrics/rand_test.go
new file mode 100644
index 00000000..ba9af94a
--- /dev/null
+++ b/datagen/internal/mockmetrics/rand_test.go
@@ -0,0 +1,250 @@
+package mockmetrics
+
+import (
+ "math"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+func Test_Normal(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Mean float64
+ StdDev float64
+ }{
+ "Zero deviation always returns the mean": {
+ Mean: 42,
+ },
+ "Non-zero deviation spreads around the mean": {
+ Mean: 100,
+ StdDev: 15,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ r := NewRand(1)
+
+ // a zero deviation collapses the distribution onto the mean,
+ // which is the only exact assertion available here.
+ if c.StdDev == 0 {
+ assert.InDelta(t, c.Mean, Normal(r, c.Mean, c.StdDev), 1e-9)
+ return
+ }
+
+ // otherwise the sample mean of a large draw must land near the
+ // requested mean, and the samples must not all be identical.
+ var (
+ sum float64
+ first = Normal(r, c.Mean, c.StdDev)
+ same = true
+ )
+
+ sum += first
+
+ for range 9_999 {
+ v := Normal(r, c.Mean, c.StdDev)
+ if v != first {
+ same = false
+ }
+
+ sum += v
+ }
+
+ assert.False(t, same)
+ assert.InDelta(t, c.Mean, sum/10_000, c.StdDev)
+ })
+ }
+}
+
+func Test_Clamp(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ X float64
+ Lo float64
+ Hi float64
+ Result float64
+ }{
+ "Below the lower bound": {
+ X: -5,
+ Lo: 0,
+ Hi: 10,
+ Result: 0,
+ },
+ "Above the upper bound": {
+ X: 15,
+ Lo: 0,
+ Hi: 10,
+ Result: 10,
+ },
+ "Within the bounds": {
+ X: 5,
+ Lo: 0,
+ Hi: 10,
+ Result: 5,
+ },
+ "Exactly on the lower bound": {
+ X: 0,
+ Lo: 0,
+ Hi: 10,
+ Result: 0,
+ },
+ "Exactly on the upper bound": {
+ X: 10,
+ Lo: 0,
+ Hi: 10,
+ Result: 10,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.InDelta(t, c.Result, Clamp(c.X, c.Lo, c.Hi), 1e-9)
+ })
+ }
+}
+
+func Test_Poisson(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Lambda float64
+
+ // Exact holds the single value every draw must produce, used for the
+ // degenerate lambdas that cannot vary.
+ Exact *int
+
+ // Draws is how many samples the statistical assertions run over.
+ Draws int
+ }{
+ "Zero lambda never produces an event": {
+ Lambda: 0,
+ Exact: new(0),
+ },
+ "Negative lambda never produces an event": {
+ Lambda: -1,
+ Exact: new(0),
+ },
+ "Small lambda goes through Knuth's algorithm": {
+ Lambda: 5,
+ Draws: 10_000,
+ },
+ "Lambda on the switchover boundary still uses Knuth": {
+ Lambda: _poissonNormalLambda,
+ Draws: 10_000,
+ },
+ "Large lambda goes through the normal approximation": {
+ Lambda: 5_000,
+ Draws: 10_000,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ r := NewRand(1)
+
+ if c.Exact != nil {
+ assert.Equal(t, *c.Exact, Poisson(r, c.Lambda))
+ return
+ }
+
+ var sum int
+
+ for range c.Draws {
+ v := Poisson(r, c.Lambda)
+ require.GreaterOrEqual(t, v, 0)
+
+ sum += v
+ }
+
+ // the mean of a Poisson draw is lambda itself; allow a generous
+ // band so the assertion never races the sampler.
+ assert.InDelta(t, c.Lambda, float64(sum)/float64(c.Draws), math.Sqrt(c.Lambda))
+ })
+ }
+}
+
+func Test_RandCount(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Mean float64
+ RelStdDev float64
+
+ // Exact holds the value every draw must produce when the parameters
+ // leave no room for variation.
+ Exact *int
+ }{
+ "Zero deviation returns the rounded mean": {
+ Mean: 1_000,
+ Exact: new(1_000),
+ },
+ "Negative mean is clamped to zero": {
+ Mean: -50,
+ Exact: new(0),
+ },
+ "Spread mean stays non-negative": {
+ Mean: 500,
+ RelStdDev: 0.2,
+ },
+ "Deviation wide enough to undershoot zero is clamped": {
+ Mean: 10,
+ RelStdDev: 2,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ r := NewRand(1)
+
+ if c.Exact != nil {
+ assert.Equal(t, *c.Exact, RandCount(r, c.Mean, c.RelStdDev))
+ return
+ }
+
+ var sum int
+
+ for range 10_000 {
+ v := RandCount(r, c.Mean, c.RelStdDev)
+ require.GreaterOrEqual(t, v, 0)
+
+ sum += v
+ }
+
+ assert.InDelta(t, c.Mean, float64(sum)/10_000, c.Mean*c.RelStdDev)
+ })
+ }
+}
+
+func Test_NewRand(t *testing.T) {
+ t.Parallel()
+
+ // the same seed replays the same sequence.
+ a, b := NewRand(7), NewRand(7)
+ require.NotNil(t, a)
+ require.NotNil(t, b)
+
+ for range 100 {
+ assert.InDelta(t, a.Float64(), b.Float64(), 1e-12)
+ }
+
+ // a different seed does not.
+ assert.NotEqual(t, NewRand(7).Float64(), NewRand(8).Float64())
+}
diff --git a/datagen/internal/pgdemo/pgdemo.go b/datagen/internal/pgdemo/pgdemo.go
index 9f6bfb7d..a7e77592 100644
--- a/datagen/internal/pgdemo/pgdemo.go
+++ b/datagen/internal/pgdemo/pgdemo.go
@@ -1,3 +1,5 @@
+// Package pgdemo fills the demo PostgreSQL data source with generated
+// time-series rows.
package pgdemo
import (
@@ -10,23 +12,38 @@ import (
"github.com/jackc/pgx/v5"
"github.com/oxynote/oxynote/datagen/internal/demodata"
"github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
)
+// _backfillDays defines how much history an empty database is filled with.
const _backfillDays = 30
// Generator inserts demo time-series data into PostgreSQL.
type Generator struct {
- dsn string
- r *rand.Rand
+ // log is the structured logger.
log *slog.Logger
+
+ // dsn addresses the database to fill.
+ dsn string
+
+ // r is the random source used for all row generation.
+ r *rand.Rand
+
+ // interval defines how often a new tick of rows is appended.
+ interval time.Duration
+
+ // backfillDays defines how much history an empty database is filled with.
+ backfillDays int
}
-// NewGenerator creates a new demo data generator.
+// NewGenerator creates a fresh instance of Generator.
func NewGenerator(dsn string, log *slog.Logger) *Generator {
return &Generator{
- dsn: dsn,
- r: mockmetrics.NewRand(time.Now().UnixNano()),
- log: log,
+ log: log,
+ dsn: dsn,
+ r: mockmetrics.NewRand(timeutil.Now().UnixNano()),
+ interval: demodata.TickInterval,
+ backfillDays: _backfillDays,
}
}
@@ -37,7 +54,7 @@ func (g *Generator) Run(ctx context.Context) error {
if err != nil {
return fmt.Errorf("pgdemo: connect: %w", err)
}
- defer conn.Close(ctx)
+ defer conn.Close(ctx) //nolint:errcheck // error provides no meaningful info
backfilled, err := g.needsBackfill(ctx, conn)
if err != nil {
@@ -45,7 +62,7 @@ func (g *Generator) Run(ctx context.Context) error {
}
if !backfilled {
- g.log.Info("pgdemo: backfilling historical data", slog.Int("days", _backfillDays))
+ g.log.Info("pgdemo: backfilling historical data", slog.Int("days", g.backfillDays))
if err := g.backfill(ctx, conn); err != nil {
return fmt.Errorf("pgdemo: backfill: %w", err)
@@ -54,18 +71,19 @@ func (g *Generator) Run(ctx context.Context) error {
g.log.Info("pgdemo: backfill complete")
}
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-time.After(demodata.TickInterval):
- now := time.Now().UTC()
-
- if err := g.insertTick(ctx, conn, now); err != nil {
+ timeutil.NewPeriodicExec(
+ g.interval,
+ 0,
+ func(ctx context.Context) {
+ if err := g.insertTick(ctx, conn, timeutil.Now()); err != nil {
g.log.Error("pgdemo: tick insert failed", slog.String("error", err.Error()))
}
- }
- }
+ },
+ nil,
+ false,
+ ).Start(ctx)
+
+ return nil
}
// needsBackfill returns true if historical data already exists.
@@ -80,12 +98,12 @@ func (g *Generator) needsBackfill(ctx context.Context, conn *pgx.Conn) (bool, er
return count > 0, nil
}
-// backfill generates _backfillDays worth of historical data.
+// backfill generates backfillDays worth of historical data.
func (g *Generator) backfill(ctx context.Context, conn *pgx.Conn) error {
- now := time.Now().UTC()
- start := now.AddDate(0, 0, -_backfillDays)
+ now := timeutil.Now()
+ start := now.AddDate(0, 0, -g.backfillDays)
- for t := start; t.Before(now); t = t.Add(demodata.TickInterval) {
+ for t := start; t.Before(now); t = t.Add(g.interval) {
if err := g.insertTick(ctx, conn, t); err != nil {
return err
}
@@ -125,7 +143,7 @@ func (g *Generator) insertTick(ctx context.Context, conn *pgx.Conn, t time.Time)
}
br := conn.SendBatch(ctx, batch)
- defer br.Close()
+ defer br.Close() //nolint:errcheck // the exec errors below already report what failed
for range batch.Len() {
if _, err := br.Exec(); err != nil {
diff --git a/datagen/internal/pgdemo/pgdemo_test.go b/datagen/internal/pgdemo/pgdemo_test.go
new file mode 100644
index 00000000..0c6b9297
--- /dev/null
+++ b/datagen/internal/pgdemo/pgdemo_test.go
@@ -0,0 +1,547 @@
+package pgdemo
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/orlangure/gnomock"
+ pgDocker "github.com/orlangure/gnomock/preset/postgres"
+ "github.com/oxynote/oxynote/datagen/internal/demodata"
+ "github.com/oxynote/oxynote/datagen/internal/mockmetrics"
+ "github.com/oxynote/oxynote/server/core/pkg/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+const (
+ // _testDB is the database name used in the throwaway container.
+ _testDB = "testdb"
+
+ // _pgUser is the privileged container user.
+ _pgUser = "pgtest"
+
+ // _pgPass is the privileged container password.
+ _pgPass = "pgpass"
+)
+
+// _schema mirrors docker/demo/postgres/init-demo-db.sh, which is what the
+// demo data source is really created with.
+var _schema = []string{
+ `CREATE TABLE deployments (
+ id BIGSERIAL PRIMARY KEY,
+ time TIMESTAMPTZ NOT NULL,
+ service TEXT NOT NULL,
+ environment TEXT NOT NULL,
+ duration_seconds DOUBLE PRECISION NOT NULL,
+ success BOOLEAN NOT NULL,
+ rollback BOOLEAN NOT NULL DEFAULT FALSE
+ )`,
+ `CREATE TABLE incidents (
+ id BIGSERIAL PRIMARY KEY,
+ time TIMESTAMPTZ NOT NULL,
+ severity TEXT NOT NULL,
+ service TEXT NOT NULL,
+ time_to_detect_minutes DOUBLE PRECISION NOT NULL,
+ time_to_resolve_minutes DOUBLE PRECISION NOT NULL
+ )`,
+ `CREATE TABLE build_metrics (
+ id BIGSERIAL PRIMARY KEY,
+ time TIMESTAMPTZ NOT NULL,
+ repository TEXT NOT NULL,
+ branch TEXT NOT NULL,
+ duration_seconds DOUBLE PRECISION NOT NULL,
+ test_count INTEGER NOT NULL,
+ tests_failed INTEGER NOT NULL,
+ coverage_pct DOUBLE PRECISION NOT NULL
+ )`,
+}
+
+// _pgAddr is the address of the throwaway PostgreSQL container.
+var _pgAddr string
+
+func TestMain(m *testing.M) {
+ container, err := gnomock.Start(pgDocker.Preset(
+ pgDocker.WithUser(_pgUser, _pgPass),
+ pgDocker.WithDatabase(_testDB),
+ ))
+ if err != nil {
+ panic("cannot set up postgres: " + err.Error())
+ }
+
+ defer func() {
+ if err = gnomock.Stop(container); err != nil {
+ panic("cannot clean up postgres: " + err.Error())
+ }
+ }()
+
+ _pgAddr = container.Host + ":" + strconv.Itoa(container.DefaultPort())
+
+ goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
+}
+
+func Test_NewGenerator(t *testing.T) {
+ t.Parallel()
+
+ log := slog.New(slog.DiscardHandler)
+
+ g := NewGenerator("postgres://test", log)
+ require.NotNil(t, g)
+
+ assert.Equal(t, log, g.log)
+ assert.Equal(t, "postgres://test", g.dsn)
+ assert.NotNil(t, g.r)
+ assert.Equal(t, demodata.TickInterval, g.interval)
+ assert.Equal(t, _backfillDays, g.backfillDays)
+}
+
+func Test_Generator_Run(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DSN overrides the throwaway database's DSN, for the cases that
+ // must fail before any query runs.
+ DSN string
+
+ // DropTable names a table to remove before the run, so the backfill
+ // check fails against a real connection.
+ DropTable string
+
+ // Err is the expected outcome of the run.
+ Err error
+ }{
+ "Error returned by pgx.Connect": {
+ DSN: "postgres://nobody@127.0.0.1:1/nothing",
+ Err: assert.AnError,
+ },
+ "Error returned by needsBackfill": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ "Error returned by backfill": {
+ DropTable: "incidents",
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+ if c.DSN != "" {
+ g.dsn = c.DSN
+ }
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ ctx := t.Context()
+
+ testutil.AssertEqualError(t, c.Err, g.Run(ctx))
+ })
+ }
+
+ // an empty database is backfilled before the tick loop starts.
+ t.Run("Successful run backfills an empty database", func(t *testing.T) {
+ t.Parallel()
+
+ log, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = log
+
+ // a wide interval keeps the backfill to a handful of ticks; the
+ // periodic loop then never fires before the context is cancelled.
+ g.interval = 6 * time.Hour
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ // cancelling mid-backfill would abort it, so wait for the run to
+ // announce that it finished.
+ require.Eventually(t, func() bool {
+ require.NoError(t, wr.Flush())
+
+ return strings.Contains(content(), "backfill complete")
+ }, 60*time.Second, 50*time.Millisecond)
+
+ assert.Positive(t, count(t, g.dsn, "deployments"))
+
+ cancel()
+
+ <-stopCh
+ })
+
+ // a populated database skips the backfill and goes straight to
+ // appending a tick per interval.
+ t.Run("Successful run appends to a populated database", func(t *testing.T) {
+ t.Parallel()
+
+ log, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = log
+
+ require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick))
+
+ seeded := count(t, g.dsn, "deployments")
+ require.Positive(t, seeded)
+
+ g.interval = 10 * time.Millisecond
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ require.Eventually(t, func() bool {
+ return count(t, g.dsn, "deployments") > seeded
+ }, 60*time.Second, 50*time.Millisecond)
+
+ cancel()
+
+ <-stopCh
+
+ require.NoError(t, wr.Flush())
+ assert.NotContains(t, content(), "backfilling historical data")
+ })
+
+ // a failure inside the tick loop is logged and the loop carries on.
+ t.Run("Tick insert failure is logged", func(t *testing.T) {
+ t.Parallel()
+
+ lg, wr, content := bufferLog()
+
+ g := prepGenerator(t)
+ g.log = lg
+
+ require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick))
+
+ seeded := count(t, g.dsn, "deployments")
+ require.Positive(t, seeded)
+
+ g.interval = 10 * time.Millisecond
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ assert.NoError(t, g.Run(ctx))
+ }()
+
+ // the table can only be pulled out from under the loop once the
+ // loop is running, which its first successful insert proves.
+ require.Eventually(t, func() bool {
+ return count(t, g.dsn, "deployments") > seeded
+ }, 60*time.Second, 50*time.Millisecond)
+
+ exec(t, g.dsn, "DROP TABLE deployments")
+
+ require.Eventually(t, func() bool {
+ require.NoError(t, wr.Flush())
+
+ return strings.Contains(content(), "tick insert failed")
+ }, 60*time.Second, 50*time.Millisecond)
+
+ cancel()
+
+ <-stopCh
+ })
+}
+
+func Test_Generator_needsBackfill(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // Seed indicates whether a row is inserted before the check.
+ Seed bool
+
+ // DropTable removes the table so the query itself fails.
+ DropTable bool
+
+ Result bool
+ Err error
+ }{
+ "Empty database still needs a backfill": {},
+ "Populated database does not": {
+ Seed: true,
+ Result: true,
+ },
+ "Error returned by the query": {
+ DropTable: true,
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+
+ if c.Seed {
+ require.NoError(t, g.insertTick(context.Background(), connect(t, g.dsn), _tick))
+ }
+
+ if c.DropTable {
+ exec(t, g.dsn, "DROP TABLE deployments")
+ }
+
+ res, err := g.needsBackfill(context.Background(), connect(t, g.dsn))
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ assert.Equal(t, c.Result, res)
+ })
+ }
+}
+
+func Test_Generator_backfill(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DropTable removes a table so an insert inside the loop fails.
+ DropTable string
+
+ Err error
+ }{
+ "Successful backfill": {},
+ "Error returned by insertTick": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ err := g.backfill(context.Background(), connect(t, g.dsn))
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ // one day of ticks covers a stretch of demo history, so every
+ // table has to have been written to.
+ assert.Positive(t, count(t, g.dsn, "deployments"))
+ assert.Positive(t, count(t, g.dsn, "incidents"))
+ assert.Positive(t, count(t, g.dsn, "build_metrics"))
+ })
+ }
+}
+
+func Test_Generator_insertTick(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ // DropTable removes a table so the batch fails on exec.
+ DropTable string
+
+ // EmptyTick indicates the generator should be steered at an instant
+ // that produces nothing at all.
+ EmptyTick bool
+
+ Err error
+ }{
+ "Successful insert": {},
+ "A tick with no rows sends no batch": {
+ EmptyTick: true,
+ },
+ "Error returned by the batch": {
+ DropTable: "deployments",
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ g := prepGenerator(t)
+
+ if c.DropTable != "" {
+ exec(t, g.dsn, "DROP TABLE "+c.DropTable)
+ }
+
+ at := _tick
+
+ // the early return needs a tick that generates nothing, which
+ // only a specific seed at a quiet instant produces.
+ if c.EmptyTick {
+ seed, quiet := emptyDraw()
+ g.r = mockmetrics.NewRand(seed)
+ at = quiet
+ }
+
+ err := g.insertTick(context.Background(), connect(t, g.dsn), at)
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ return
+ }
+
+ total := count(t, g.dsn, "deployments") +
+ count(t, g.dsn, "incidents") +
+ count(t, g.dsn, "build_metrics")
+
+ if c.EmptyTick {
+ assert.Zero(t, total)
+ return
+ }
+
+ assert.Positive(t, total)
+ })
+ }
+}
+
+// _tick is a weekday office-hours instant, busy enough that every table gets
+// rows.
+var _tick = time.Date(2024, time.January, 9, 11, 0, 0, 0, time.UTC)
+
+// prepGenerator gives a test its own randomly named database in the shared
+// container, already carrying the demo schema.
+func prepGenerator(t *testing.T) *Generator {
+ t.Helper()
+
+ name := "demo_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.Itoa(len(t.Name()))
+
+ admin := connect(t, dsn(_testDB))
+ _, err := admin.Exec(context.Background(), "CREATE DATABASE "+name)
+ require.NoError(t, err)
+
+ conn := connect(t, dsn(name))
+ for _, q := range _schema {
+ _, err = conn.Exec(context.Background(), q)
+ require.NoError(t, err)
+ }
+
+ g := NewGenerator(dsn(name), slog.New(slog.DiscardHandler))
+
+ // a single day keeps the backfill loop to a few hundred ticks instead
+ // of the month the generator runs with in production.
+ g.backfillDays = 1
+
+ // a pinned seed makes the first tick at _tick fill every table, so no
+ // test depends on a sparse table happening to draw a row.
+ g.r = mockmetrics.NewRand(_fullDrawSeed)
+
+ return g
+}
+
+// dsn builds a connection string for one database in the container.
+func dsn(db string) string {
+ return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", _pgUser, _pgPass, _pgAddr, db)
+}
+
+// connect opens a connection that is closed when the test ends.
+func connect(t *testing.T, dsn string) *pgx.Conn {
+ t.Helper()
+
+ conn, err := pgx.Connect(context.Background(), dsn)
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ assert.NoError(t, conn.Close(context.Background()))
+ })
+
+ return conn
+}
+
+// exec runs a single statement against the given database.
+func exec(t *testing.T, dsn, query string) {
+ t.Helper()
+
+ _, err := connect(t, dsn).Exec(context.Background(), query)
+ require.NoError(t, err)
+}
+
+// count returns the number of rows in a table over a connection of its own,
+// so the polling callers cannot pile up connections.
+func count(t *testing.T, dsn, table string) int {
+ t.Helper()
+
+ conn, err := pgx.Connect(context.Background(), dsn)
+ require.NoError(t, err)
+
+ defer func() {
+ assert.NoError(t, conn.Close(context.Background()))
+ }()
+
+ var n int
+
+ require.NoError(t, conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&n))
+
+ return n
+}
+
+// emptyDraw finds a seed and a quiet instant that together generate nothing
+// at all, so the empty-tick early return can be reached deterministically.
+func emptyDraw() (int64, time.Time) {
+ at := time.Date(2024, time.January, 7, 3, 0, 0, 0, time.UTC)
+
+ for seed := int64(1); ; seed++ {
+ tick := demodata.GenerateTick(mockmetrics.NewRand(seed), at)
+ if len(tick.Deployments)+len(tick.Incidents)+len(tick.BuildMetrics) == 0 {
+ return seed, at
+ }
+ }
+}
+
+// bufferLog creates a logger writing into a buffer whose contents the caller
+// can read back once flushed.
+func bufferLog() (*slog.Logger, *testutil.Writer, func() string) {
+ wr, buf := testutil.NewBuffer()
+
+ return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String
+}
+
+// _fullDrawSeed is a seed whose first draw at _tick fills every table, so a
+// dropped table reliably breaks the batch.
+var _fullDrawSeed = fullDraw()
+
+// fullDraw searches for that seed.
+func fullDraw() int64 {
+ for seed := int64(1); ; seed++ {
+ tick := demodata.GenerateTick(mockmetrics.NewRand(seed), _tick)
+ if len(tick.Deployments) > 0 && len(tick.Incidents) > 0 && len(tick.BuildMetrics) > 0 {
+ return seed
+ }
+ }
+}
diff --git a/datagen/internal/server/server.go b/datagen/internal/server/server.go
index a885bb40..e88c3166 100644
--- a/datagen/internal/server/server.go
+++ b/datagen/internal/server/server.go
@@ -1,3 +1,4 @@
+// Package server exposes the generated demo metrics over HTTP.
package server
import (
@@ -15,6 +16,9 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)
+// _requestTimeout bounds how long a single scrape may take.
+const _requestTimeout = 30 * time.Second
+
// Server exposes metrics over HTTP.
type Server struct {
log *slog.Logger
@@ -71,6 +75,8 @@ func (s *Server) Address() string {
// Close performs clean up operations.
func (s *Server) Close() error {
if err := s.http.Shutdown(context.Background()); err != nil {
+ // NOCOV: shutting down against an unbounded context only fails if
+ // closing a listener does, which cannot be provoked in tests.
return err
}
@@ -83,11 +89,11 @@ func (s *Server) Close() error {
func (s *Server) router() chi.Router {
r := chi.NewRouter()
- r.Use(httpserver.Timeout(30 * time.Second))
+ r.Use(httpserver.Timeout(_requestTimeout))
r.Use(httpserver.Recoverer(s.log))
r.Handle("/api/metrics", promhttp.InstrumentMetricHandler(
- s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{}),
+ s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{CoalesceGather: true}),
))
r.MethodNotAllowed(httpserver.MethodNotAllowed(s.log))
diff --git a/datagen/internal/server/server_test.go b/datagen/internal/server/server_test.go
new file mode 100644
index 00000000..3e224989
--- /dev/null
+++ b/datagen/internal/server/server_test.go
@@ -0,0 +1,206 @@
+package server
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/oxynote/oxynote/server/core/pkg/metricutil"
+ "github.com/oxynote/oxynote/server/core/pkg/testutil"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+func Test_New(t *testing.T) {
+ t.Parallel()
+
+ var (
+ log = slog.New(slog.DiscardHandler)
+ fc = newFactory()
+ )
+
+ s := New(log, fc)
+ require.NotNil(t, s)
+
+ assert.Equal(t, log, s.log)
+ assert.Equal(t, fc, s.fc)
+
+ require.NotNil(t, s.http)
+ assert.Equal(t, ":8080", s.http.Addr)
+ assert.Equal(t, time.Minute, s.http.ReadTimeout)
+ assert.Equal(t, time.Minute, s.http.ReadHeaderTimeout)
+ assert.NotNil(t, s.http.Handler)
+ assert.NotNil(t, s.http.ErrorLog)
+}
+
+func Test_Server_Listen(t *testing.T) {
+ t.Parallel()
+
+ log, wr, content := bufferLog()
+
+ s := Server{
+ log: log,
+ http: &http.Server{Addr: ":a", ReadHeaderTimeout: time.Minute},
+ }
+
+ // an invalid port makes ListenAndServe fail immediately.
+ s.Listen()
+
+ require.NoError(t, wr.Flush())
+ assert.Contains(t, content(), `"msg":"listening"`)
+ assert.Contains(t, content(), "cannot terminate gracefully")
+
+ // a graceful shutdown ends Listen without the error log.
+ log2, wr2, content2 := bufferLog()
+
+ s2 := Server{
+ log: log2,
+ http: &http.Server{Addr: "127.0.0.1:0", ReadHeaderTimeout: time.Minute},
+ }
+
+ stopCh := make(chan struct{})
+
+ go func() {
+ defer close(stopCh)
+
+ s2.Listen()
+ }()
+
+ require.Eventually(t, func() bool {
+ require.NoError(t, wr2.Flush())
+
+ return content2() != ""
+ }, 5*time.Second, 10*time.Millisecond)
+
+ require.NoError(t, s2.http.Shutdown(context.Background()))
+
+ <-stopCh
+
+ require.NoError(t, wr2.Flush())
+ assert.Contains(t, content2(), `"msg":"listening"`)
+ assert.NotContains(t, content2(), "cannot terminate gracefully")
+}
+
+func Test_Server_Address(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Addr string
+ Result string
+ }{
+ "Default HTTP port is omitted": {
+ Addr: ":80",
+ Result: "http://localhost",
+ },
+ "Custom port is included": {
+ Addr: ":1234",
+ Result: "http://localhost:1234",
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ s := Server{http: &http.Server{Addr: c.Addr, ReadHeaderTimeout: time.Minute}}
+
+ assert.Equal(t, c.Result, s.Address())
+ })
+ }
+}
+
+func Test_Server_Close(t *testing.T) {
+ t.Parallel()
+
+ s := Server{
+ log: slog.New(slog.DiscardHandler),
+ http: &http.Server{ReadHeaderTimeout: time.Minute},
+ }
+
+ assert.NoError(t, s.Close())
+}
+
+func Test_Server_router(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Method string
+ Path string
+ RespCode int
+
+ // Contains is a fragment the response body must carry, used where
+ // the body is a metrics dump rather than a fixed payload.
+ Contains string
+ }{
+ "Metrics are served": {
+ Method: http.MethodGet,
+ Path: "/api/metrics",
+ RespCode: http.StatusOK,
+ Contains: "engineering_test_gauge",
+ },
+ "Unknown path is not found": {
+ Method: http.MethodGet,
+ Path: "/api/nothing",
+ RespCode: http.StatusNotFound,
+ },
+ // the metrics route is registered for every method, so a scrape
+ // with an odd verb is answered rather than rejected.
+ "Metrics answer any method": {
+ Method: http.MethodDelete,
+ Path: "/api/metrics",
+ RespCode: http.StatusOK,
+ Contains: "engineering_test_gauge",
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ fc := newFactory()
+
+ // a registered gauge gives the scrape something to report.
+ fc.NewGauge(metricutil.Options{Name: "test_gauge", Help: "Tracks nothing."}).Set(1)
+
+ s := Server{log: slog.New(slog.DiscardHandler), fc: fc}
+
+ req := httptest.NewRequest(c.Method, "http://test.com"+c.Path, http.NoBody)
+ rec := httptest.NewRecorder()
+
+ s.router().ServeHTTP(rec, req)
+
+ assert.Equal(t, c.RespCode, rec.Code)
+
+ if c.Contains != "" {
+ assert.Contains(t, rec.Body.String(), c.Contains)
+ }
+ })
+ }
+}
+
+// newFactory creates a metrics factory backed by a registry of its own, so
+// parallel tests never collide over metric names.
+func newFactory() metricutil.Factory {
+ return metricutil.NewFactory(
+ "engineering",
+ prometheus.NewRegistry(),
+ metricutil.WithCustomHost("demo"),
+ )
+}
+
+// bufferLog creates a logger writing into a buffer whose contents the caller
+// can read back once flushed.
+func bufferLog() (*slog.Logger, *testutil.Writer, func() string) {
+ wr, buf := testutil.NewBuffer()
+
+ return slog.New(slog.NewJSONHandler(wr, nil)), wr, buf.String
+}
diff --git a/docker/demo/prometheus/prometheus.yaml b/docker/demo/prometheus/prometheus.yaml
deleted file mode 100644
index a1bda1b5..00000000
--- a/docker/demo/prometheus/prometheus.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-global:
- scrape_interval: 5s
- evaluation_interval: 5s
-
-scrape_configs:
- - job_name: 'core'
- metrics_path: api/x/metrics
- static_configs:
- - targets: ['core:8080']
- - job_name: 'datagen'
- metrics_path: api/metrics
- static_configs:
- - targets: ['datagen:8080']
diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml
index a465ad17..11ec0543 100644
--- a/docker/docker-compose.dev.yaml
+++ b/docker/docker-compose.dev.yaml
@@ -238,21 +238,6 @@ services:
- SCREEN_DEPTH=16
- MAX_CONCURRENT_CHROME_PROCESSES=10
- LOG_LEVEL=WARNING
- prometheus:
- container_name: prometheus
- image: prom/prometheus:latest
- restart: unless-stopped
- volumes:
- - prometheus_data:/prometheus
- - ./demo/prometheus:/etc/prometheus/
- command:
- - "--config.file=/etc/prometheus/prometheus.yaml"
- logging:
- driver: local
- options:
- mode: non-blocking
- max-size: 10m
- max-file: "3"
grafana:
image: grafana/grafana:latest
container_name: grafana
@@ -273,5 +258,4 @@ volumes:
caddy_data:
caddy_config:
changedetection_data:
- prometheus_data:
grafana_storage:
diff --git a/docker/env/core.example.env b/docker/env/core.example.env
index 398c8308..a23e384b 100644
--- a/docker/env/core.example.env
+++ b/docker/env/core.example.env
@@ -18,7 +18,6 @@ OXYNOTE_CORE_STORAGE_BUCKET=dev
OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL=http://auth-realtime:8081/api/auth/get-session
OXYNOTE_CORE_SERVER_MCP_SESSION_URL=http://auth-realtime:8081/api/internal/mcp/session
OXYNOTE_CORE_AUTH_REALTIME_URL=http://auth-realtime:8081
-OXYNOTE_CORE_SERVER_DEMO_PROMETHEUS_URL=http://prometheus:9090
# an empty URL disables changedetection.io: url-watcher hooks cannot be
# created and existing ones are skipped.
OXYNOTE_CORE_CHANGEDETECTION_API_URL=http://changedetection:5000
diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.yaml
index 9ff07331..1880fb29 100644
--- a/e2e/docker-compose.yaml
+++ b/e2e/docker-compose.yaml
@@ -6,7 +6,7 @@
# - no container_name, so this stack coexists with the dev one
# - env is inlined and committed; there is nothing secret to protect
# - only the services the tests exercise (no changedetection, mariadb,
-# datagen, prometheus or grafana)
+# datagen or grafana)
#
# the front door is the dev Caddyfile verbatim: e2e must exercise the
# same routing and the same /api/x and /api/internal blocks that ship.
@@ -124,12 +124,6 @@ services:
- OXYNOTE_CORE_BASE_APP_URL=http://localhost:18080
- OXYNOTE_CORE_ORIGINS=http://localhost:18080,localhost:18080
- OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET=e2esigningsecret
- # core only seeds a new workspace's welcome document while a demo
- # prometheus URL is set — with it empty, initialize returns early and
- # the onboarding page polls for a first document that never comes.
- # The URL is stored, not dialed, so there is no prometheus here; a
- # block that queries it would simply get a connection error.
- - OXYNOTE_CORE_SERVER_DEMO_PROMETHEUS_URL=http://prometheus.invalid:9090
- OXYNOTE_CORE_EMAIL_SMTP_HOST=mailpit
- OXYNOTE_CORE_EMAIL_SMTP_PORT=1025
- OXYNOTE_CORE_EMAIL_SMTP_TLS=none
diff --git a/server/CLAUDE.md b/server/CLAUDE.md
index a5eb86b3..4409dbd8 100644
--- a/server/CLAUDE.md
+++ b/server/CLAUDE.md
@@ -13,7 +13,7 @@ infrastructure, and the non-Go services.
- `server/core/` — Go module `github.com/oxynote/oxynote/server/core`. One binary: `cmd/core` (`oxynote-core`) — main API server. Listens on `:8080`. Exposes `/api/...` (auth-required), `/api/apps/...` (public, sessionless third-party callbacks), and `/api/x/...` (internal-only, no auth; the reverse proxy is expected to firewall this). Owns Postgres, Meilisearch, Valkey (Redis), MinIO, the GitHub/Slack apps, the assistant, and the outbound data-source connections (`internal/datasource`).
- `server/auth-realtime/` — `@oxynote/auth-realtime` package. TypeScript ES-module service that runs Better Auth (organization plugin) and a Hocuspocus (Yjs CRDT) server in a single Hono process on port `8081`. Forwards non-auth `/api/...` traffic to core (`OXYNOTE_AUTH_REALTIME_BACKEND_URL`).
-- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content and a fake metrics endpoint scraped by Prometheus for the demo data sources.
+- `datagen/` — separate Go module `github.com/oxynote/oxynote/datagen`. Synthesises demo Postgres/MariaDB content, and serves a fake metrics endpoint for a Prometheus to scrape. The demo Prometheus data source no longer uses that endpoint: core synthesises those metrics itself (`server/core/internal/datasource/demo`), so nothing in the dev stack scrapes it.
Go builds go through **goreleaser**: `make build` in `server/core` and `datagen` runs `goreleaser release --snapshot --clean`, producing binaries in `bin/` and the `ghcr.io/oxynote/{core,datagen}:dev` images via each module's `Dockerfile.dev`. The dev compose stack consumes those images; `auth-realtime` (and `web/`) are built from source by compose via their `Dockerfile.dev`. `make run` / `make start` from the repository root orchestrates both.
@@ -31,8 +31,11 @@ Per-component:
# core
cd server/core && make build # goreleaser release --snapshot --clean -> bin/
-# datagen
+# datagen — same targets as core
cd datagen && make build
+cd datagen && make test # go test -race ./... (pgdemo/mariademo need docker)
+cd datagen && make check-coverage # fails below COVERAGE_MIN
+cd datagen && make check-lint # golangci-lint; lint = the fixing variant
# auth-realtime (full details in auth-realtime/CLAUDE.md)
cd server/auth-realtime && pnpm run dev # local watch mode (tsx + sentry.ts preloaded)
@@ -43,8 +46,12 @@ cd server/auth-realtime && pnpm run test # vitest run --coverage
cd server/auth-realtime && pnpm run qa # check-lint + test; qa-fix = lint + test
```
-The Go test/lint workflow is documented in [core/CLAUDE.md](core/CLAUDE.md)
-("Commands & QA gates").
+Both Go modules share the same test/lint workflow, documented in
+[core/CLAUDE.md](core/CLAUDE.md) ("Commands & QA gates"); datagen carries its
+own copy of core's golangci-lint profile. Its `pgdemo` and `mariademo` suites
+start throwaway Postgres and MariaDB containers, so docker is a unit-test
+dependency for those two packages the same way it is for core's `internal/db`
+and `internal/datasource/processor`.
Go dependencies are fetched into the module cache at build time (`make deps` runs `go mod download`) — there is no vendoring. All dependencies, including the first-party `github.com/oxynote/wetsocks`, are public; no GOPRIVATE or git auth setup is needed.
@@ -71,6 +78,16 @@ So from a frontend's point of view: auth + realtime is `:8080/auth-realtime/...`
Most public routes in the README (`/api/documents`, `/api/documents/tree`, etc.) are served by core; the README is the closest thing to a contract spec — when changing handlers, update it.
+## The demo data source
+
+Every organization is seeded at initialization with a Prometheus data source named "Demo" whose URL is `demo://engineering`, plus the welcome document whose charts query it. This is unconditional — there is no env var and no way to opt out, so a fresh install shows populated charts with nothing else deployed.
+
+- `internal/datasource/demo` synthesises those metrics in-process. `runner.ensurePrepared` picks it over the real client when the URL is exactly `demo://engineering`; any other `demo://` URL is `demo.ErrUnknownSource` rather than a second demo. The data source's type stays `prometheus`, so the editor, autocomplete and charts cannot tell the difference.
+- **One `demo.Client` serves the whole process, and it lives on `datasource.Manager`.** The client owns its engine, parser and registry, and the registry's walks cache the history they replay. A runner is built per call, so building the client there instead would throw that cache away on every request and re-walk every tick since the epoch.
+- **The timeline is a pure function of the tick, not stored data.** A fixed epoch and a one-minute tick, values computed on demand from a per-series seed — so history never rewrites, a query over any window backfills for free, and the same window always answers identically. `walk.checkpoint` caches one value per day-long segment; without it a sample would replay every tick since the epoch.
+- Queries run through the official `promql` engine over `queryable`, a `storage.Queryable` that synthesises only the requested window. Samples are spaced at the query's own step and the lookback delta is widened to match, which is what lets a year-long range answer with ~100 points per series instead of half a million.
+- `registry.go` is the single declaration of what exists: ~15 gauge families driving both sample synthesis and the metadata/label/series endpoints. `Test_welcomeDocumentQueries` runs every query in `internal/document/files/available_metrics.json` against it, so a metric renamed in one place and not the other fails the build rather than emptying the welcome document silently.
+
## Document storage / Hocuspocus integration
This is the model that's least obvious from a fresh read:
diff --git a/server/core/.golangci.yml b/server/core/.golangci.yml
index 4181a1db..b0b2bcdb 100644
--- a/server/core/.golangci.yml
+++ b/server/core/.golangci.yml
@@ -255,6 +255,21 @@ linters:
- linters:
- gochecknoglobals
text: ^_.+
+ # the demo registry is tuned sampling parameters read as a block: every
+ # bound, target and spike chance is one row of demo tuning, not a
+ # threshold logic branches on. Naming each would bury the table, and a
+ # shared constant would tie together gauges that only coincide today.
+ - linters:
+ - goconst
+ - mnd
+ path: internal/datasource/demo
+ # the demo timeline promises bit-identical replay, so its tests compare
+ # the floats it produces exactly. A tolerance would pass on precisely
+ # the drift the assertions exist to catch.
+ - linters:
+ - testifylint
+ path: internal/datasource/demo
+ text: float-compare
- linters:
- forcetypeassert
- gochecknoinits
diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go
index 6f4d10d6..de5d7b74 100644
--- a/server/core/cmd/core/main.go
+++ b/server/core/cmd/core/main.go
@@ -289,10 +289,9 @@ func main() { //nolint:maintidx // main performs linear wiring of all components
srv, err := server.NewServer(
log,
server.Options{
- PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"),
- DemoPrometheusURL: buildinfo.Getenv("SERVER_DEMO_PROMETHEUS_URL"),
- Port: _serverPort,
- Origins: origins,
+ PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"),
+ Port: _serverPort,
+ Origins: origins,
Assistant: server.AssistantCapability{
Status: assistantStatus,
Model: assistantOpts.Model,
diff --git a/server/core/go.mod b/server/core/go.mod
index 39d7213b..9ea6386a 100644
--- a/server/core/go.mod
+++ b/server/core/go.mod
@@ -6,7 +6,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Masterminds/squirrel v1.5.4
github.com/blang/semver/v4 v4.0.0
- github.com/bradleyfalzon/ghinstallation/v2 v2.18.0
+ github.com/bradleyfalzon/ghinstallation/v2 v2.19.0
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cloudwego/eino v0.9.15
github.com/cloudwego/eino-ext/components/model/claude v0.1.25
@@ -17,70 +17,72 @@ require (
github.com/coder/websocket v1.8.15
github.com/dchest/uniuri v1.2.0
github.com/eino-contrib/jsonschema v1.0.3
- github.com/getsentry/sentry-go v0.46.2
- github.com/go-chi/chi/v5 v5.3.0
+ github.com/getsentry/sentry-go v0.48.0
+ github.com/go-chi/chi/v5 v5.3.2
github.com/go-chi/cors v1.2.2
github.com/go-sql-driver/mysql v1.10.0
github.com/gomodule/redigo v1.9.3
github.com/google/go-cmp v0.7.0
- github.com/google/go-containerregistry v0.21.6
- github.com/google/go-github/v72 v72.0.0
+ github.com/google/go-containerregistry v0.22.0
+ github.com/google/go-github/v88 v88.0.0
github.com/gorilla/schema v1.4.1
github.com/gosimple/slug v1.15.0
github.com/guregu/null/v5 v5.0.0
github.com/hashicorp/go-multierror v1.1.1
- github.com/jackc/pgx/v5 v5.9.2
+ github.com/jackc/pgx/v5 v5.10.0
github.com/jarcoal/httpmock v1.4.2
- github.com/jellydator/ttlcache/v3 v3.4.0
+ github.com/jellydator/ttlcache/v3 v3.4.1
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4
github.com/jmoiron/sqlx v1.4.0
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0
- github.com/meilisearch/meilisearch-go v0.36.2
- github.com/minio/minio-go/v7 v7.1.0
+ github.com/meilisearch/meilisearch-go v0.36.3
+ github.com/minio/minio-go/v7 v7.3.0
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/orlangure/gnomock v0.32.0
github.com/oxynote/wetsocks v0.1.0
- github.com/prometheus/client_golang v1.23.2
+ github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2
- github.com/prometheus/common v0.67.5
+ github.com/prometheus/common v0.70.1
+ github.com/prometheus/prometheus v0.314.0
github.com/qustavo/sqlhooks/v2 v2.1.0
github.com/rafaeljusto/redigomock v2.4.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/rs/xid v1.6.0
github.com/rubenv/sql-migrate v1.8.1
github.com/shopspring/decimal v1.4.0
- github.com/slack-go/slack v0.23.1
- github.com/stretchr/testify v1.11.1
+ github.com/slack-go/slack v0.29.0
+ github.com/stretchr/testify v1.12.1
github.com/tidwall/gjson v1.19.0
github.com/wneessen/go-mail v0.8.1
go.uber.org/goleak v1.3.0
- google.golang.org/genai v1.36.0
+ google.golang.org/genai v1.69.0
)
require (
cloud.google.com/go v0.116.0 // indirect
- cloud.google.com/go/auth v0.9.3 // indirect
- cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
- cloud.google.com/go/compute/metadata v0.7.0 // indirect
+ cloud.google.com/go/auth v0.20.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/anthropics/anthropic-sdk-go v1.56.0 // indirect
- github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect
- github.com/aws/smithy-go v1.22.2 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.31 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.30 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
+ github.com/aws/smithy-go v1.27.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
@@ -94,32 +96,35 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/dennwc/varint v1.0.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/cli v29.5.2+incompatible // indirect
+ github.com/docker/cli v29.7.2+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.7 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd // indirect
github.com/eino-contrib/ollama v0.1.0 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
+ github.com/felixge/httpsnoop v1.1.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
- github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/google/go-github/v84 v84.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
- github.com/google/s2a-go v0.1.8 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect
+ github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
+ github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -127,8 +132,8 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.18.6 // indirect
- github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/klauspost/compress v1.19.2 // indirect
+ github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
@@ -139,18 +144,17 @@ require (
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -162,36 +166,46 @@ require (
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
- go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
- go.opentelemetry.io/otel v1.41.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
- go.opentelemetry.io/otel/metric v1.41.0 // indirect
- go.opentelemetry.io/otel/sdk v1.37.0 // indirect
- go.opentelemetry.io/otel/trace v1.41.0 // indirect
- go.opentelemetry.io/proto/otlp v1.7.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
+ go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
- go.yaml.in/yaml/v3 v3.0.4 // indirect
- go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
golang.org/x/arch v0.12.0 // indirect
- golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
- golang.org/x/net v0.56.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
- golang.org/x/text v0.40.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
- google.golang.org/api v0.197.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
- google.golang.org/grpc v1.74.2 // indirect
- google.golang.org/protobuf v1.36.11 // indirect
+ google.golang.org/api v0.290.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc // indirect
+ google.golang.org/grpc v1.82.1 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/apimachinery v0.35.3 // indirect
+ k8s.io/client-go v0.35.3 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
+ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
)
diff --git a/server/core/go.sum b/server/core/go.sum
index 18a7ba8c..c039824b 100644
--- a/server/core/go.sum
+++ b/server/core/go.sum
@@ -1,18 +1,24 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
-cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
-cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
-cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
-cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
-cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@@ -20,40 +26,46 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
+github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
+github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc=
github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
-github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
-github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
+github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
+github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
-github.com/aws/aws-sdk-go-v2/config v1.29.15 h1:I5XjesVMpDZXZEZonVfjI12VNMrYa38LtLnw4NtY5Ss=
-github.com/aws/aws-sdk-go-v2/config v1.29.15/go.mod h1:tNIp4JIPonlsgaO5hxO372a6gjhN63aSWl2GVl5QoBQ=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.68 h1:cFb9yjI02/sWHBSYXAtkamjzCuRymvmeFmt0TC0MbYY=
-github.com/aws/aws-sdk-go-v2/credentials v1.17.68/go.mod h1:H6E+jBzyqUu8u0vGaU6POkK3P0NylYEeRZ6ynBpMqIk=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
-github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
-github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
-github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 h1:oIaQ1e17CSKaWmUTu62MtraRWVIosn/iONMuZt0gbqc=
-github.com/aws/aws-sdk-go-v2/service/sts v1.33.20/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
-github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
-github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk=
+github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
+github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
+github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
+github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
+github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
+github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
+github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
+github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
@@ -62,8 +74,8 @@ github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2y
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 h1:WPqnN6NS9XvYlOgZQAIseN7Z1uAiE+UxgDKlW7FvFuU=
-github.com/bradleyfalzon/ghinstallation/v2 v2.18.0/go.mod h1:gpoSwwWc4biE49F7n+roCcpkEkZ1Qr9soZ2ESvMiouU=
+github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI=
+github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
github.com/buger/jsonparser v1.2.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
@@ -78,11 +90,11 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.9.15 h1:F+7uXeZYbJm30a/kaC93Mj6H9K2zx4thQaQP695NggE=
@@ -99,7 +111,8 @@ github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10 h1:i5YdCW8omvO
github.com/cloudwego/eino-ext/components/model/openrouter v0.1.10/go.mod h1:WRc1OIXVaf2USal59xrIt+05TUCu9P1c8MQh0FcWG8c=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -114,12 +127,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
+github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE=
+github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
-github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
-github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
+github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.7 h1:jaPIxEIDz5bQeghNAdzz0ETwMMnM4vzjZlxz3pWP4JA=
@@ -130,39 +145,74 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd h1:I4PrRZuNMeDP3VbFrak4QsqwO5tWkQf0tqrrr1L2DsU=
+github.com/edsrzf/mmap-go v1.2.1-0.20241212181136-fad1cd13edbd/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4=
github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
+github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
-github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns=
-github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
+github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
+github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
-github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
-github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
-github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
-github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0=
+github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
+github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
+github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
+github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
+github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
+github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
+github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
+github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE=
+github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc=
+github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
+github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
+github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
+github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
+github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
+github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
+github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
+github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
+github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
+github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
+github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
+github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
+github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
+github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
@@ -174,65 +224,51 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM=
-github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo=
-github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM=
-github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg=
-github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA=
-github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ=
+github.com/google/go-containerregistry v0.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I=
+github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
+github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M=
+github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
+github.com/googleapis/enterprise-certificate-proxy v0.3.18 h1:hvVi34VucdrV1IIsiWuqYM8kutw/92MxNEFxCJZEh0k=
+github.com/googleapis/enterprise-certificate-proxy v0.3.18/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
+github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
+github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
+github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM=
+github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/guregu/null/v5 v5.0.0 h1:PRxjqyOekS11W+w/7Vfz6jgJE/BCwELWtgvOJzddimw=
github.com/guregu/null/v5 v5.0.0/go.mod h1:SjupzNy+sCPtwQTKWhUCqjhVCO69hpsl2QsZrWHjlwU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -247,14 +283,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
-github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
-github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
-github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
+github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
+github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4 h1:8Pm5dsAGaQ+uuGyB705/7epGUStVLe7DFROYdFTq5aA=
github.com/jellydator/xync v0.0.0-20240601154136-0f038e166df4/go.mod h1:cf3z0/QbPKWqY/8YJHkvjiJQx1sjVaJfueetY6YHJJk=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -268,11 +304,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
-github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
-github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
+github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -294,10 +330,10 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
+github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
@@ -305,16 +341,16 @@ github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX
github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
-github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU=
-github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
+github.com/meilisearch/meilisearch-go v0.36.3 h1:Yx1aTY5jDgtbStPVkhJTDoLnZTy5sejQSPyjfNMy6e4=
+github.com/meilisearch/meilisearch-go v0.36.3/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
-github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
-github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
+github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU=
+github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -328,8 +364,9 @@ github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -338,6 +375,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
+github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
+github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -352,29 +391,40 @@ github.com/oxynote/wetsocks v0.1.0 h1:amd/vv58vNVXgQsR3QFK/HGHHXs6kFu8MBqn1Sp/35
github.com/oxynote/wetsocks v0.1.0/go.mod h1:KlgjMvRx1pxooHqEvghbrXPIW1oc7/x03B7uiyMnEAM=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
+github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
-github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
-github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_golang/exp v0.0.0-20260724065723-ecdb8254ba61 h1:SgKx/5u9SwqzZ27E1T4bfuisjTOkI3GagC6WtdEE5lg=
+github.com/prometheus/client_golang/exp v0.0.0-20260724065723-ecdb8254ba61/go.mod h1:CoLfLGxCH1vzpdmZ+p2uaUGH43j+99HYmnK1Wak6rS4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
-github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
-github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
-github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
+github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
+github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
+github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/prometheus/prometheus v0.314.0 h1:YjsimqsIi6/mOtzZcrPEYUALO6zpfaht9O5sXqDz2vg=
+github.com/prometheus/prometheus v0.314.0/go.mod h1:zjg3pMTAkY0/JG8jy/h8/YgSQUVB+aCXMhUqN6l64jg=
+github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuXs=
+github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU=
github.com/qustavo/sqlhooks/v2 v2.1.0 h1:54yBemHnGHp/7xgT+pxwmIlMSDNYKx5JW5dfRAiCZi0=
github.com/qustavo/sqlhooks/v2 v2.1.0/go.mod h1:aMREyKo7fOKTwiLuWPsaHRXEmtqG4yREztO0idF83AU=
github.com/rafaeljusto/redigomock v2.4.0+incompatible h1:d7uo5MVINMxnRr20MxbgDkmZ8QRfevjOVgEa4n0OZyY=
@@ -397,14 +447,16 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
-github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
-github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
+github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI=
+github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -412,18 +464,19 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
@@ -445,6 +498,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
@@ -455,137 +510,101 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
-go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
-go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
-go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
-go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
-go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
-go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
-go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
-go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
-go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
-go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
+go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
+golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
-golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
-google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM=
-google.golang.org/genai v1.36.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
-google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A=
+google.golang.org/api v0.290.0/go.mod h1:weJZ3lldHFYI0DBFNKpJelUDNnusTt5YaOEgxvt8ci8=
+google.golang.org/genai v1.69.0 h1:quP3Rbiz0Mn+zPfXsWHQQwOx8IfO2MnQehZUbrJ/jPo=
+google.golang.org/genai v1.69.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
+google.golang.org/genproto/googleapis/api v0.0.0-20260724162435-b2f20204f0df h1:NsJx+hCSwIBI6+C4BuJIkb8xOG1M+nfQDsqIrQHT92k=
+google.golang.org/genproto/googleapis/api v0.0.0-20260724162435-b2f20204f0df/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc h1:3TtNq/QbJNrSY1nVdjcikfBw6ujnaNbdrd88wNr1OW4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
+google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
+gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -596,5 +615,23 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
+k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
+k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
+k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
+k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
+k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/server/core/internal/_mock/meilisearch_index_manager.go b/server/core/internal/_mock/meilisearch_index_manager.go
index 3c79ef2b..dd0cd48d 100644
--- a/server/core/internal/_mock/meilisearch_index_manager.go
+++ b/server/core/internal/_mock/meilisearch_index_manager.go
@@ -275,10 +275,10 @@ var _ meilisearch.IndexManager = &MeiliIndexManager{}
// GetSortableAttributesWithContextFunc: func(ctx context.Context) (*[]string, error) {
// panic("mock out the GetSortableAttributesWithContext method")
// },
-// GetStatsFunc: func() (*meilisearch.StatsIndex, error) {
+// GetStatsFunc: func(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) {
// panic("mock out the GetStats method")
// },
-// GetStatsWithContextFunc: func(ctx context.Context) (*meilisearch.StatsIndex, error) {
+// GetStatsWithContextFunc: func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) {
// panic("mock out the GetStatsWithContext method")
// },
// GetStopWordsFunc: func() (*[]string, error) {
@@ -898,10 +898,10 @@ type MeiliIndexManager struct {
GetSortableAttributesWithContextFunc func(ctx context.Context) (*[]string, error)
// GetStatsFunc mocks the GetStats method.
- GetStatsFunc func() (*meilisearch.StatsIndex, error)
+ GetStatsFunc func(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error)
// GetStatsWithContextFunc mocks the GetStatsWithContext method.
- GetStatsWithContextFunc func(ctx context.Context) (*meilisearch.StatsIndex, error)
+ GetStatsWithContextFunc func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error)
// GetStopWordsFunc mocks the GetStopWords method.
GetStopWordsFunc func() (*[]string, error)
@@ -1750,11 +1750,15 @@ type MeiliIndexManager struct {
}
// GetStats holds details about calls to the GetStats method.
GetStats []struct {
+ // Param is the param argument value.
+ Param *meilisearch.StatsParams
}
// GetStatsWithContext holds details about calls to the GetStatsWithContext method.
GetStatsWithContext []struct {
// Ctx is the ctx argument value.
Ctx context.Context
+ // Param is the param argument value.
+ Param *meilisearch.StatsParams
}
// GetStopWords holds details about calls to the GetStopWords method.
GetStopWords []struct {
@@ -5751,9 +5755,12 @@ func (mock *MeiliIndexManager) GetSortableAttributesWithContextCalls() []struct
}
// GetStats calls GetStatsFunc.
-func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) {
+func (mock *MeiliIndexManager) GetStats(param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) {
callInfo := struct {
- }{}
+ Param *meilisearch.StatsParams
+ }{
+ Param: param,
+ }
mock.lockGetStats.Lock()
mock.calls.GetStats = append(mock.calls.GetStats, callInfo)
mock.lockGetStats.Unlock()
@@ -5764,7 +5771,7 @@ func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) {
)
return statsIndexOut, errOut
}
- return mock.GetStatsFunc()
+ return mock.GetStatsFunc(param)
}
// GetStatsCalls gets all the calls that were made to GetStats.
@@ -5772,8 +5779,10 @@ func (mock *MeiliIndexManager) GetStats() (*meilisearch.StatsIndex, error) {
//
// len(mockedIndexManager.GetStatsCalls())
func (mock *MeiliIndexManager) GetStatsCalls() []struct {
+ Param *meilisearch.StatsParams
} {
var calls []struct {
+ Param *meilisearch.StatsParams
}
mock.lockGetStats.RLock()
calls = mock.calls.GetStats
@@ -5782,11 +5791,13 @@ func (mock *MeiliIndexManager) GetStatsCalls() []struct {
}
// GetStatsWithContext calls GetStatsWithContextFunc.
-func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilisearch.StatsIndex, error) {
+func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.StatsIndex, error) {
callInfo := struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
}{
- Ctx: ctx,
+ Ctx: ctx,
+ Param: param,
}
mock.lockGetStatsWithContext.Lock()
mock.calls.GetStatsWithContext = append(mock.calls.GetStatsWithContext, callInfo)
@@ -5798,7 +5809,7 @@ func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilis
)
return statsIndexOut, errOut
}
- return mock.GetStatsWithContextFunc(ctx)
+ return mock.GetStatsWithContextFunc(ctx, param)
}
// GetStatsWithContextCalls gets all the calls that were made to GetStatsWithContext.
@@ -5806,10 +5817,12 @@ func (mock *MeiliIndexManager) GetStatsWithContext(ctx context.Context) (*meilis
//
// len(mockedIndexManager.GetStatsWithContextCalls())
func (mock *MeiliIndexManager) GetStatsWithContextCalls() []struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
} {
var calls []struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
}
mock.lockGetStatsWithContext.RLock()
calls = mock.calls.GetStatsWithContext
diff --git a/server/core/internal/_mock/meilisearch_service_manager.go b/server/core/internal/_mock/meilisearch_service_manager.go
index bc98900c..5f4326a5 100644
--- a/server/core/internal/_mock/meilisearch_service_manager.go
+++ b/server/core/internal/_mock/meilisearch_service_manager.go
@@ -84,6 +84,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{}
// DeleteKeyWithContextFunc: func(ctx context.Context, keyOrUID string) (bool, error) {
// panic("mock out the DeleteKeyWithContext method")
// },
+// DeleteSearchRuleFunc: func(uid string) error {
+// panic("mock out the DeleteSearchRule method")
+// },
+// DeleteSearchRuleWithContextFunc: func(ctx context.Context, uid string) error {
+// panic("mock out the DeleteSearchRuleWithContext method")
+// },
// DeleteTasksFunc: func(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error) {
// panic("mock out the DeleteTasks method")
// },
@@ -168,15 +174,27 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{}
// GetRawIndexesWithContextFunc: func(ctx context.Context, param *meilisearch.IndexesQuery) (map[string]interface{}, error) {
// panic("mock out the GetRawIndexesWithContext method")
// },
-// GetStatsFunc: func() (*meilisearch.Stats, error) {
+// GetSearchRuleFunc: func(uid string) (*meilisearch.SearchRule, error) {
+// panic("mock out the GetSearchRule method")
+// },
+// GetSearchRuleWithContextFunc: func(ctx context.Context, uid string) (*meilisearch.SearchRule, error) {
+// panic("mock out the GetSearchRuleWithContext method")
+// },
+// GetStatsFunc: func(param *meilisearch.StatsParams) (*meilisearch.Stats, error) {
// panic("mock out the GetStats method")
// },
-// GetStatsWithContextFunc: func(ctx context.Context) (*meilisearch.Stats, error) {
+// GetStatsWithContextFunc: func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error) {
// panic("mock out the GetStatsWithContext method")
// },
// GetTaskFunc: func(taskUID int64) (*meilisearch.Task, error) {
// panic("mock out the GetTask method")
// },
+// GetTaskDocumentsFunc: func(taskUID int64, dst interface{}) error {
+// panic("mock out the GetTaskDocuments method")
+// },
+// GetTaskDocumentsWithContextFunc: func(ctx context.Context, taskUID int64, dst interface{}) error {
+// panic("mock out the GetTaskDocumentsWithContext method")
+// },
// GetTaskWithContextFunc: func(ctx context.Context, taskUID int64) (*meilisearch.Task, error) {
// panic("mock out the GetTaskWithContext method")
// },
@@ -222,6 +240,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{}
// ListIndexesWithContextFunc: func(ctx context.Context, param *meilisearch.IndexesQuery) (*meilisearch.IndexesResults, error) {
// panic("mock out the ListIndexesWithContext method")
// },
+// ListSearchRulesFunc: func(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) {
+// panic("mock out the ListSearchRules method")
+// },
+// ListSearchRulesWithContextFunc: func(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) {
+// panic("mock out the ListSearchRulesWithContext method")
+// },
// ListWebhooksFunc: func() (*meilisearch.WebhookResults, error) {
// panic("mock out the ListWebhooks method")
// },
@@ -240,6 +264,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{}
// ResetChatWorkspaceWithContextFunc: func(ctx context.Context, uid string) (*meilisearch.ChatWorkspaceSettings, error) {
// panic("mock out the ResetChatWorkspaceWithContext method")
// },
+// SearchRulesManagerFunc: func() meilisearch.SearchRulesManager {
+// panic("mock out the SearchRulesManager method")
+// },
+// SearchRulesReaderFunc: func() meilisearch.SearchRulesReader {
+// panic("mock out the SearchRulesReader method")
+// },
// ServiceReaderFunc: func() meilisearch.ServiceReader {
// panic("mock out the ServiceReader method")
// },
@@ -273,6 +303,12 @@ var _ meilisearch.ServiceManager = &MeiliServiceManager{}
// UpdateNetworkWithContextFunc: func(ctx context.Context, params *meilisearch.UpdateNetworkRequest) (any, error) {
// panic("mock out the UpdateNetworkWithContext method")
// },
+// UpdateSearchRuleFunc: func(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) {
+// panic("mock out the UpdateSearchRule method")
+// },
+// UpdateSearchRuleWithContextFunc: func(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) {
+// panic("mock out the UpdateSearchRuleWithContext method")
+// },
// UpdateWebhookFunc: func(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error) {
// panic("mock out the UpdateWebhook method")
// },
@@ -367,6 +403,12 @@ type MeiliServiceManager struct {
// DeleteKeyWithContextFunc mocks the DeleteKeyWithContext method.
DeleteKeyWithContextFunc func(ctx context.Context, keyOrUID string) (bool, error)
+ // DeleteSearchRuleFunc mocks the DeleteSearchRule method.
+ DeleteSearchRuleFunc func(uid string) error
+
+ // DeleteSearchRuleWithContextFunc mocks the DeleteSearchRuleWithContext method.
+ DeleteSearchRuleWithContextFunc func(ctx context.Context, uid string) error
+
// DeleteTasksFunc mocks the DeleteTasks method.
DeleteTasksFunc func(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error)
@@ -451,15 +493,27 @@ type MeiliServiceManager struct {
// GetRawIndexesWithContextFunc mocks the GetRawIndexesWithContext method.
GetRawIndexesWithContextFunc func(ctx context.Context, param *meilisearch.IndexesQuery) (map[string]interface{}, error)
+ // GetSearchRuleFunc mocks the GetSearchRule method.
+ GetSearchRuleFunc func(uid string) (*meilisearch.SearchRule, error)
+
+ // GetSearchRuleWithContextFunc mocks the GetSearchRuleWithContext method.
+ GetSearchRuleWithContextFunc func(ctx context.Context, uid string) (*meilisearch.SearchRule, error)
+
// GetStatsFunc mocks the GetStats method.
- GetStatsFunc func() (*meilisearch.Stats, error)
+ GetStatsFunc func(param *meilisearch.StatsParams) (*meilisearch.Stats, error)
// GetStatsWithContextFunc mocks the GetStatsWithContext method.
- GetStatsWithContextFunc func(ctx context.Context) (*meilisearch.Stats, error)
+ GetStatsWithContextFunc func(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error)
// GetTaskFunc mocks the GetTask method.
GetTaskFunc func(taskUID int64) (*meilisearch.Task, error)
+ // GetTaskDocumentsFunc mocks the GetTaskDocuments method.
+ GetTaskDocumentsFunc func(taskUID int64, dst interface{}) error
+
+ // GetTaskDocumentsWithContextFunc mocks the GetTaskDocumentsWithContext method.
+ GetTaskDocumentsWithContextFunc func(ctx context.Context, taskUID int64, dst interface{}) error
+
// GetTaskWithContextFunc mocks the GetTaskWithContext method.
GetTaskWithContextFunc func(ctx context.Context, taskUID int64) (*meilisearch.Task, error)
@@ -505,6 +559,12 @@ type MeiliServiceManager struct {
// ListIndexesWithContextFunc mocks the ListIndexesWithContext method.
ListIndexesWithContextFunc func(ctx context.Context, param *meilisearch.IndexesQuery) (*meilisearch.IndexesResults, error)
+ // ListSearchRulesFunc mocks the ListSearchRules method.
+ ListSearchRulesFunc func(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error)
+
+ // ListSearchRulesWithContextFunc mocks the ListSearchRulesWithContext method.
+ ListSearchRulesWithContextFunc func(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error)
+
// ListWebhooksFunc mocks the ListWebhooks method.
ListWebhooksFunc func() (*meilisearch.WebhookResults, error)
@@ -523,6 +583,12 @@ type MeiliServiceManager struct {
// ResetChatWorkspaceWithContextFunc mocks the ResetChatWorkspaceWithContext method.
ResetChatWorkspaceWithContextFunc func(ctx context.Context, uid string) (*meilisearch.ChatWorkspaceSettings, error)
+ // SearchRulesManagerFunc mocks the SearchRulesManager method.
+ SearchRulesManagerFunc func() meilisearch.SearchRulesManager
+
+ // SearchRulesReaderFunc mocks the SearchRulesReader method.
+ SearchRulesReaderFunc func() meilisearch.SearchRulesReader
+
// ServiceReaderFunc mocks the ServiceReader method.
ServiceReaderFunc func() meilisearch.ServiceReader
@@ -556,6 +622,12 @@ type MeiliServiceManager struct {
// UpdateNetworkWithContextFunc mocks the UpdateNetworkWithContext method.
UpdateNetworkWithContextFunc func(ctx context.Context, params *meilisearch.UpdateNetworkRequest) (any, error)
+ // UpdateSearchRuleFunc mocks the UpdateSearchRule method.
+ UpdateSearchRuleFunc func(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error)
+
+ // UpdateSearchRuleWithContextFunc mocks the UpdateSearchRuleWithContext method.
+ UpdateSearchRuleWithContextFunc func(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error)
+
// UpdateWebhookFunc mocks the UpdateWebhook method.
UpdateWebhookFunc func(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error)
@@ -695,6 +767,18 @@ type MeiliServiceManager struct {
// KeyOrUID is the keyOrUID argument value.
KeyOrUID string
}
+ // DeleteSearchRule holds details about calls to the DeleteSearchRule method.
+ DeleteSearchRule []struct {
+ // UID is the uid argument value.
+ UID string
+ }
+ // DeleteSearchRuleWithContext holds details about calls to the DeleteSearchRuleWithContext method.
+ DeleteSearchRuleWithContext []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UID is the uid argument value.
+ UID string
+ }
// DeleteTasks holds details about calls to the DeleteTasks method.
DeleteTasks []struct {
// Param is the param argument value.
@@ -859,19 +943,51 @@ type MeiliServiceManager struct {
// Param is the param argument value.
Param *meilisearch.IndexesQuery
}
+ // GetSearchRule holds details about calls to the GetSearchRule method.
+ GetSearchRule []struct {
+ // UID is the uid argument value.
+ UID string
+ }
+ // GetSearchRuleWithContext holds details about calls to the GetSearchRuleWithContext method.
+ GetSearchRuleWithContext []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UID is the uid argument value.
+ UID string
+ }
// GetStats holds details about calls to the GetStats method.
GetStats []struct {
+ // Param is the param argument value.
+ Param *meilisearch.StatsParams
}
// GetStatsWithContext holds details about calls to the GetStatsWithContext method.
GetStatsWithContext []struct {
// Ctx is the ctx argument value.
Ctx context.Context
+ // Param is the param argument value.
+ Param *meilisearch.StatsParams
}
// GetTask holds details about calls to the GetTask method.
GetTask []struct {
// TaskUID is the taskUID argument value.
TaskUID int64
}
+ // GetTaskDocuments holds details about calls to the GetTaskDocuments method.
+ GetTaskDocuments []struct {
+ // TaskUID is the taskUID argument value.
+ TaskUID int64
+ // Dst is the dst argument value.
+ Dst interface{}
+ }
+ // GetTaskDocumentsWithContext holds details about calls to the GetTaskDocumentsWithContext method.
+ GetTaskDocumentsWithContext []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // TaskUID is the taskUID argument value.
+ TaskUID int64
+ // Dst is the dst argument value.
+ Dst interface{}
+ }
// GetTaskWithContext holds details about calls to the GetTaskWithContext method.
GetTaskWithContext []struct {
// Ctx is the ctx argument value.
@@ -949,6 +1065,18 @@ type MeiliServiceManager struct {
// Param is the param argument value.
Param *meilisearch.IndexesQuery
}
+ // ListSearchRules holds details about calls to the ListSearchRules method.
+ ListSearchRules []struct {
+ // Params is the params argument value.
+ Params *meilisearch.SearchRulesParams
+ }
+ // ListSearchRulesWithContext holds details about calls to the ListSearchRulesWithContext method.
+ ListSearchRulesWithContext []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // Params is the params argument value.
+ Params *meilisearch.SearchRulesParams
+ }
// ListWebhooks holds details about calls to the ListWebhooks method.
ListWebhooks []struct {
}
@@ -981,6 +1109,12 @@ type MeiliServiceManager struct {
// UID is the uid argument value.
UID string
}
+ // SearchRulesManager holds details about calls to the SearchRulesManager method.
+ SearchRulesManager []struct {
+ }
+ // SearchRulesReader holds details about calls to the SearchRulesReader method.
+ SearchRulesReader []struct {
+ }
// ServiceReader holds details about calls to the ServiceReader method.
ServiceReader []struct {
}
@@ -1046,6 +1180,22 @@ type MeiliServiceManager struct {
// Params is the params argument value.
Params *meilisearch.UpdateNetworkRequest
}
+ // UpdateSearchRule holds details about calls to the UpdateSearchRule method.
+ UpdateSearchRule []struct {
+ // UID is the uid argument value.
+ UID string
+ // Params is the params argument value.
+ Params *meilisearch.SearchRulesRequest
+ }
+ // UpdateSearchRuleWithContext holds details about calls to the UpdateSearchRuleWithContext method.
+ UpdateSearchRuleWithContext []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UID is the uid argument value.
+ UID string
+ // Params is the params argument value.
+ Params *meilisearch.SearchRulesRequest
+ }
// UpdateWebhook holds details about calls to the UpdateWebhook method.
UpdateWebhook []struct {
// UUID is the uuid argument value.
@@ -1114,6 +1264,8 @@ type MeiliServiceManager struct {
lockDeleteIndexWithContext sync.RWMutex
lockDeleteKey sync.RWMutex
lockDeleteKeyWithContext sync.RWMutex
+ lockDeleteSearchRule sync.RWMutex
+ lockDeleteSearchRuleWithContext sync.RWMutex
lockDeleteTasks sync.RWMutex
lockDeleteTasksWithContext sync.RWMutex
lockDeleteWebhook sync.RWMutex
@@ -1142,9 +1294,13 @@ type MeiliServiceManager struct {
lockGetRawIndexWithContext sync.RWMutex
lockGetRawIndexes sync.RWMutex
lockGetRawIndexesWithContext sync.RWMutex
+ lockGetSearchRule sync.RWMutex
+ lockGetSearchRuleWithContext sync.RWMutex
lockGetStats sync.RWMutex
lockGetStatsWithContext sync.RWMutex
lockGetTask sync.RWMutex
+ lockGetTaskDocuments sync.RWMutex
+ lockGetTaskDocumentsWithContext sync.RWMutex
lockGetTaskWithContext sync.RWMutex
lockGetTasks sync.RWMutex
lockGetTasksWithContext sync.RWMutex
@@ -1160,12 +1316,16 @@ type MeiliServiceManager struct {
lockListChatWorkspacesWithContext sync.RWMutex
lockListIndexes sync.RWMutex
lockListIndexesWithContext sync.RWMutex
+ lockListSearchRules sync.RWMutex
+ lockListSearchRulesWithContext sync.RWMutex
lockListWebhooks sync.RWMutex
lockListWebhooksWithContext sync.RWMutex
lockMultiSearch sync.RWMutex
lockMultiSearchWithContext sync.RWMutex
lockResetChatWorkspace sync.RWMutex
lockResetChatWorkspaceWithContext sync.RWMutex
+ lockSearchRulesManager sync.RWMutex
+ lockSearchRulesReader sync.RWMutex
lockServiceReader sync.RWMutex
lockSwapIndexes sync.RWMutex
lockSwapIndexesWithContext sync.RWMutex
@@ -1177,6 +1337,8 @@ type MeiliServiceManager struct {
lockUpdateKeyWithContext sync.RWMutex
lockUpdateNetwork sync.RWMutex
lockUpdateNetworkWithContext sync.RWMutex
+ lockUpdateSearchRule sync.RWMutex
+ lockUpdateSearchRuleWithContext sync.RWMutex
lockUpdateWebhook sync.RWMutex
lockUpdateWebhookWithContext sync.RWMutex
lockVersion sync.RWMutex
@@ -1948,6 +2110,80 @@ func (mock *MeiliServiceManager) DeleteKeyWithContextCalls() []struct {
return calls
}
+// DeleteSearchRule calls DeleteSearchRuleFunc.
+func (mock *MeiliServiceManager) DeleteSearchRule(uid string) error {
+ callInfo := struct {
+ UID string
+ }{
+ UID: uid,
+ }
+ mock.lockDeleteSearchRule.Lock()
+ mock.calls.DeleteSearchRule = append(mock.calls.DeleteSearchRule, callInfo)
+ mock.lockDeleteSearchRule.Unlock()
+ if mock.DeleteSearchRuleFunc == nil {
+ var (
+ errOut error
+ )
+ return errOut
+ }
+ return mock.DeleteSearchRuleFunc(uid)
+}
+
+// DeleteSearchRuleCalls gets all the calls that were made to DeleteSearchRule.
+// Check the length with:
+//
+// len(mockedServiceManager.DeleteSearchRuleCalls())
+func (mock *MeiliServiceManager) DeleteSearchRuleCalls() []struct {
+ UID string
+} {
+ var calls []struct {
+ UID string
+ }
+ mock.lockDeleteSearchRule.RLock()
+ calls = mock.calls.DeleteSearchRule
+ mock.lockDeleteSearchRule.RUnlock()
+ return calls
+}
+
+// DeleteSearchRuleWithContext calls DeleteSearchRuleWithContextFunc.
+func (mock *MeiliServiceManager) DeleteSearchRuleWithContext(ctx context.Context, uid string) error {
+ callInfo := struct {
+ Ctx context.Context
+ UID string
+ }{
+ Ctx: ctx,
+ UID: uid,
+ }
+ mock.lockDeleteSearchRuleWithContext.Lock()
+ mock.calls.DeleteSearchRuleWithContext = append(mock.calls.DeleteSearchRuleWithContext, callInfo)
+ mock.lockDeleteSearchRuleWithContext.Unlock()
+ if mock.DeleteSearchRuleWithContextFunc == nil {
+ var (
+ errOut error
+ )
+ return errOut
+ }
+ return mock.DeleteSearchRuleWithContextFunc(ctx, uid)
+}
+
+// DeleteSearchRuleWithContextCalls gets all the calls that were made to DeleteSearchRuleWithContext.
+// Check the length with:
+//
+// len(mockedServiceManager.DeleteSearchRuleWithContextCalls())
+func (mock *MeiliServiceManager) DeleteSearchRuleWithContextCalls() []struct {
+ Ctx context.Context
+ UID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UID string
+ }
+ mock.lockDeleteSearchRuleWithContext.RLock()
+ calls = mock.calls.DeleteSearchRuleWithContext
+ mock.lockDeleteSearchRuleWithContext.RUnlock()
+ return calls
+}
+
// DeleteTasks calls DeleteTasksFunc.
func (mock *MeiliServiceManager) DeleteTasks(param *meilisearch.DeleteTasksQuery) (*meilisearch.TaskInfo, error) {
callInfo := struct {
@@ -2999,10 +3235,89 @@ func (mock *MeiliServiceManager) GetRawIndexesWithContextCalls() []struct {
return calls
}
-// GetStats calls GetStatsFunc.
-func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) {
+// GetSearchRule calls GetSearchRuleFunc.
+func (mock *MeiliServiceManager) GetSearchRule(uid string) (*meilisearch.SearchRule, error) {
callInfo := struct {
- }{}
+ UID string
+ }{
+ UID: uid,
+ }
+ mock.lockGetSearchRule.Lock()
+ mock.calls.GetSearchRule = append(mock.calls.GetSearchRule, callInfo)
+ mock.lockGetSearchRule.Unlock()
+ if mock.GetSearchRuleFunc == nil {
+ var (
+ searchRuleOut *meilisearch.SearchRule
+ errOut error
+ )
+ return searchRuleOut, errOut
+ }
+ return mock.GetSearchRuleFunc(uid)
+}
+
+// GetSearchRuleCalls gets all the calls that were made to GetSearchRule.
+// Check the length with:
+//
+// len(mockedServiceManager.GetSearchRuleCalls())
+func (mock *MeiliServiceManager) GetSearchRuleCalls() []struct {
+ UID string
+} {
+ var calls []struct {
+ UID string
+ }
+ mock.lockGetSearchRule.RLock()
+ calls = mock.calls.GetSearchRule
+ mock.lockGetSearchRule.RUnlock()
+ return calls
+}
+
+// GetSearchRuleWithContext calls GetSearchRuleWithContextFunc.
+func (mock *MeiliServiceManager) GetSearchRuleWithContext(ctx context.Context, uid string) (*meilisearch.SearchRule, error) {
+ callInfo := struct {
+ Ctx context.Context
+ UID string
+ }{
+ Ctx: ctx,
+ UID: uid,
+ }
+ mock.lockGetSearchRuleWithContext.Lock()
+ mock.calls.GetSearchRuleWithContext = append(mock.calls.GetSearchRuleWithContext, callInfo)
+ mock.lockGetSearchRuleWithContext.Unlock()
+ if mock.GetSearchRuleWithContextFunc == nil {
+ var (
+ searchRuleOut *meilisearch.SearchRule
+ errOut error
+ )
+ return searchRuleOut, errOut
+ }
+ return mock.GetSearchRuleWithContextFunc(ctx, uid)
+}
+
+// GetSearchRuleWithContextCalls gets all the calls that were made to GetSearchRuleWithContext.
+// Check the length with:
+//
+// len(mockedServiceManager.GetSearchRuleWithContextCalls())
+func (mock *MeiliServiceManager) GetSearchRuleWithContextCalls() []struct {
+ Ctx context.Context
+ UID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UID string
+ }
+ mock.lockGetSearchRuleWithContext.RLock()
+ calls = mock.calls.GetSearchRuleWithContext
+ mock.lockGetSearchRuleWithContext.RUnlock()
+ return calls
+}
+
+// GetStats calls GetStatsFunc.
+func (mock *MeiliServiceManager) GetStats(param *meilisearch.StatsParams) (*meilisearch.Stats, error) {
+ callInfo := struct {
+ Param *meilisearch.StatsParams
+ }{
+ Param: param,
+ }
mock.lockGetStats.Lock()
mock.calls.GetStats = append(mock.calls.GetStats, callInfo)
mock.lockGetStats.Unlock()
@@ -3013,7 +3328,7 @@ func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) {
)
return statsOut, errOut
}
- return mock.GetStatsFunc()
+ return mock.GetStatsFunc(param)
}
// GetStatsCalls gets all the calls that were made to GetStats.
@@ -3021,8 +3336,10 @@ func (mock *MeiliServiceManager) GetStats() (*meilisearch.Stats, error) {
//
// len(mockedServiceManager.GetStatsCalls())
func (mock *MeiliServiceManager) GetStatsCalls() []struct {
+ Param *meilisearch.StatsParams
} {
var calls []struct {
+ Param *meilisearch.StatsParams
}
mock.lockGetStats.RLock()
calls = mock.calls.GetStats
@@ -3031,11 +3348,13 @@ func (mock *MeiliServiceManager) GetStatsCalls() []struct {
}
// GetStatsWithContext calls GetStatsWithContextFunc.
-func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meilisearch.Stats, error) {
+func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context, param *meilisearch.StatsParams) (*meilisearch.Stats, error) {
callInfo := struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
}{
- Ctx: ctx,
+ Ctx: ctx,
+ Param: param,
}
mock.lockGetStatsWithContext.Lock()
mock.calls.GetStatsWithContext = append(mock.calls.GetStatsWithContext, callInfo)
@@ -3047,7 +3366,7 @@ func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meil
)
return statsOut, errOut
}
- return mock.GetStatsWithContextFunc(ctx)
+ return mock.GetStatsWithContextFunc(ctx, param)
}
// GetStatsWithContextCalls gets all the calls that were made to GetStatsWithContext.
@@ -3055,10 +3374,12 @@ func (mock *MeiliServiceManager) GetStatsWithContext(ctx context.Context) (*meil
//
// len(mockedServiceManager.GetStatsWithContextCalls())
func (mock *MeiliServiceManager) GetStatsWithContextCalls() []struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
} {
var calls []struct {
- Ctx context.Context
+ Ctx context.Context
+ Param *meilisearch.StatsParams
}
mock.lockGetStatsWithContext.RLock()
calls = mock.calls.GetStatsWithContext
@@ -3102,6 +3423,88 @@ func (mock *MeiliServiceManager) GetTaskCalls() []struct {
return calls
}
+// GetTaskDocuments calls GetTaskDocumentsFunc.
+func (mock *MeiliServiceManager) GetTaskDocuments(taskUID int64, dst interface{}) error {
+ callInfo := struct {
+ TaskUID int64
+ Dst interface{}
+ }{
+ TaskUID: taskUID,
+ Dst: dst,
+ }
+ mock.lockGetTaskDocuments.Lock()
+ mock.calls.GetTaskDocuments = append(mock.calls.GetTaskDocuments, callInfo)
+ mock.lockGetTaskDocuments.Unlock()
+ if mock.GetTaskDocumentsFunc == nil {
+ var (
+ errOut error
+ )
+ return errOut
+ }
+ return mock.GetTaskDocumentsFunc(taskUID, dst)
+}
+
+// GetTaskDocumentsCalls gets all the calls that were made to GetTaskDocuments.
+// Check the length with:
+//
+// len(mockedServiceManager.GetTaskDocumentsCalls())
+func (mock *MeiliServiceManager) GetTaskDocumentsCalls() []struct {
+ TaskUID int64
+ Dst interface{}
+} {
+ var calls []struct {
+ TaskUID int64
+ Dst interface{}
+ }
+ mock.lockGetTaskDocuments.RLock()
+ calls = mock.calls.GetTaskDocuments
+ mock.lockGetTaskDocuments.RUnlock()
+ return calls
+}
+
+// GetTaskDocumentsWithContext calls GetTaskDocumentsWithContextFunc.
+func (mock *MeiliServiceManager) GetTaskDocumentsWithContext(ctx context.Context, taskUID int64, dst interface{}) error {
+ callInfo := struct {
+ Ctx context.Context
+ TaskUID int64
+ Dst interface{}
+ }{
+ Ctx: ctx,
+ TaskUID: taskUID,
+ Dst: dst,
+ }
+ mock.lockGetTaskDocumentsWithContext.Lock()
+ mock.calls.GetTaskDocumentsWithContext = append(mock.calls.GetTaskDocumentsWithContext, callInfo)
+ mock.lockGetTaskDocumentsWithContext.Unlock()
+ if mock.GetTaskDocumentsWithContextFunc == nil {
+ var (
+ errOut error
+ )
+ return errOut
+ }
+ return mock.GetTaskDocumentsWithContextFunc(ctx, taskUID, dst)
+}
+
+// GetTaskDocumentsWithContextCalls gets all the calls that were made to GetTaskDocumentsWithContext.
+// Check the length with:
+//
+// len(mockedServiceManager.GetTaskDocumentsWithContextCalls())
+func (mock *MeiliServiceManager) GetTaskDocumentsWithContextCalls() []struct {
+ Ctx context.Context
+ TaskUID int64
+ Dst interface{}
+} {
+ var calls []struct {
+ Ctx context.Context
+ TaskUID int64
+ Dst interface{}
+ }
+ mock.lockGetTaskDocumentsWithContext.RLock()
+ calls = mock.calls.GetTaskDocumentsWithContext
+ mock.lockGetTaskDocumentsWithContext.RUnlock()
+ return calls
+}
+
// GetTaskWithContext calls GetTaskWithContextFunc.
func (mock *MeiliServiceManager) GetTaskWithContext(ctx context.Context, taskUID int64) (*meilisearch.Task, error) {
callInfo := struct {
@@ -3638,6 +4041,82 @@ func (mock *MeiliServiceManager) ListIndexesWithContextCalls() []struct {
return calls
}
+// ListSearchRules calls ListSearchRulesFunc.
+func (mock *MeiliServiceManager) ListSearchRules(params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) {
+ callInfo := struct {
+ Params *meilisearch.SearchRulesParams
+ }{
+ Params: params,
+ }
+ mock.lockListSearchRules.Lock()
+ mock.calls.ListSearchRules = append(mock.calls.ListSearchRules, callInfo)
+ mock.lockListSearchRules.Unlock()
+ if mock.ListSearchRulesFunc == nil {
+ var (
+ searchRulesResultsOut *meilisearch.SearchRulesResults
+ errOut error
+ )
+ return searchRulesResultsOut, errOut
+ }
+ return mock.ListSearchRulesFunc(params)
+}
+
+// ListSearchRulesCalls gets all the calls that were made to ListSearchRules.
+// Check the length with:
+//
+// len(mockedServiceManager.ListSearchRulesCalls())
+func (mock *MeiliServiceManager) ListSearchRulesCalls() []struct {
+ Params *meilisearch.SearchRulesParams
+} {
+ var calls []struct {
+ Params *meilisearch.SearchRulesParams
+ }
+ mock.lockListSearchRules.RLock()
+ calls = mock.calls.ListSearchRules
+ mock.lockListSearchRules.RUnlock()
+ return calls
+}
+
+// ListSearchRulesWithContext calls ListSearchRulesWithContextFunc.
+func (mock *MeiliServiceManager) ListSearchRulesWithContext(ctx context.Context, params *meilisearch.SearchRulesParams) (*meilisearch.SearchRulesResults, error) {
+ callInfo := struct {
+ Ctx context.Context
+ Params *meilisearch.SearchRulesParams
+ }{
+ Ctx: ctx,
+ Params: params,
+ }
+ mock.lockListSearchRulesWithContext.Lock()
+ mock.calls.ListSearchRulesWithContext = append(mock.calls.ListSearchRulesWithContext, callInfo)
+ mock.lockListSearchRulesWithContext.Unlock()
+ if mock.ListSearchRulesWithContextFunc == nil {
+ var (
+ searchRulesResultsOut *meilisearch.SearchRulesResults
+ errOut error
+ )
+ return searchRulesResultsOut, errOut
+ }
+ return mock.ListSearchRulesWithContextFunc(ctx, params)
+}
+
+// ListSearchRulesWithContextCalls gets all the calls that were made to ListSearchRulesWithContext.
+// Check the length with:
+//
+// len(mockedServiceManager.ListSearchRulesWithContextCalls())
+func (mock *MeiliServiceManager) ListSearchRulesWithContextCalls() []struct {
+ Ctx context.Context
+ Params *meilisearch.SearchRulesParams
+} {
+ var calls []struct {
+ Ctx context.Context
+ Params *meilisearch.SearchRulesParams
+ }
+ mock.lockListSearchRulesWithContext.RLock()
+ calls = mock.calls.ListSearchRulesWithContext
+ mock.lockListSearchRulesWithContext.RUnlock()
+ return calls
+}
+
// ListWebhooks calls ListWebhooksFunc.
func (mock *MeiliServiceManager) ListWebhooks() (*meilisearch.WebhookResults, error) {
callInfo := struct {
@@ -3857,6 +4336,66 @@ func (mock *MeiliServiceManager) ResetChatWorkspaceWithContextCalls() []struct {
return calls
}
+// SearchRulesManager calls SearchRulesManagerFunc.
+func (mock *MeiliServiceManager) SearchRulesManager() meilisearch.SearchRulesManager {
+ callInfo := struct {
+ }{}
+ mock.lockSearchRulesManager.Lock()
+ mock.calls.SearchRulesManager = append(mock.calls.SearchRulesManager, callInfo)
+ mock.lockSearchRulesManager.Unlock()
+ if mock.SearchRulesManagerFunc == nil {
+ var (
+ searchRulesManagerOut meilisearch.SearchRulesManager
+ )
+ return searchRulesManagerOut
+ }
+ return mock.SearchRulesManagerFunc()
+}
+
+// SearchRulesManagerCalls gets all the calls that were made to SearchRulesManager.
+// Check the length with:
+//
+// len(mockedServiceManager.SearchRulesManagerCalls())
+func (mock *MeiliServiceManager) SearchRulesManagerCalls() []struct {
+} {
+ var calls []struct {
+ }
+ mock.lockSearchRulesManager.RLock()
+ calls = mock.calls.SearchRulesManager
+ mock.lockSearchRulesManager.RUnlock()
+ return calls
+}
+
+// SearchRulesReader calls SearchRulesReaderFunc.
+func (mock *MeiliServiceManager) SearchRulesReader() meilisearch.SearchRulesReader {
+ callInfo := struct {
+ }{}
+ mock.lockSearchRulesReader.Lock()
+ mock.calls.SearchRulesReader = append(mock.calls.SearchRulesReader, callInfo)
+ mock.lockSearchRulesReader.Unlock()
+ if mock.SearchRulesReaderFunc == nil {
+ var (
+ searchRulesReaderOut meilisearch.SearchRulesReader
+ )
+ return searchRulesReaderOut
+ }
+ return mock.SearchRulesReaderFunc()
+}
+
+// SearchRulesReaderCalls gets all the calls that were made to SearchRulesReader.
+// Check the length with:
+//
+// len(mockedServiceManager.SearchRulesReaderCalls())
+func (mock *MeiliServiceManager) SearchRulesReaderCalls() []struct {
+} {
+ var calls []struct {
+ }
+ mock.lockSearchRulesReader.RLock()
+ calls = mock.calls.SearchRulesReader
+ mock.lockSearchRulesReader.RUnlock()
+ return calls
+}
+
// ServiceReader calls ServiceReaderFunc.
func (mock *MeiliServiceManager) ServiceReader() meilisearch.ServiceReader {
callInfo := struct {
@@ -4267,6 +4806,90 @@ func (mock *MeiliServiceManager) UpdateNetworkWithContextCalls() []struct {
return calls
}
+// UpdateSearchRule calls UpdateSearchRuleFunc.
+func (mock *MeiliServiceManager) UpdateSearchRule(uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) {
+ callInfo := struct {
+ UID string
+ Params *meilisearch.SearchRulesRequest
+ }{
+ UID: uid,
+ Params: params,
+ }
+ mock.lockUpdateSearchRule.Lock()
+ mock.calls.UpdateSearchRule = append(mock.calls.UpdateSearchRule, callInfo)
+ mock.lockUpdateSearchRule.Unlock()
+ if mock.UpdateSearchRuleFunc == nil {
+ var (
+ searchRuleOut *meilisearch.SearchRule
+ errOut error
+ )
+ return searchRuleOut, errOut
+ }
+ return mock.UpdateSearchRuleFunc(uid, params)
+}
+
+// UpdateSearchRuleCalls gets all the calls that were made to UpdateSearchRule.
+// Check the length with:
+//
+// len(mockedServiceManager.UpdateSearchRuleCalls())
+func (mock *MeiliServiceManager) UpdateSearchRuleCalls() []struct {
+ UID string
+ Params *meilisearch.SearchRulesRequest
+} {
+ var calls []struct {
+ UID string
+ Params *meilisearch.SearchRulesRequest
+ }
+ mock.lockUpdateSearchRule.RLock()
+ calls = mock.calls.UpdateSearchRule
+ mock.lockUpdateSearchRule.RUnlock()
+ return calls
+}
+
+// UpdateSearchRuleWithContext calls UpdateSearchRuleWithContextFunc.
+func (mock *MeiliServiceManager) UpdateSearchRuleWithContext(ctx context.Context, uid string, params *meilisearch.SearchRulesRequest) (*meilisearch.SearchRule, error) {
+ callInfo := struct {
+ Ctx context.Context
+ UID string
+ Params *meilisearch.SearchRulesRequest
+ }{
+ Ctx: ctx,
+ UID: uid,
+ Params: params,
+ }
+ mock.lockUpdateSearchRuleWithContext.Lock()
+ mock.calls.UpdateSearchRuleWithContext = append(mock.calls.UpdateSearchRuleWithContext, callInfo)
+ mock.lockUpdateSearchRuleWithContext.Unlock()
+ if mock.UpdateSearchRuleWithContextFunc == nil {
+ var (
+ searchRuleOut *meilisearch.SearchRule
+ errOut error
+ )
+ return searchRuleOut, errOut
+ }
+ return mock.UpdateSearchRuleWithContextFunc(ctx, uid, params)
+}
+
+// UpdateSearchRuleWithContextCalls gets all the calls that were made to UpdateSearchRuleWithContext.
+// Check the length with:
+//
+// len(mockedServiceManager.UpdateSearchRuleWithContextCalls())
+func (mock *MeiliServiceManager) UpdateSearchRuleWithContextCalls() []struct {
+ Ctx context.Context
+ UID string
+ Params *meilisearch.SearchRulesRequest
+} {
+ var calls []struct {
+ Ctx context.Context
+ UID string
+ Params *meilisearch.SearchRulesRequest
+ }
+ mock.lockUpdateSearchRuleWithContext.RLock()
+ calls = mock.calls.UpdateSearchRuleWithContext
+ mock.lockUpdateSearchRuleWithContext.RUnlock()
+ return calls
+}
+
// UpdateWebhook calls UpdateWebhookFunc.
func (mock *MeiliServiceManager) UpdateWebhook(uuid string, params *meilisearch.UpdateWebhookRequest) (*meilisearch.Webhook, error) {
callInfo := struct {
diff --git a/server/core/internal/apps/github/content.go b/server/core/internal/apps/github/content.go
index 9bf7a92e..a2610357 100644
--- a/server/core/internal/apps/github/content.go
+++ b/server/core/internal/apps/github/content.go
@@ -5,7 +5,7 @@ import (
"slices"
"strings"
- gogithub "github.com/google/go-github/v72/github"
+ gogithub "github.com/google/go-github/v88/github"
)
// TreeItemType represents the type of a tree item, either a file or a folder.
diff --git a/server/core/internal/apps/github/content_test.go b/server/core/internal/apps/github/content_test.go
index 4d5c498d..b9ebaef9 100644
--- a/server/core/internal/apps/github/content_test.go
+++ b/server/core/internal/apps/github/content_test.go
@@ -3,7 +3,7 @@ package github
import (
"testing"
- gogithub "github.com/google/go-github/v72/github"
+ gogithub "github.com/google/go-github/v88/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
diff --git a/server/core/internal/apps/github/manager.go b/server/core/internal/apps/github/manager.go
index e4d49b0f..bc5a9b0c 100644
--- a/server/core/internal/apps/github/manager.go
+++ b/server/core/internal/apps/github/manager.go
@@ -8,7 +8,7 @@ import (
"time"
"github.com/bradleyfalzon/ghinstallation/v2"
- gogithub "github.com/google/go-github/v72/github"
+ gogithub "github.com/google/go-github/v88/github"
"github.com/oxynote/oxynote/server/core/pkg/cryptoutil"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
)
@@ -193,11 +193,15 @@ func (m *Manager) createInstallationClient(installationID int64) (*gogithub.Clie
return nil, fmt.Errorf("failed to create installation transport: %w", err)
}
- return gogithub.NewClient(
- &http.Client{
- Transport: rt,
- },
- ), nil
+ client, err := gogithub.NewClient(gogithub.WithHTTPClient(&http.Client{
+ Transport: rt,
+ }))
+ if err != nil {
+ // NOCOV: WithHTTPClient only rejects a nil client.
+ return nil, fmt.Errorf("failed to create github client: %w", err)
+ }
+
+ return client, nil
}
// InstallationClient represents a GitHub App installation client.
@@ -476,11 +480,15 @@ func createAppClient(appID int64, privateKeyPath string) (*gogithub.Client, erro
return nil, fmt.Errorf("failed to create installation transport: %w", err)
}
- return gogithub.NewClient(
- &http.Client{
- Transport: rt,
- },
- ), nil
+ client, err := gogithub.NewClient(gogithub.WithHTTPClient(&http.Client{
+ Transport: rt,
+ }))
+ if err != nil {
+ // NOCOV: WithHTTPClient only rejects a nil client.
+ return nil, fmt.Errorf("failed to create github client: %w", err)
+ }
+
+ return client, nil
}
// parseGithubError parses errors returned by the Github API and maps them to appropriate HTTP status codes.
diff --git a/server/core/internal/apps/github/manager_test.go b/server/core/internal/apps/github/manager_test.go
index 9ad60465..963bc3ad 100644
--- a/server/core/internal/apps/github/manager_test.go
+++ b/server/core/internal/apps/github/manager_test.go
@@ -7,11 +7,10 @@ import (
"fmt"
"net/http"
"net/http/httptest"
- "net/url"
"strconv"
"testing"
- gogithub "github.com/google/go-github/v72/github"
+ gogithub "github.com/google/go-github/v88/github"
"github.com/oxynote/oxynote/server/core/pkg/cryptoutil"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
@@ -37,10 +36,17 @@ func pointManagerAt(t *testing.T, man *Manager, handler http.Handler) {
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
- base, err := url.Parse(srv.URL + "/")
+ base := srv.URL + "/"
+
+ // the base URL is fixed at construction, so the client is rebuilt
+ // around the original one's transport instead of being re-pointed.
+ client, err := gogithub.NewClient(
+ gogithub.WithHTTPClient(man.appClient.Client()),
+ gogithub.WithURLs(&base, &base),
+ )
require.NoError(t, err)
- man.appClient.BaseURL = base
+ man.appClient = client
}
// newTestInstallationClient creates an InstallationClient talking to a test
@@ -51,13 +57,14 @@ func newTestInstallationClient(t *testing.T, user bool, handler http.Handler) *I
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
- client := gogithub.NewClient(srv.Client())
+ base := srv.URL + "/"
- base, err := url.Parse(srv.URL + "/")
+ client, err := gogithub.NewClient(
+ gogithub.WithHTTPClient(srv.Client()),
+ gogithub.WithURLs(&base, &base),
+ )
require.NoError(t, err)
- client.BaseURL = base
-
return &InstallationClient{
user: user,
owner: "own",
diff --git a/server/core/internal/apps/slack/manager.go b/server/core/internal/apps/slack/manager.go
index 001354f6..11195a5c 100644
--- a/server/core/internal/apps/slack/manager.go
+++ b/server/core/internal/apps/slack/manager.go
@@ -218,9 +218,6 @@ func (m *Manager) ProcessNotification(ctx context.Context, n notification.Notifi
// VerifyMiddleware verifies the Slack request signature.
func (m *Manager) VerifyMiddleware(r *http.Request) error {
- // an empty SignatureSecret would make the verifier accept signatures
- // computed over an empty key, so refuse outright rather than trusting
- // the route middleware to be the only gate.
if !m.Configured() {
return ErrNotConfigured
}
diff --git a/server/core/internal/assistant/provider/gemini.go b/server/core/internal/assistant/provider/gemini.go
index 9d49f66c..19f93bba 100644
--- a/server/core/internal/assistant/provider/gemini.go
+++ b/server/core/internal/assistant/provider/gemini.go
@@ -18,6 +18,11 @@ func newGemini(ctx context.Context, opts Options) (model.ToolCallingChatModel, e
Backend: genai.BackendGeminiAPI,
HTTPOptions: genai.HTTPOptions{
Timeout: new(opts.timeout()),
+ // the SDK does not retry at all when this is nil; the empty
+ // struct enables its defaults (5 attempts, exponential
+ // backoff on 408/429/5xx), matching the retry behavior the
+ // other providers' SDKs ship out of the box.
+ RetryOptions: &genai.HTTPRetryOptions{},
},
}
@@ -36,6 +41,13 @@ func newGemini(ctx context.Context, opts Options) (model.ToolCallingChatModel, e
Model: opts.Model,
MaxTokens: opts.maxTokens(),
Temperature: opts.Temperature,
+ // eino maps thought parts to ReasoningContent, which the chat
+ // protocol renders as the transient thinking stream; gemini-3
+ // models additionally require thought parts to be restored on
+ // the way back to the API.
+ ThinkingConfig: &genai.ThinkingConfig{
+ IncludeThoughts: true,
+ },
}
cm, err := gemini.NewChatModel(ctx, cfg)
diff --git a/server/core/internal/assistant/provider/gemini_test.go b/server/core/internal/assistant/provider/gemini_test.go
index d4654e36..3167e0a8 100644
--- a/server/core/internal/assistant/provider/gemini_test.go
+++ b/server/core/internal/assistant/provider/gemini_test.go
@@ -32,10 +32,14 @@ func Test_newGemini(t *testing.T) {
APIKey: "k",
Backend: genai.BackendGeminiAPI,
HTTPOptions: genai.HTTPOptions{
- Timeout: new(_defaultRequestTimeout),
+ Timeout: new(_defaultRequestTimeout),
+ RetryOptions: &genai.HTTPRetryOptions{},
},
},
- Config: &gemini.Config{Model: "gemini-2.5-pro"},
+ Config: &gemini.Config{
+ Model: "gemini-2.5-pro",
+ ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true},
+ },
},
"Optional tuning": {
Opts: tunedOptions(ProviderGoogle, "gemini-2.5-pro"),
@@ -43,14 +47,16 @@ func Test_newGemini(t *testing.T) {
APIKey: "k",
Backend: genai.BackendGeminiAPI,
HTTPOptions: genai.HTTPOptions{
- BaseURL: "https://example.invalid/v1",
- Timeout: new(time.Minute),
+ BaseURL: "https://example.invalid/v1",
+ Timeout: new(time.Minute),
+ RetryOptions: &genai.HTTPRetryOptions{},
},
},
Config: &gemini.Config{
- Model: "gemini-2.5-pro",
- MaxTokens: new(1024),
- Temperature: new(float32(0.3)),
+ Model: "gemini-2.5-pro",
+ MaxTokens: new(1024),
+ Temperature: new(float32(0.3)),
+ ThinkingConfig: &genai.ThinkingConfig{IncludeThoughts: true},
},
},
}
diff --git a/server/core/internal/datasource/demo/client.go b/server/core/internal/datasource/demo/client.go
new file mode 100644
index 00000000..d838b9ee
--- /dev/null
+++ b/server/core/internal/datasource/demo/client.go
@@ -0,0 +1,261 @@
+package demo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/oxynote/oxynote/server/core/internal/datasource/processor"
+ v1 "github.com/prometheus/client_golang/api/prometheus/v1"
+ "github.com/prometheus/common/model"
+ "github.com/prometheus/prometheus/model/labels"
+ "github.com/prometheus/prometheus/promql"
+ "github.com/prometheus/prometheus/promql/parser"
+)
+
+// _queryTimeout bounds one evaluation, matching what the data source
+// processors give a real server.
+var _queryTimeout = 10 * time.Second
+
+// _maxSamples caps how many samples one evaluation may load. The demo's
+// own series are far below it, so it only ever stops a query whose
+// expansion — a subquery over a long window — would otherwise grow
+// without bound.
+const _maxSamples = 5_000_000
+
+// newEngine creates the engine demo queries evaluate on.
+func newEngine() *promql.Engine {
+ return promql.NewEngine(promql.EngineOpts{
+ MaxSamples: _maxSamples,
+ Timeout: _queryTimeout,
+ EnableAtModifier: true,
+ EnableNegativeOffset: true,
+ NoStepSubqueryIntervalFn: func(int64) int64 {
+ return _tickMillis
+ },
+ })
+}
+
+// Client answers Prometheus operations out of the demo registry.
+//
+// One client serves every query a process answers. The registry it holds
+// caches the history its walks replay, and a data source runner is built
+// per request, so a client built per request would throw that cache away
+// every time.
+type Client struct {
+ // engine evaluates the queries asked of the registry.
+ engine *promql.Engine
+
+ // parser reads the selectors the label and series endpoints are
+ // narrowed by.
+ parser parser.Parser
+
+ // registry is everything this client publishes.
+ registry registry
+}
+
+// NewClient creates a fresh instance of Client.
+func NewClient() *Client {
+ return &Client{
+ engine: newEngine(),
+ parser: parser.NewParser(parser.Options{}),
+ registry: newRegistry(),
+ }
+}
+
+// TestConnection tests the connection to the demo data source, which is
+// this process and so is always reachable.
+func (c *Client) TestConnection(context.Context) (processor.ConnectionStatus, error) {
+ return processor.ConnectionStatusSuccess, nil
+}
+
+// Metadata retrieves metadata about the demo data source.
+func (c *Client) Metadata(context.Context) (*processor.PrometheusMetadataResult, error) {
+ md := make(map[string][]v1.Metadata, len(c.registry))
+
+ for _, f := range c.registry {
+ md[f.name] = []v1.Metadata{
+ {
+ Type: v1.MetricTypeGauge,
+ Help: f.help,
+ },
+ }
+ }
+
+ return &processor.PrometheusMetadataResult{Result: md}, nil
+}
+
+// QueryRange performs a query against the demo data source over the
+// specified time range.
+func (c *Client) QueryRange(
+ ctx context.Context,
+ q string,
+ tr processor.TimeRange,
+) (*processor.PrometheusQueryResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, _queryTimeout)
+ defer cancel()
+
+ ntr := tr.Normalize()
+ step := ntr.QueryStep()
+
+ // the samples are spaced a step apart, so the lookback window has to
+ // span one to reach the sample behind every point of the grid.
+ query, err := c.engine.NewRangeQuery(
+ ctx,
+ queryable{registry: c.registry},
+ promql.NewPrometheusQueryOpts(false, step+_tick),
+ ntr.ProcessPrometheusQuery(q),
+ ntr.From,
+ ntr.To,
+ step,
+ )
+ if err != nil {
+ return nil, processor.NewInvalidQueryError(err.Error())
+ }
+
+ defer query.Close()
+
+ res := query.Exec(ctx)
+ if res.Err != nil {
+ // an evaluation that ran out of time or was abandoned is not a
+ // query the reader can fix, so it is not reported as one. What
+ // the engine rejects on its own terms — a query too expensive to
+ // run, a type it cannot evaluate — is.
+ if ctx.Err() != nil {
+ return nil, fmt.Errorf("evaluating demo query: %w", res.Err)
+ }
+
+ return nil, processor.NewInvalidQueryError(res.Err.Error())
+ }
+
+ matrix, ok := res.Value.(promql.Matrix)
+ if !ok {
+ // NOCOV: a range query only ever evaluates to a matrix.
+ return nil, errors.New("unexpected demo query result type")
+ }
+
+ warns, _ := res.Warnings.AsStrings(q, 0, 0)
+
+ return &processor.PrometheusQueryResult{
+ Type: model.ValMatrix,
+ Result: toModelMatrix(matrix),
+ Warnings: warns,
+ }, nil
+}
+
+// LabelNames retrieves the label names the demo's series carry, narrowed
+// to the ones the matchers select.
+func (c *Client) LabelNames(
+ _ context.Context,
+ matchers []string,
+ _ processor.TimeRange,
+) (*processor.PrometheusLabelNamesResult, error) {
+ sets, err := c.parseMatchers(matchers)
+ if err != nil {
+ return nil, err
+ }
+
+ return &processor.PrometheusLabelNamesResult{Result: c.registry.labelNames(sets)}, nil
+}
+
+// LabelValues retrieves the values the demo's series carry for the named
+// label, narrowed to the ones the matchers select.
+func (c *Client) LabelValues(
+ _ context.Context,
+ label string,
+ matchers []string,
+ _ processor.TimeRange,
+) (*processor.PrometheusLabelValuesResult, error) {
+ sets, err := c.parseMatchers(matchers)
+ if err != nil {
+ return nil, err
+ }
+
+ return &processor.PrometheusLabelValuesResult{Result: c.registry.labelValues(label, sets)}, nil
+}
+
+// Series retrieves the demo's series matching the given selectors.
+func (c *Client) Series(
+ _ context.Context,
+ matchers []string,
+ _ processor.TimeRange,
+) (*processor.PrometheusSeriesResult, error) {
+ sets, err := c.parseMatchers(matchers)
+ if err != nil {
+ return nil, err
+ }
+
+ var (
+ res []model.LabelSet
+ seen = map[string]struct{}{}
+ )
+
+ for _, set := range sets {
+ c.registry.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) {
+ key := lbls.String()
+
+ if _, ok := seen[key]; ok {
+ return
+ }
+
+ seen[key] = struct{}{}
+
+ res = append(res, toModelLabelSet(lbls))
+ })
+ }
+
+ return &processor.PrometheusSeriesResult{Result: res}, nil
+}
+
+// parseMatchers turns the API's series selectors into matcher sets. No
+// selector at all selects everything, which is what the endpoints answer
+// when the caller narrows nothing.
+func (c *Client) parseMatchers(mm []string) ([][]*labels.Matcher, error) {
+ if len(mm) == 0 {
+ return [][]*labels.Matcher{nil}, nil
+ }
+
+ sets, err := c.parser.ParseMetricSelectors(mm)
+ if err != nil {
+ return nil, processor.NewInvalidQueryError(err.Error())
+ }
+
+ return sets, nil
+}
+
+// toModelMatrix converts an evaluated matrix into the shape the real
+// Prometheus client returns, so a chart cannot tell the two apart.
+func toModelMatrix(m promql.Matrix) model.Matrix {
+ res := make(model.Matrix, 0, len(m))
+
+ for _, s := range m {
+ values := make([]model.SamplePair, 0, len(s.Floats))
+
+ for _, p := range s.Floats {
+ values = append(values, model.SamplePair{
+ Timestamp: model.Time(p.T),
+ Value: model.SampleValue(p.F),
+ })
+ }
+
+ res = append(res, &model.SampleStream{
+ Metric: model.Metric(toModelLabelSet(s.Metric)),
+ Values: values,
+ })
+ }
+
+ return res
+}
+
+// toModelLabelSet converts an evaluated label set into the shape the real
+// Prometheus client returns.
+func toModelLabelSet(lbls labels.Labels) model.LabelSet {
+ ls := make(model.LabelSet, lbls.Len())
+
+ lbls.Range(func(l labels.Label) {
+ ls[model.LabelName(l.Name)] = model.LabelValue(l.Value)
+ })
+
+ return ls
+}
diff --git a/server/core/internal/datasource/demo/client_test.go b/server/core/internal/datasource/demo/client_test.go
new file mode 100644
index 00000000..41422ac0
--- /dev/null
+++ b/server/core/internal/datasource/demo/client_test.go
@@ -0,0 +1,465 @@
+package demo
+
+import (
+ "context"
+ "encoding/json"
+ "math"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/oxynote/oxynote/server/core/internal/datasource/processor"
+ "github.com/oxynote/oxynote/server/core/pkg/testutil"
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
+ v1 "github.com/prometheus/client_golang/api/prometheus/v1"
+ "github.com/prometheus/common/model"
+ "github.com/prometheus/prometheus/model/labels"
+ "github.com/prometheus/prometheus/promql"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// _availableMetricsPath is the welcome document's pool of metric blocks,
+// whose queries this package has to be able to answer.
+const _availableMetricsPath = "../../document/files/available_metrics.json"
+
+// lastThreeHours returns the range a chart asks for by default.
+func lastThreeHours() processor.TimeRange {
+ now := timeutil.Now()
+
+ return processor.TimeRange{
+ From: now.Add(-3 * time.Hour),
+ To: now,
+ }
+}
+
+// resultMatrix returns the matrix a query result carries, failing the
+// test if it carries anything else.
+func resultMatrix(t *testing.T, res *processor.PrometheusQueryResult) model.Matrix {
+ t.Helper()
+
+ require.NotNil(t, res)
+ require.Equal(t, model.ValMatrix, res.Type)
+
+ m, ok := res.Result.(model.Matrix)
+ require.True(t, ok, "result is %T, not a matrix", res.Result)
+
+ return m
+}
+
+func Test_newEngine(t *testing.T) {
+ t.Parallel()
+
+ assert.NotNil(t, newEngine())
+}
+
+func Test_NewClient(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+ require.NotNil(t, c)
+
+ // the client owns what a query needs, so one client answers every
+ // query a process makes and the registry's replay cache survives
+ // between them.
+ assert.NotNil(t, c.engine)
+ assert.NotNil(t, c.parser)
+ assert.Len(t, c.registry, 15)
+}
+
+func Test_Client_TestConnection(t *testing.T) {
+ t.Parallel()
+
+ // the demo is this process, so the connection cannot be down.
+ cs, err := NewClient().TestConnection(context.Background())
+
+ require.NoError(t, err)
+ assert.Equal(t, processor.ConnectionStatusSuccess, cs)
+}
+
+func Test_Client_Metadata(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+
+ res, err := c.Metadata(context.Background())
+ require.NoError(t, err)
+ require.NotNil(t, res)
+
+ md, ok := res.Result.(map[string][]v1.Metadata)
+ require.True(t, ok)
+
+ // every family the registry declares is described, as a gauge, with
+ // the help text the editor shows.
+ assert.Len(t, md, 15)
+
+ for _, f := range c.registry {
+ entry, ok := md[f.name]
+ require.True(t, ok, "%s is missing from the metadata", f.name)
+ require.Len(t, entry, 1)
+
+ assert.Equal(t, v1.MetricTypeGauge, entry[0].Type)
+ assert.Equal(t, f.help, entry[0].Help)
+ }
+}
+
+func Test_Client_QueryRange(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+ name := _namespace + "deploy_confidence_index"
+
+ cc := map[string]struct {
+ Query string
+ Range processor.TimeRange
+ Context context.Context //nolint:containedctx // the case states which context the query runs under
+ Series int
+ Err error
+ }{
+ "An abandoned evaluation is not blamed on the query": {
+ Query: name,
+ Range: lastThreeHours(),
+ Context: func() context.Context {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ return ctx
+ }(),
+ Err: assert.AnError,
+ },
+ "A plain selector returns every series of the family": {
+ Query: name,
+ Range: lastThreeHours(),
+ Series: 3,
+ },
+ "A selector narrowed by a label returns the one series": {
+ Query: name + `{vibe="nervous"}`,
+ Range: lastThreeHours(),
+ Series: 1,
+ },
+ "An aggregation collapses the family": {
+ Query: "sum(" + name + ")",
+ Range: lastThreeHours(),
+ Series: 1,
+ },
+ "A function over a window reads the history behind each point": {
+ Query: "avg_over_time(" + name + "[30m])",
+ Range: lastThreeHours(),
+ Series: 3,
+ },
+ "A macro is expanded before the query runs": {
+ Query: "max_over_time(" + name + "[$__interval])",
+ Range: lastThreeHours(),
+ Series: 3,
+ },
+ "A year of history backfills for free": {
+ Query: name,
+ Range: processor.TimeRange{From: timeutil.Now().AddDate(-1, 0, 0), To: timeutil.Now()},
+ Series: 3,
+ },
+ "A selector naming nothing returns no series": {
+ Query: "not_a_metric",
+ Range: lastThreeHours(),
+ },
+ "A query that does not parse is reported as the user's": {
+ Query: "sum(",
+ Range: lastThreeHours(),
+ Err: assert.AnError,
+ },
+ "A query of the wrong type is reported too": {
+ Query: `"a string"`,
+ Range: lastThreeHours(),
+ Err: assert.AnError,
+ },
+ }
+
+ for cn, c2 := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := c2.Context
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ res, err := c.QueryRange(ctx, c2.Query, c2.Range)
+ testutil.AssertEqualError(t, c2.Err, err)
+
+ if c2.Err != nil {
+ assert.Nil(t, res)
+ return
+ }
+
+ m := resultMatrix(t, res)
+ require.Len(t, m, c2.Series)
+
+ ntr := c2.Range.Normalize()
+
+ for _, s := range m {
+ require.NotEmpty(t, s.Values)
+
+ // the line spans the window asked for, with no gap at
+ // either edge, and every point carries a real number.
+ first := s.Values[0].Timestamp.Time()
+ last := s.Values[len(s.Values)-1].Timestamp.Time()
+
+ assert.WithinDuration(t, ntr.From, first, 2*ntr.QueryStep())
+ assert.WithinDuration(t, ntr.To, last, 2*ntr.QueryStep())
+
+ for _, v := range s.Values {
+ assert.False(t, math.IsNaN(float64(v.Value)))
+ assert.False(t, math.IsInf(float64(v.Value), 0))
+ }
+
+ // the point count follows the step the range asks for,
+ // not the range's width in ticks: a year spans 525,600
+ // of them and still draws a few hundred points.
+ assert.Less(t, len(s.Values), 1_000)
+ }
+ })
+ }
+}
+
+func Test_Client_QueryRange_isFrozen(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+ tr := lastThreeHours()
+ q := _namespace + "stale_branches_total"
+
+ first, err := c.QueryRange(context.Background(), q, tr)
+ require.NoError(t, err)
+
+ second, err := c.QueryRange(context.Background(), q, tr)
+ require.NoError(t, err)
+
+ // the same window answers bit-identically, which is what keeps a
+ // drawn line from rewriting itself under the reader.
+ firstJSON, err := json.Marshal(first.Result)
+ require.NoError(t, err)
+
+ secondJSON, err := json.Marshal(second.Result)
+ require.NoError(t, err)
+
+ assert.JSONEq(t, string(firstJSON), string(secondJSON))
+
+ // and a window inside it answers the same values for the timestamps
+ // the two share: history does not depend on how it was asked for.
+ inner, err := c.QueryRange(context.Background(), q, processor.TimeRange{
+ From: tr.From.Add(time.Hour),
+ To: tr.To,
+ })
+ require.NoError(t, err)
+
+ outer := resultMatrix(t, first)
+ require.Len(t, resultMatrix(t, inner), len(outer))
+}
+
+func Test_Client_LabelNames(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+
+ res, err := c.LabelNames(context.Background(), nil, lastThreeHours())
+ require.NoError(t, err)
+ require.NotNil(t, res)
+
+ assert.Contains(t, res.Result, model.MetricNameLabel)
+ assert.Contains(t, res.Result, "vibe")
+
+ // a selector narrows the answer.
+ res, err = c.LabelNames(
+ context.Background(),
+ []string{_namespace + "deploy_confidence_index"},
+ lastThreeHours(),
+ )
+ require.NoError(t, err)
+ assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, res.Result)
+
+ // a selector that does not parse is the caller's mistake.
+ res, err = c.LabelNames(context.Background(), []string{"{{"}, lastThreeHours())
+ require.Error(t, err)
+ assert.Nil(t, res)
+}
+
+func Test_Client_LabelValues(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+
+ // the metric names are what the editor completes on.
+ res, err := c.LabelValues(context.Background(), model.MetricNameLabel, nil, lastThreeHours())
+ require.NoError(t, err)
+ assert.Len(t, res.Result, 15)
+
+ res, err = c.LabelValues(context.Background(), "vibe", nil, lastThreeHours())
+ require.NoError(t, err)
+ assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, res.Result)
+
+ res, err = c.LabelValues(
+ context.Background(),
+ "vibe",
+ []string{_namespace + "secrets_detected_commits_count"},
+ lastThreeHours(),
+ )
+ require.NoError(t, err)
+ assert.Empty(t, res.Result)
+
+ res, err = c.LabelValues(context.Background(), "vibe", []string{"{{"}, lastThreeHours())
+ require.Error(t, err)
+ assert.Nil(t, res)
+}
+
+func Test_Client_Series(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+
+ res, err := c.Series(
+ context.Background(),
+ []string{_namespace + "deploy_confidence_index"},
+ lastThreeHours(),
+ )
+ require.NoError(t, err)
+ require.Len(t, res.Result, 3)
+
+ for _, ls := range res.Result {
+ assert.Equal(t, model.LabelValue(_namespace+"deploy_confidence_index"), ls[model.MetricNameLabel])
+ assert.NotEmpty(t, ls["vibe"])
+ }
+
+ // selectors that overlap answer each series once.
+ res, err = c.Series(
+ context.Background(),
+ []string{
+ _namespace + "deploy_confidence_index",
+ _namespace + `deploy_confidence_index{vibe="nervous"}`,
+ },
+ lastThreeHours(),
+ )
+ require.NoError(t, err)
+ assert.Len(t, res.Result, 3)
+
+ // no selector at all names every series there is.
+ res, err = c.Series(context.Background(), nil, lastThreeHours())
+ require.NoError(t, err)
+
+ var buckets int
+
+ for _, f := range c.registry {
+ buckets += len(f.buckets)
+ }
+
+ assert.Len(t, res.Result, buckets)
+
+ res, err = c.Series(context.Background(), []string{"{{"}, lastThreeHours())
+ require.Error(t, err)
+ assert.Nil(t, res)
+}
+
+func Test_Client_parseMatchers(t *testing.T) {
+ t.Parallel()
+
+ c := NewClient()
+
+ // no selector means one empty set, which selects everything.
+ sets, err := c.parseMatchers(nil)
+ require.NoError(t, err)
+ assert.Equal(t, [][]*labels.Matcher{nil}, sets)
+
+ sets, err = c.parseMatchers([]string{`metric{a="b"}`, "other"})
+ require.NoError(t, err)
+ require.Len(t, sets, 2)
+ assert.Len(t, sets[0], 2)
+ assert.Len(t, sets[1], 1)
+
+ sets, err = c.parseMatchers([]string{"{{"})
+ require.Error(t, err)
+ assert.Nil(t, sets)
+}
+
+func Test_toModelMatrix(t *testing.T) {
+ t.Parallel()
+
+ m := toModelMatrix(promql.Matrix{
+ {
+ Metric: labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red"),
+ Floats: []promql.FPoint{{T: 1_700_000_000_000, F: 1.5}},
+ },
+ })
+
+ require.Len(t, m, 1)
+ assert.Equal(t, model.LabelValue("metric"), m[0].Metric[model.MetricNameLabel])
+ assert.Equal(t, model.LabelValue("red"), m[0].Metric["colour"])
+
+ require.Len(t, m[0].Values, 1)
+ assert.Equal(t, model.SampleValue(1.5), m[0].Values[0].Value)
+ assert.Equal(t, int64(1_700_000_000), m[0].Values[0].Timestamp.Unix())
+
+ assert.Empty(t, toModelMatrix(nil))
+}
+
+func Test_toModelLabelSet(t *testing.T) {
+ t.Parallel()
+
+ ls := toModelLabelSet(labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red"))
+
+ assert.Equal(t, model.LabelSet{
+ model.MetricNameLabel: "metric",
+ "colour": "red",
+ }, ls)
+
+ assert.Empty(t, toModelLabelSet(labels.EmptyLabels()))
+}
+
+// Test_welcomeDocumentQueries guards the pair that has no compiler
+// between them: the welcome document's metric blocks name metrics by
+// string, and this package is what has to answer them. A family renamed
+// on one side and not the other empties the document's charts, which
+// nothing else here would notice.
+func Test_welcomeDocumentQueries(t *testing.T) {
+ t.Parallel()
+
+ raw, err := os.ReadFile(_availableMetricsPath)
+ require.NoError(t, err)
+
+ var blocks []struct {
+ Attrs struct {
+ Queries []struct {
+ Query string `json:"query"`
+ } `json:"queries"`
+ } `json:"attrs"`
+ }
+
+ require.NoError(t, json.Unmarshal(raw, &blocks))
+ require.NotEmpty(t, blocks)
+
+ c := NewClient()
+ tr := lastThreeHours()
+
+ var queries int
+
+ for _, b := range blocks {
+ for _, q := range b.Attrs.Queries {
+ queries++
+
+ t.Run(q.Query, func(t *testing.T) {
+ t.Parallel()
+
+ res, err := c.QueryRange(context.Background(), q.Query, tr)
+ require.NoError(t, err)
+
+ m := resultMatrix(t, res)
+ assert.NotEmpty(t, m, "%s returned no series", q.Query)
+
+ for _, s := range m {
+ assert.NotEmpty(t, s.Values, "%s returned an empty series", q.Query)
+ }
+ })
+ }
+ }
+
+ // every family the registry declares earns its place in the pool the
+ // document draws from, and vice versa.
+ assert.Len(t, c.registry, queries)
+}
diff --git a/server/core/internal/datasource/demo/demo.go b/server/core/internal/datasource/demo/demo.go
new file mode 100644
index 00000000..fd535bc2
--- /dev/null
+++ b/server/core/internal/datasource/demo/demo.go
@@ -0,0 +1,28 @@
+// Package demo serves a self-contained Prometheus data source: its metrics
+// are synthesized on demand from a fixed seed instead of being scraped from
+// a running server, so a fresh install shows populated charts with nothing
+// else deployed.
+package demo
+
+import (
+ "net/http"
+
+ "github.com/oxynote/oxynote/server/core/pkg/errutil"
+)
+
+const (
+ // Scheme prefixes every URL this package answers for.
+ Scheme = "demo://"
+
+ // URL is the only demo data source that exists.
+ URL = Scheme + "engineering"
+)
+
+// ErrUnknownSource is returned when a demo URL names a source that does
+// not exist.
+var ErrUnknownSource = errutil.New(
+ http.StatusBadRequest,
+ "data_source.unknown_demo_source",
+ "Unknown demo data source. The only demo data source is %q.",
+ URL,
+)
diff --git a/server/core/internal/datasource/demo/demo_test.go b/server/core/internal/datasource/demo/demo_test.go
new file mode 100644
index 00000000..e0f3bb48
--- /dev/null
+++ b/server/core/internal/datasource/demo/demo_test.go
@@ -0,0 +1,31 @@
+package demo
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "go.uber.org/goleak"
+)
+
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m)
+}
+
+func Test_URL(t *testing.T) {
+ t.Parallel()
+
+ // the runner routes on the scheme before it routes on the whole URL,
+ // so the one demo source has to carry it.
+ assert.Equal(t, "demo://", Scheme)
+ assert.Equal(t, "demo://engineering", URL)
+}
+
+func Test_ErrUnknownSource(t *testing.T) {
+ t.Parallel()
+
+ assert.Error(t, ErrUnknownSource)
+
+ // the message names the source that does exist: the reader mistyped
+ // something, and the only useful answer is what they meant.
+ assert.Contains(t, ErrUnknownSource.Error(), URL)
+}
diff --git a/server/core/internal/datasource/demo/queryable.go b/server/core/internal/datasource/demo/queryable.go
new file mode 100644
index 00000000..026c6b63
--- /dev/null
+++ b/server/core/internal/datasource/demo/queryable.go
@@ -0,0 +1,245 @@
+package demo
+
+import (
+ "context"
+ "slices"
+
+ "github.com/prometheus/prometheus/model/histogram"
+ "github.com/prometheus/prometheus/model/labels"
+ "github.com/prometheus/prometheus/storage"
+ "github.com/prometheus/prometheus/tsdb/chunkenc"
+ "github.com/prometheus/prometheus/tsdb/chunks"
+ "github.com/prometheus/prometheus/util/annotations"
+)
+
+// _samplesPerRange is the least number of samples a range selector's
+// window holds, so a function reading a window has something to read.
+const _samplesPerRange = 4
+
+// _maxSamplesPerSeries caps what one series contributes to a single
+// evaluation. Reaching it widens the spacing rather than cutting the
+// series short: a coarser line still spans the window that was asked
+// for, while a truncated one would simply stop mid-chart.
+//
+// It has to stay well above the hundred-odd points a time range resolves
+// its step to. Widening spaces the samples span/_maxSamplesPerSeries
+// apart, and the lookback window a query opens is its own step wide — so
+// a cap below that ratio would space samples further apart than the
+// window that has to find them, and points of the grid would read empty.
+const _maxSamplesPerSeries = 5_000
+
+// selectStep reports how far apart the samples of one Select sit. The
+// engine reads on a fixed grid and every point of it has to find a sample
+// behind it, so the step the query evaluates at is also the coarsest the
+// samples may be spaced; a range selector needs several samples inside
+// every window on top of that.
+func selectStep(hints *storage.SelectHints, span int64) int64 {
+ step := _tickMillis
+
+ if hints != nil {
+ if hints.Step > step {
+ step = hints.Step
+ }
+
+ if hints.Range > 0 {
+ if within := hints.Range / _samplesPerRange; within < step {
+ step = within
+ }
+ }
+ }
+
+ if step < _tickMillis {
+ step = _tickMillis
+ }
+
+ if widest := span / _maxSamplesPerSeries; widest > step {
+ step = widest
+ }
+
+ return step
+}
+
+// samplesIn returns one series' readings across the given millisecond
+// window, spaced a step apart and aligned to absolute multiples of it so
+// the same window always yields the same timestamps. The first sample
+// sits at or before the window's start, which is what gives the first
+// point of the evaluation grid something to look back at.
+func samplesIn(value func(tick int64) float64, start, end, step int64) []chunks.Sample {
+ if last := timeAt(latestTick()).UnixMilli(); end > last {
+ end = last
+ }
+
+ var ss []chunks.Sample
+
+ for ts := start / step * step; ts <= end; ts += step {
+ tick := tickAtMillis(ts)
+ if tick < 0 {
+ continue
+ }
+
+ ss = append(ss, floatSample{t: ts, f: value(tick)})
+ }
+
+ return ss
+}
+
+// queryable serves one client's registry to the promql engine.
+type queryable struct {
+ // registry is what the evaluation reads out of.
+ registry registry
+}
+
+// Querier returns a querier scoped to the given millisecond bounds.
+func (q queryable) Querier(mint, maxt int64) (storage.Querier, error) {
+ return &querier{registry: q.registry, mint: mint, maxt: maxt}, nil
+}
+
+// querier answers one promql evaluation out of the registry.
+type querier struct {
+ // registry is what the evaluation reads out of.
+ registry registry
+
+ // mint and maxt are the inclusive millisecond bounds the evaluation
+ // reads within.
+ mint int64
+ maxt int64
+}
+
+// Select returns the series the matchers name, sampled across the window
+// the hints ask about.
+func (q *querier) Select(
+ _ context.Context,
+ sortSeries bool,
+ hints *storage.SelectHints,
+ mm ...*labels.Matcher,
+) storage.SeriesSet {
+ start, end := q.mint, q.maxt
+
+ if hints != nil {
+ start, end = hints.Start, hints.End
+ }
+
+ step := selectStep(hints, end-start)
+
+ var ss []storage.Series
+
+ q.registry.forEach(mm, func(lbls labels.Labels, value func(tick int64) float64) {
+ ss = append(ss, storage.NewListSeries(lbls, samplesIn(value, start, end, step)))
+ })
+
+ if sortSeries {
+ slices.SortFunc(ss, func(a, b storage.Series) int {
+ return labels.Compare(a.Labels(), b.Labels())
+ })
+ }
+
+ return &seriesSet{series: ss, index: -1}
+}
+
+// LabelNames returns every label name the matching series carry, sorted.
+func (q *querier) LabelNames(
+ _ context.Context,
+ _ *storage.LabelHints,
+ mm ...*labels.Matcher,
+) ([]string, annotations.Annotations, error) {
+ return q.registry.labelNames([][]*labels.Matcher{mm}), nil, nil
+}
+
+// LabelValues returns every value the matching series carry for the named
+// label, sorted.
+func (q *querier) LabelValues(
+ _ context.Context,
+ name string,
+ _ *storage.LabelHints,
+ mm ...*labels.Matcher,
+) ([]string, annotations.Annotations, error) {
+ return q.registry.labelValues(name, [][]*labels.Matcher{mm}), nil, nil
+}
+
+// Close releases the querier's resources, of which it holds none.
+func (q *querier) Close() error {
+ return nil
+}
+
+// seriesSet iterates the series one Select produced.
+type seriesSet struct {
+ // series are the selected series, already sorted when the caller
+ // asked for that.
+ series []storage.Series
+
+ // index is the position At reports, before Next has run for the
+ // first time -1.
+ index int
+}
+
+// Next advances to the next series and reports whether one is there.
+func (ss *seriesSet) Next() bool {
+ ss.index++
+
+ return ss.index < len(ss.series)
+}
+
+// At returns the series the iteration currently sits on.
+func (ss *seriesSet) At() storage.Series {
+ return ss.series[ss.index]
+}
+
+// Err returns the error iteration failed with, of which synthesized data
+// has none.
+func (ss *seriesSet) Err() error {
+ return nil
+}
+
+// Warnings returns the warnings gathered over the set, of which
+// synthesized data has none.
+func (ss *seriesSet) Warnings() annotations.Annotations {
+ return nil
+}
+
+// floatSample is one demo reading in the shape the storage layer reads
+// samples in. The demo publishes gauges only, so the histogram accessors
+// report none.
+type floatSample struct {
+ // t is the sample's millisecond timestamp.
+ t int64
+
+ // f is the value read at that instant.
+ f float64
+}
+
+// T returns the sample's timestamp in milliseconds.
+func (s floatSample) T() int64 {
+ return s.t
+}
+
+// ST returns the sample's start timestamp, which a synthesized reading
+// does not carry.
+func (s floatSample) ST() int64 {
+ return 0
+}
+
+// F returns the sample's value.
+func (s floatSample) F() float64 {
+ return s.f
+}
+
+// H returns the sample's histogram, of which a gauge has none.
+func (s floatSample) H() *histogram.Histogram {
+ return nil
+}
+
+// FH returns the sample's float histogram, of which a gauge has none.
+func (s floatSample) FH() *histogram.FloatHistogram {
+ return nil
+}
+
+// Type reports what kind of value the sample carries.
+func (s floatSample) Type() chunkenc.ValueType {
+ return chunkenc.ValFloat
+}
+
+// Copy returns a deep copy of the sample, which holds no references and
+// so is itself.
+func (s floatSample) Copy() chunks.Sample {
+ return s
+}
diff --git a/server/core/internal/datasource/demo/queryable_test.go b/server/core/internal/datasource/demo/queryable_test.go
new file mode 100644
index 00000000..7c620327
--- /dev/null
+++ b/server/core/internal/datasource/demo/queryable_test.go
@@ -0,0 +1,279 @@
+package demo
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/prometheus/common/model"
+ "github.com/prometheus/prometheus/model/labels"
+ "github.com/prometheus/prometheus/storage"
+ "github.com/prometheus/prometheus/tsdb/chunkenc"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// countingValue returns a value function reporting the tick it was asked
+// about, so a caller can tell which tick produced a sample.
+func countingValue(tick int64) float64 {
+ return float64(tick)
+}
+
+func Test_selectStep(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Hints *storage.SelectHints
+ Span int64
+ Result int64
+ }{
+ "No hints at all falls back to the tick": {
+ Result: _tickMillis,
+ },
+ "A step finer than the tick is widened to it": {
+ Hints: &storage.SelectHints{Step: 15_000},
+ Result: _tickMillis,
+ },
+ "A step coarser than the tick is kept": {
+ Hints: &storage.SelectHints{Step: 5 * _tickMillis},
+ Result: 5 * _tickMillis,
+ },
+ "A range selector is sampled several times per window": {
+ Hints: &storage.SelectHints{Step: 100 * _tickMillis, Range: 20 * _tickMillis},
+ Result: 5 * _tickMillis,
+ },
+ "A range shorter than the tick still samples at the tick": {
+ Hints: &storage.SelectHints{Step: 100 * _tickMillis, Range: 1_000},
+ Result: _tickMillis,
+ },
+ "A range wider than the step leaves the step alone": {
+ Hints: &storage.SelectHints{Step: 2 * _tickMillis, Range: 1_000 * _tickMillis},
+ Result: 2 * _tickMillis,
+ },
+ "A span too wide for the cap widens the spacing": {
+ Hints: &storage.SelectHints{Step: _tickMillis},
+ Span: _tickMillis * _maxSamplesPerSeries * 3,
+ Result: _tickMillis * 3,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ step := selectStep(c.Hints, c.Span)
+ assert.Equal(t, c.Result, step)
+
+ // whatever the inputs, one series never outgrows the cap and
+ // never samples finer than the timeline itself.
+ assert.GreaterOrEqual(t, step, _tickMillis)
+ assert.LessOrEqual(t, c.Span/step, int64(_maxSamplesPerSeries))
+ })
+ }
+}
+
+func Test_samplesIn(t *testing.T) {
+ t.Parallel()
+
+ start := _epoch.Add(time.Hour).UnixMilli()
+ end := _epoch.Add(2 * time.Hour).UnixMilli()
+
+ ss := samplesIn(countingValue, start, end, _tickMillis)
+
+ require.NotEmpty(t, ss)
+
+ // the window is covered end to end, one sample a step apart, and
+ // each carries the value of the tick it lands in.
+ assert.Len(t, ss, 61)
+ assert.Equal(t, start, ss[0].T())
+ assert.Equal(t, end, ss[len(ss)-1].T())
+
+ for i, s := range ss {
+ assert.Equal(t, float64(tickAtMillis(s.T())), s.F())
+
+ if i > 0 {
+ assert.Equal(t, _tickMillis, s.T()-ss[i-1].T())
+ }
+ }
+
+ // the first sample sits at or before the window's start, so the
+ // first point of the evaluation grid has something to look back at.
+ ss = samplesIn(countingValue, start+30_000, end, _tickMillis)
+ require.NotEmpty(t, ss)
+ assert.LessOrEqual(t, ss[0].T(), start+30_000)
+
+ // nothing past the present is ever sampled.
+ last := timeAt(latestTick()).UnixMilli()
+
+ ss = samplesIn(countingValue, last-10*_tickMillis, last+time.Hour.Milliseconds(), _tickMillis)
+ require.NotEmpty(t, ss)
+ assert.LessOrEqual(t, ss[len(ss)-1].T(), last)
+
+ // a window that lands entirely before the epoch produces nothing
+ // rather than running the timeline backwards.
+ assert.Empty(t, samplesIn(
+ countingValue,
+ _epoch.Add(-10*time.Hour).UnixMilli(),
+ _epoch.Add(-5*time.Hour).UnixMilli(),
+ _tickMillis,
+ ))
+}
+
+func Test_queryable_Querier(t *testing.T) {
+ t.Parallel()
+
+ q, err := queryable{}.Querier(10, 20)
+ require.NoError(t, err)
+ require.NotNil(t, q)
+
+ qr, ok := q.(*querier)
+ require.True(t, ok)
+
+ assert.Equal(t, int64(10), qr.mint)
+ assert.Equal(t, int64(20), qr.maxt)
+}
+
+func Test_querier_Select(t *testing.T) {
+ t.Parallel()
+
+ var (
+ start = _epoch.Add(time.Hour).UnixMilli()
+ end = _epoch.Add(2 * time.Hour).UnixMilli()
+ name = _namespace + "deploy_confidence_index"
+ )
+
+ q := &querier{registry: newRegistry(), mint: start, maxt: end}
+
+ // the matchers narrow the set, and every series in it carries the
+ // samples of the window the hints asked about.
+ set := q.Select(
+ context.Background(),
+ true,
+ &storage.SelectHints{Start: start, End: end, Step: _tickMillis},
+ nameMatcher(t, name),
+ )
+
+ require.NoError(t, set.Err())
+ assert.Empty(t, set.Warnings())
+
+ var got []string
+
+ for set.Next() {
+ s := set.At()
+ got = append(got, s.Labels().Get("vibe"))
+
+ assert.Equal(t, name, s.Labels().Get(model.MetricNameLabel))
+
+ var samples int
+
+ it := s.Iterator(nil)
+ for it.Next() == chunkenc.ValFloat {
+ ts, _ := it.At()
+
+ assert.GreaterOrEqual(t, ts, start)
+ assert.LessOrEqual(t, ts, end)
+
+ samples++
+ }
+
+ require.NoError(t, it.Err())
+ assert.Equal(t, 61, samples)
+ }
+
+ // sorting was asked for, so the series arrive in label order.
+ assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, got)
+
+ // with no hints the querier falls back to its own bounds rather than
+ // answering with nothing.
+ set = q.Select(context.Background(), false, nil, nameMatcher(t, name))
+
+ require.True(t, set.Next())
+ assert.NotNil(t, set.At())
+
+ // a matcher naming nothing yields an empty set, not an error.
+ set = q.Select(context.Background(), false, nil, nameMatcher(t, "nope"))
+
+ assert.False(t, set.Next())
+ require.NoError(t, set.Err())
+}
+
+func Test_querier_LabelNames(t *testing.T) {
+ t.Parallel()
+
+ names, annos, err := (&querier{registry: newRegistry()}).LabelNames(
+ context.Background(),
+ nil,
+ nameMatcher(t, _namespace+"deploy_confidence_index"),
+ )
+
+ require.NoError(t, err)
+ assert.Empty(t, annos)
+ assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, names)
+}
+
+func Test_querier_LabelValues(t *testing.T) {
+ t.Parallel()
+
+ values, annos, err := (&querier{registry: newRegistry()}).LabelValues(
+ context.Background(),
+ "vibe",
+ nil,
+ nameMatcher(t, _namespace+"deploy_confidence_index"),
+ )
+
+ require.NoError(t, err)
+ assert.Empty(t, annos)
+ assert.Equal(t, []string{"feeling_lucky", "nervous", "oncall_already_paged"}, values)
+}
+
+func Test_querier_Close(t *testing.T) {
+ t.Parallel()
+
+ assert.NoError(t, (&querier{}).Close())
+}
+
+func Test_seriesSet(t *testing.T) {
+ t.Parallel()
+
+ one := storage.NewListSeries(labels.FromStrings(model.MetricNameLabel, "one"), nil)
+ two := storage.NewListSeries(labels.FromStrings(model.MetricNameLabel, "two"), nil)
+
+ ss := &seriesSet{series: []storage.Series{one, two}, index: -1}
+
+ require.True(t, ss.Next())
+ assert.Same(t, one, ss.At())
+
+ require.True(t, ss.Next())
+ assert.Same(t, two, ss.At())
+
+ // the set ends, and stays ended.
+ assert.False(t, ss.Next())
+ assert.False(t, ss.Next())
+
+ assert.NoError(t, ss.Err())
+ assert.Empty(t, ss.Warnings())
+
+ // an empty set reports nothing rather than panicking on the first
+ // read.
+ empty := &seriesSet{index: -1}
+ assert.False(t, empty.Next())
+}
+
+func Test_floatSample(t *testing.T) {
+ t.Parallel()
+
+ s := floatSample{t: 1_700_000_000_000, f: 42.5}
+
+ assert.Equal(t, int64(1_700_000_000_000), s.T())
+ assert.Equal(t, 42.5, s.F())
+ assert.Equal(t, chunkenc.ValFloat, s.Type())
+
+ // a synthesized gauge reading carries no start timestamp and no
+ // histogram of either shape.
+ assert.Zero(t, s.ST())
+ assert.Nil(t, s.H())
+ assert.Nil(t, s.FH())
+
+ // it holds no references, so a copy is the value itself.
+ assert.Equal(t, s, s.Copy())
+}
diff --git a/server/core/internal/datasource/demo/registry.go b/server/core/internal/datasource/demo/registry.go
new file mode 100644
index 00000000..20fada2b
--- /dev/null
+++ b/server/core/internal/datasource/demo/registry.go
@@ -0,0 +1,556 @@
+package demo
+
+import (
+ "math"
+ "slices"
+
+ "github.com/prometheus/common/model"
+ "github.com/prometheus/prometheus/model/labels"
+)
+
+// _namespace prefixes every demo metric name.
+const _namespace = "engineering_"
+
+// _labelOutcome names the label both outcome-split families are broken
+// down by.
+const _labelOutcome = "outcome"
+
+// Sampling parameters of the friday_deploys family, whose two buckets are
+// derived from one another instead of being sampled independently.
+const (
+ // _fridayDeployMean is the weekly number of Friday deploys attempted.
+ _fridayDeployMean = 30_000
+
+ // _fridayDeployRelStdDev spreads that count tick to tick.
+ _fridayDeployRelStdDev = 0.15
+
+ // _fridayDeploySuccessMean is the mean fraction of attempts that
+ // survive.
+ _fridayDeploySuccessMean = 0.68
+
+ // _fridayDeploySuccessStdDev spreads that fraction tick to tick.
+ _fridayDeploySuccessStdDev = 0.12
+)
+
+// bucket is one series of a metric family.
+type bucket struct {
+ // label is the label value the bucket is published under. It is
+ // empty for a family that publishes a single unlabelled series.
+ label string
+
+ // value reports the bucket's reading at the given tick.
+ value func(tick int64) float64
+}
+
+// family is one metric family of the demo timeline.
+type family struct {
+ // name is the metric name the family is published under.
+ name string
+
+ // help describes what the family measures.
+ help string
+
+ // label is the label key the family's buckets are broken down by.
+ // It is empty for a family that publishes a single unlabelled
+ // series.
+ label string
+
+ // buckets are the family's series, one per label value.
+ buckets []bucket
+}
+
+// registry is everything the demo publishes. One is built per client and
+// read by every query it answers: the walks behind it cache the history
+// they replay, so a registry that is rebuilt is a cache that is thrown
+// away.
+type registry []family
+
+// forEach calls fn for every series the matchers select, in declaration
+// order.
+func (r registry) forEach(mm []*labels.Matcher, fn func(lbls labels.Labels, value func(tick int64) float64)) {
+ for _, f := range r {
+ for _, b := range f.buckets {
+ lbls := seriesLabels(f, b)
+
+ if matches(lbls, mm) {
+ fn(lbls, b.value)
+ }
+ }
+ }
+}
+
+// labelNames returns every label name the given matcher sets select,
+// sorted. The sets are unioned, the way a series endpoint handed several
+// selectors answers with everything any one of them names.
+func (r registry) labelNames(sets [][]*labels.Matcher) []string {
+ names := map[string]struct{}{}
+
+ for _, set := range sets {
+ r.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) {
+ lbls.Range(func(l labels.Label) {
+ names[l.Name] = struct{}{}
+ })
+ })
+ }
+
+ return sortedKeys(names)
+}
+
+// labelValues returns every value the given matcher sets carry for the
+// named label, sorted and unioned across the sets.
+func (r registry) labelValues(name string, sets [][]*labels.Matcher) []string {
+ values := map[string]struct{}{}
+
+ for _, set := range sets {
+ r.forEach(set, func(lbls labels.Labels, _ func(tick int64) float64) {
+ if v := lbls.Get(name); v != "" {
+ values[v] = struct{}{}
+ }
+ })
+ }
+
+ return sortedKeys(values)
+}
+
+// seriesLabels returns the label set the given bucket of the given family
+// is published under.
+func seriesLabels(f family, b bucket) labels.Labels {
+ if f.label == "" {
+ return labels.FromStrings(model.MetricNameLabel, f.name)
+ }
+
+ return labels.FromStrings(model.MetricNameLabel, f.name, f.label, b.label)
+}
+
+// matches reports whether the label set satisfies every matcher.
+func matches(lbls labels.Labels, mm []*labels.Matcher) bool {
+ for _, m := range mm {
+ if !m.Matches(lbls.Get(m.Name)) {
+ return false
+ }
+ }
+
+ return true
+}
+
+// sortedKeys returns the set's members in sorted order, and an empty
+// slice when it holds none.
+func sortedKeys(set map[string]struct{}) []string {
+ kk := make([]string, 0, len(set))
+
+ for k := range set {
+ kk = append(kk, k)
+ }
+
+ slices.Sort(kk)
+
+ return kk
+}
+
+// builder assigns every series its own random stream as the registry is
+// declared, so the table below carries tuned parameters rather than
+// hand-written seeds, and no two series ever draw the same numbers.
+type builder struct {
+ // seed is the last seed handed out.
+ seed int64
+}
+
+// next hands out the next unused seed.
+func (b *builder) next() int64 {
+ b.seed++
+
+ return b.seed
+}
+
+// count declares a bucket resampled from scratch every tick.
+func (b *builder) count(label string, mean, relStdDev float64) bucket {
+ seed := b.next()
+
+ return bucket{
+ label: label,
+ value: func(tick int64) float64 {
+ return countAt(seed, tick, mean, relStdDev)
+ },
+ }
+}
+
+// walk declares a bucket that drifts between ticks.
+func (b *builder) walk(label string, p walkParams) bucket {
+ w := newWalk(b.next(), p)
+
+ return bucket{
+ label: label,
+ value: w.at,
+ }
+}
+
+// deploys declares the friday_deploys buckets. The successful bucket is a
+// fraction of the attempted one rather than an independent sample, so a
+// chart of the two never shows more deploys surviving than were tried.
+func (b *builder) deploys() []bucket {
+ attempted := b.count("attempted", _fridayDeployMean, _fridayDeployRelStdDev)
+ seed := b.next()
+
+ return []bucket{
+ attempted,
+ {
+ label: "successful",
+ value: func(tick int64) float64 {
+ rate := clamp(
+ normal(newRand(seed, tick), _fridayDeploySuccessMean, _fridayDeploySuccessStdDev),
+ 0,
+ 1,
+ )
+
+ return math.Round(attempted.value(tick) * rate)
+ },
+ },
+ }
+}
+
+// newRegistry declares the demo's metric families. Means are aggregate
+// weekly totals across the ~10k organisations the demo pretends to
+// measure.
+//
+//nolint:funlen,maintidx // a flat table of tuned parameters, not logic
+func newRegistry() registry {
+ b := &builder{}
+
+ return registry{
+ {
+ name: _namespace + "meetings_could_be_doc_count",
+ help: "Weekly count of meetings that could've been a document.",
+ label: "excuse",
+ buckets: []bucket{
+ b.count("alignment", 42_000, 0.15),
+ b.count("bandwidth", 36_000, 0.15),
+ b.count("synergy", 30_000, 0.15),
+ b.count("optics", 12_000, 0.20),
+ },
+ },
+ {
+ name: _namespace + "circle_back_count",
+ help: `Weekly count of "let's circle back" mentions.`,
+ label: _labelOutcome,
+ buckets: []bucket{
+ b.count("circled_back", 16_000, 0.20),
+ b.count("forgotten", 48_000, 0.15),
+ b.count("rescheduled_to_circle_back", 16_000, 0.20),
+ },
+ },
+ {
+ name: _namespace + "friday_deploys_count",
+ help: "Weekly Friday deploy counts.",
+ label: _labelOutcome,
+ buckets: b.deploys(),
+ },
+ {
+ name: _namespace + "pizza_fridays_next_30_days",
+ help: "Pizza Fridays scheduled in the next 30 days.",
+ label: "pizza_count",
+ buckets: []bucket{
+ b.count("1_to_5", 10_000, 0.15),
+ b.count("5_to_10", 7_000, 0.20),
+ b.count("10_plus", 3_000, 0.25),
+ },
+ },
+ {
+ name: _namespace + "ship_it_approvals_after_5pm_count",
+ help: `Weekly count of "ship it" PR approvals left after 5 pm.`,
+ label: "reviewer_state",
+ buckets: []bucket{
+ b.count("sober", 20_000, 0.15),
+ b.count("one_beer_in", 14_000, 0.20),
+ b.count("heading_to_the_door", 6_000, 0.25),
+ },
+ },
+ {
+ name: _namespace + "prs_merged_friday_afternoon_count",
+ help: "Weekly count of PRs merged on Fridays after 3 pm.",
+ label: "confidence",
+ buckets: []bucket{
+ b.count("yolo", 20_000, 0.15),
+ b.count("fingers_crossed", 22_500, 0.15),
+ b.count("vacation_starts_monday", 7_500, 0.20),
+ },
+ },
+ {
+ name: _namespace + "secrets_detected_commits_count",
+ help: "Secrets detected in commits over the past week.",
+ label: "severity",
+ buckets: []bucket{
+ b.count("normal", 2_100, 0.25),
+ b.count("critical", 750, 0.30),
+ b.count("nuclear", 150, 0.35),
+ },
+ },
+ {
+ name: _namespace + "quick_sync_meetings_same_day_count",
+ help: `Weekly count of "quick sync" meetings scheduled same-day.`,
+ label: "duration",
+ buckets: []bucket{
+ b.count("10m_to_30m", 33_000, 0.15),
+ b.count("30m_to_1h", 21_000, 0.15),
+ b.count("1h_plus", 6_000, 0.25),
+ },
+ },
+ {
+ name: _namespace + "temporary_fix_age_days",
+ help: `Age in days of the oldest surviving "temporary" fix.`,
+ label: "original_comment",
+ buckets: []bucket{
+ b.walk("temp", walkParams{
+ Min: 365,
+ Max: 5_000,
+ Start: 2_000,
+ Target: 2_200,
+ MeanReversion: 0.015,
+ NoiseStdDev: 80,
+ DriftPerStep: 0.2,
+ SpikeChance: 0.08,
+ SpikeStdDev: 200,
+ }),
+ b.walk("hack", walkParams{
+ Min: 1_000,
+ Max: 18_250,
+ Start: 6_000,
+ Target: 7_000,
+ MeanReversion: 0.008,
+ NoiseStdDev: 150,
+ DriftPerStep: 0.4,
+ SpikeChance: 0.07,
+ SpikeStdDev: 400,
+ }),
+ b.walk("fixme", walkParams{
+ Min: 180,
+ Max: 4_000,
+ Start: 1_500,
+ Target: 1_700,
+ MeanReversion: 0.015,
+ NoiseStdDev: 60,
+ DriftPerStep: 0.2,
+ SpikeChance: 0.08,
+ SpikeStdDev: 150,
+ }),
+ b.walk("todo", walkParams{
+ Min: 500,
+ Max: 10_000,
+ Start: 3_500,
+ Target: 4_000,
+ MeanReversion: 0.008,
+ NoiseStdDev: 100,
+ DriftPerStep: 0.3,
+ SpikeChance: 0.08,
+ SpikeStdDev: 250,
+ }),
+ },
+ },
+ {
+ name: _namespace + "deploy_confidence_index",
+ help: "Self-reported deploy confidence index (0–100).",
+ label: "vibe",
+ buckets: []bucket{
+ b.walk("feeling_lucky", walkParams{
+ Min: 60,
+ Max: 100,
+ Start: 78,
+ Target: 80,
+ MeanReversion: 0.08,
+ NoiseStdDev: 3,
+ SpikeChance: 0.05,
+ SpikeStdDev: 8,
+ }),
+ b.walk("nervous", walkParams{
+ Min: 30,
+ Max: 75,
+ Start: 52,
+ Target: 55,
+ MeanReversion: 0.08,
+ NoiseStdDev: 4,
+ SpikeChance: 0.07,
+ SpikeStdDev: 10,
+ }),
+ b.walk("oncall_already_paged", walkParams{
+ Min: 0,
+ Max: 40,
+ Start: 18,
+ Target: 20,
+ MeanReversion: 0.1,
+ NoiseStdDev: 3,
+ SpikeChance: 0.1,
+ SpikeStdDev: 8,
+ }),
+ },
+ },
+ {
+ name: _namespace + "pr_time_to_first_review_minutes",
+ help: "Median time from PR open to first review comment (minutes).",
+ buckets: []bucket{
+ b.walk("", walkParams{
+ Min: 5,
+ Max: 10_080,
+ Start: 280,
+ Target: 300,
+ MeanReversion: 0.1,
+ NoiseStdDev: 25,
+ SpikeChance: 0.08,
+ SpikeStdDev: 180,
+ }),
+ },
+ },
+ {
+ name: _namespace + "hotfixes_per_release",
+ help: "Average number of hotfixes per release.",
+ label: "root_cause",
+ buckets: []bucket{
+ b.walk("works_on_my_machine", walkParams{
+ Min: 0,
+ Max: 12,
+ Start: 0.9,
+ Target: 1.0,
+ MeanReversion: 0.1,
+ NoiseStdDev: 0.2,
+ SpikeChance: 0.05,
+ SpikeStdDev: 1,
+ }),
+ b.walk("not_my_fault", walkParams{
+ Min: 0,
+ Max: 10,
+ Start: 0.8,
+ Target: 0.9,
+ MeanReversion: 0.1,
+ NoiseStdDev: 0.2,
+ SpikeChance: 0.05,
+ SpikeStdDev: 0.8,
+ }),
+ b.walk("timezone_issue", walkParams{
+ Min: 0,
+ Max: 5,
+ Start: 0.4,
+ Target: 0.45,
+ MeanReversion: 0.1,
+ NoiseStdDev: 0.1,
+ SpikeChance: 0.04,
+ SpikeStdDev: 0.5,
+ }),
+ },
+ },
+ {
+ name: _namespace + "works_on_my_machine_rate",
+ help: `Self-reported "works on my machine" incidence rate (0–100).`,
+ label: "platform",
+ buckets: []bucket{
+ b.walk("linux", walkParams{
+ Min: 10,
+ Max: 60,
+ Start: 28,
+ Target: 30,
+ MeanReversion: 0.06,
+ NoiseStdDev: 2,
+ SpikeChance: 0.05,
+ SpikeStdDev: 6,
+ }),
+ b.walk("mac", walkParams{
+ Min: 25,
+ Max: 75,
+ Start: 48,
+ Target: 50,
+ MeanReversion: 0.06,
+ NoiseStdDev: 2.5,
+ SpikeChance: 0.06,
+ SpikeStdDev: 8,
+ }),
+ b.walk("my_special_setup", walkParams{
+ Min: 60,
+ Max: 100,
+ Start: 82,
+ Target: 85,
+ MeanReversion: 0.05,
+ NoiseStdDev: 3,
+ SpikeChance: 0.07,
+ SpikeStdDev: 5,
+ }),
+ },
+ },
+ {
+ name: _namespace + "stale_branches_total",
+ help: "Number of branches with no activity for more than 90 days.",
+ label: "owner_status",
+ buckets: []bucket{
+ b.walk("still_employed", walkParams{
+ Min: 0,
+ Max: 800_000,
+ Start: 95_000,
+ Target: 120_000,
+ MeanReversion: 0.03,
+ NoiseStdDev: 6_000,
+ DriftPerStep: 200,
+ SpikeChance: 0.08,
+ SpikeStdDev: 30_000,
+ }),
+ b.walk("left_the_company", walkParams{
+ Min: 0,
+ Max: 700_000,
+ Start: 85_000,
+ Target: 100_000,
+ MeanReversion: 0.015,
+ NoiseStdDev: 5_000,
+ DriftPerStep: 300,
+ SpikeChance: 0.07,
+ SpikeStdDev: 25_000,
+ }),
+ b.walk("nobody_knows", walkParams{
+ Min: 0,
+ Max: 500_000,
+ Start: 50_000,
+ Target: 80_000,
+ MeanReversion: 0.008,
+ NoiseStdDev: 4_000,
+ DriftPerStep: 100,
+ SpikeChance: 0.07,
+ SpikeStdDev: 20_000,
+ }),
+ },
+ },
+ {
+ name: _namespace + "feature_flag_graveyard_size",
+ help: "Number of feature flags that have outlived their purpose.",
+ label: "removal_likelihood",
+ buckets: []bucket{
+ b.walk("maybe_someday", walkParams{
+ Min: 20_000,
+ Max: 2_000_000,
+ Start: 200_000,
+ Target: 240_000,
+ MeanReversion: 0.02,
+ NoiseStdDev: 15_000,
+ DriftPerStep: 400,
+ SpikeChance: 0.08,
+ SpikeStdDev: 60_000,
+ }),
+ b.walk("never", walkParams{
+ Min: 30_000,
+ Max: 3_000_000,
+ Start: 250_000,
+ Target: 280_000,
+ MeanReversion: 0.02,
+ NoiseStdDev: 18_000,
+ DriftPerStep: 500,
+ SpikeChance: 0.09,
+ SpikeStdDev: 70_000,
+ }),
+ b.walk("what_does_this_do", walkParams{
+ Min: 5_000,
+ Max: 1_000_000,
+ Start: 70_000,
+ Target: 80_000,
+ MeanReversion: 0.02,
+ NoiseStdDev: 8_000,
+ DriftPerStep: 200,
+ SpikeChance: 0.08,
+ SpikeStdDev: 30_000,
+ }),
+ },
+ },
+ }
+}
diff --git a/server/core/internal/datasource/demo/registry_test.go b/server/core/internal/datasource/demo/registry_test.go
new file mode 100644
index 00000000..594e74a6
--- /dev/null
+++ b/server/core/internal/datasource/demo/registry_test.go
@@ -0,0 +1,330 @@
+package demo
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/prometheus/common/model"
+ "github.com/prometheus/prometheus/model/labels"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_builder_next(t *testing.T) {
+ t.Parallel()
+
+ b := &builder{}
+
+ // every series gets a seed of its own, so no two draw the same
+ // numbers.
+ assert.Equal(t, int64(1), b.next())
+ assert.Equal(t, int64(2), b.next())
+ assert.Equal(t, int64(3), b.next())
+}
+
+func Test_builder_count(t *testing.T) {
+ t.Parallel()
+
+ b := &builder{}
+
+ one := b.count("first", 1_000, 0.1)
+ two := b.count("second", 1_000, 0.1)
+
+ assert.Equal(t, "first", one.label)
+ assert.Equal(t, "second", two.label)
+
+ require.NotNil(t, one.value)
+
+ // the reading is the count the tick names, and two buckets declared
+ // alike still read differently because their seeds differ.
+ assert.Equal(t, countAt(1, 42, 1_000, 0.1), one.value(42))
+ assert.NotEqual(t, one.value(42), two.value(42))
+}
+
+func Test_builder_walk(t *testing.T) {
+ t.Parallel()
+
+ b := &builder{}
+
+ one := b.walk("first", _testWalk)
+ two := b.walk("second", _testWalk)
+
+ assert.Equal(t, "first", one.label)
+ require.NotNil(t, one.value)
+
+ // the bucket reads its walk, which starts where the params say.
+ assert.Equal(t, _testWalk.Start, one.value(0))
+
+ // same params, different seed, so the two diverge once they move.
+ assert.NotEqual(t, one.value(100), two.value(100))
+}
+
+func Test_builder_deploys(t *testing.T) {
+ t.Parallel()
+
+ bb := (&builder{}).deploys()
+ require.Len(t, bb, 2)
+
+ assert.Equal(t, "attempted", bb[0].label)
+ assert.Equal(t, "successful", bb[1].label)
+
+ // the successful bucket is a share of the attempted one, so a chart
+ // of both never shows more deploys surviving than were tried.
+ for tick := range int64(500) {
+ attempted := bb[0].value(tick)
+ successful := bb[1].value(tick)
+
+ assert.GreaterOrEqual(t, successful, 0.0)
+ assert.LessOrEqual(t, successful, attempted, "tick %d", tick)
+ }
+}
+
+func Test_newRegistry(t *testing.T) {
+ t.Parallel()
+
+ ff := newRegistry()
+ require.Len(t, ff, 15)
+
+ var (
+ names = map[string]struct{}{}
+ seen = map[string]float64{}
+ buckets int
+ )
+
+ for _, f := range ff {
+ // every family is namespaced, described, and populated.
+ assert.True(t, strings.HasPrefix(f.name, _namespace), f.name)
+ assert.NotEmpty(t, f.help, f.name)
+ require.NotEmpty(t, f.buckets, f.name)
+
+ _, dup := names[f.name]
+ assert.False(t, dup, "duplicate family %q", f.name)
+ names[f.name] = struct{}{}
+
+ bucketLabels := map[string]struct{}{}
+
+ for _, b := range f.buckets {
+ buckets++
+
+ require.NotNil(t, b.value, f.name)
+
+ // a family either breaks down by a label or publishes one
+ // unlabelled series; a bucket never disagrees with its family.
+ if f.label == "" {
+ assert.Empty(t, b.label, f.name)
+ assert.Len(t, f.buckets, 1, f.name)
+ } else {
+ assert.NotEmpty(t, b.label, f.name)
+ }
+
+ _, dup := bucketLabels[b.label]
+ assert.False(t, dup, "duplicate bucket %q in %q", b.label, f.name)
+ bucketLabels[b.label] = struct{}{}
+
+ // every series reads, and no two read alike — a seed shared by
+ // accident would show up here as two identical lines.
+ v := b.value(1_000)
+ key := f.name + "/" + b.label
+
+ for other, ov := range seen {
+ assert.NotEqual(t, ov, v, "%s and %s read alike", other, key)
+ }
+
+ seen[key] = v
+ }
+ }
+
+ assert.Len(t, seen, buckets)
+}
+
+func Test_registry_isCachedPerClient(t *testing.T) {
+ t.Parallel()
+
+ // the client holds one registry for its lifetime: the walks behind it
+ // cache the history they replay, and rebuilding it per query would
+ // walk every tick since the epoch again.
+ c := NewClient()
+
+ first := c.registry
+ require.NotEmpty(t, first)
+
+ assert.Equal(t, first[0].buckets[0].value(7), c.registry[0].buckets[0].value(7))
+
+ // a separate client is a separate cache, reading the same history.
+ other := NewClient()
+ require.Len(t, other.registry, len(first))
+
+ for i := range first {
+ assert.Equal(t, first[i].name, other.registry[i].name)
+ assert.Equal(t, first[i].buckets[0].value(7), other.registry[i].buckets[0].value(7))
+ }
+}
+
+// nameMatcher builds a matcher selecting one metric by name, failing
+// the test if it cannot.
+func nameMatcher(t *testing.T, name string) *labels.Matcher {
+ t.Helper()
+
+ m, err := labels.NewMatcher(labels.MatchEqual, model.MetricNameLabel, name)
+ require.NoError(t, err)
+
+ return m
+}
+
+func Test_seriesLabels(t *testing.T) {
+ t.Parallel()
+
+ // a labelled family carries its breakdown alongside the metric name.
+ lbls := seriesLabels(
+ family{name: "metric", label: "colour"},
+ bucket{label: "red"},
+ )
+
+ assert.Equal(t, "metric", lbls.Get(model.MetricNameLabel))
+ assert.Equal(t, "red", lbls.Get("colour"))
+ assert.Equal(t, 2, lbls.Len())
+
+ // an unlabelled one carries nothing but the name — not an empty
+ // label, which would show up in autocomplete as a real dimension.
+ lbls = seriesLabels(family{name: "metric"}, bucket{})
+
+ assert.Equal(t, "metric", lbls.Get(model.MetricNameLabel))
+ assert.Equal(t, 1, lbls.Len())
+}
+
+func Test_matches(t *testing.T) {
+ t.Parallel()
+
+ lbls := labels.FromStrings(model.MetricNameLabel, "metric", "colour", "red")
+
+ cc := map[string]struct {
+ Matchers []*labels.Matcher
+ Result bool
+ }{
+ "No matchers at all": {Result: true},
+ "Matching name": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "metric"}}, Result: true},
+ "Other name": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "nope"}}},
+ "Matching label": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "colour", Value: "red"}}, Result: true},
+ "Other label value": {Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "colour", Value: "blue"}}},
+ "Absent label matched empty": {
+ Matchers: []*labels.Matcher{{Type: labels.MatchEqual, Name: "size", Value: ""}},
+ Result: true,
+ },
+ "One of several fails": {
+ Matchers: []*labels.Matcher{
+ {Type: labels.MatchEqual, Name: model.MetricNameLabel, Value: "metric"},
+ {Type: labels.MatchEqual, Name: "colour", Value: "blue"},
+ },
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, matches(lbls, c.Matchers))
+ })
+ }
+}
+
+func Test_registry_forEach(t *testing.T) {
+ t.Parallel()
+
+ // no matchers reaches every series the registry declares.
+ var all int
+
+ newRegistry().forEach(nil, func(labels.Labels, func(int64) float64) { all++ })
+
+ var buckets int
+
+ for _, f := range newRegistry() {
+ buckets += len(f.buckets)
+ }
+
+ assert.Equal(t, buckets, all)
+
+ // a matcher narrows to the family it names, and every series handed
+ // over carries a reading.
+ var named int
+
+ newRegistry().forEach(
+ []*labels.Matcher{nameMatcher(t, _namespace+"deploy_confidence_index")},
+ func(lbls labels.Labels, value func(int64) float64) {
+ named++
+
+ assert.Equal(t, _namespace+"deploy_confidence_index", lbls.Get(model.MetricNameLabel))
+ require.NotNil(t, value)
+ assert.NotZero(t, value(500))
+ },
+ )
+
+ assert.Equal(t, 3, named)
+
+ // a matcher naming nothing reaches nothing.
+ newRegistry().forEach(
+ []*labels.Matcher{nameMatcher(t, "not_a_metric")},
+ func(labels.Labels, func(int64) float64) { t.Fatal("matched a series that does not exist") },
+ )
+}
+
+func Test_sortedKeys(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, []string{"a", "b", "c"}, sortedKeys(map[string]struct{}{
+ "c": {},
+ "a": {},
+ "b": {},
+ }))
+
+ // an empty set answers an empty slice rather than nil, so a JSON
+ // response carries [] instead of null.
+ assert.Equal(t, []string{}, sortedKeys(map[string]struct{}{}))
+}
+
+func Test_registry_labelNames(t *testing.T) {
+ t.Parallel()
+
+ names := newRegistry().labelNames([][]*labels.Matcher{nil})
+
+ assert.Contains(t, names, model.MetricNameLabel)
+ assert.Contains(t, names, "vibe")
+ assert.Contains(t, names, "severity")
+ assert.IsIncreasing(t, names)
+
+ // narrowing to one family leaves only that family's dimension.
+ names = newRegistry().labelNames([][]*labels.Matcher{
+ {nameMatcher(t, _namespace+"deploy_confidence_index")},
+ })
+
+ assert.Equal(t, []string{model.MetricNameLabel, "vibe"}, names)
+
+ // several selectors are unioned, the way the endpoint answers when
+ // handed more than one.
+ names = newRegistry().labelNames([][]*labels.Matcher{
+ {nameMatcher(t, _namespace+"deploy_confidence_index")},
+ {nameMatcher(t, _namespace+"secrets_detected_commits_count")},
+ })
+
+ assert.Equal(t, []string{model.MetricNameLabel, "severity", "vibe"}, names)
+}
+
+func Test_registry_labelValues(t *testing.T) {
+ t.Parallel()
+
+ // the metric names are a label value like any other, which is what
+ // the editor's metric autocomplete reads.
+ names := newRegistry().labelValues(model.MetricNameLabel, [][]*labels.Matcher{nil})
+
+ assert.Len(t, names, 15)
+ assert.Contains(t, names, _namespace+"pizza_fridays_next_30_days")
+ assert.IsIncreasing(t, names)
+
+ assert.Equal(
+ t,
+ []string{"feeling_lucky", "nervous", "oncall_already_paged"},
+ newRegistry().labelValues("vibe", [][]*labels.Matcher{nil}),
+ )
+
+ // a label no series carries answers empty rather than a blank value.
+ assert.Empty(t, newRegistry().labelValues("nonexistent", [][]*labels.Matcher{nil}))
+}
diff --git a/server/core/internal/datasource/demo/series.go b/server/core/internal/datasource/demo/series.go
new file mode 100644
index 00000000..36cb3549
--- /dev/null
+++ b/server/core/internal/datasource/demo/series.go
@@ -0,0 +1,215 @@
+package demo
+
+import (
+ "math"
+ "math/rand"
+ "sync"
+ "time"
+
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
+)
+
+// _tick is the interval between two demo samples.
+const _tick = time.Minute
+
+// _tickMillis is that same interval in the milliseconds the storage layer
+// counts in.
+const _tickMillis = int64(_tick / time.Millisecond)
+
+// _segment is how many ticks one replay segment spans. A sample replays
+// at most this many steps from the checkpoint before it, which is what
+// keeps a query over a year from walking every tick since the epoch.
+const _segment = 1440
+
+// _seedStride separates the random streams of two series whose segment
+// numbers differ by one, so no two streams overlap.
+const _seedStride = 1_000_003
+
+// _epoch is the instant the demo timeline starts. It is fixed so that a
+// tick always denotes the same moment, on every install and every run.
+var _epoch = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// tickAt reports which tick covers the given instant.
+func tickAt(t time.Time) int64 {
+ return int64(t.Sub(_epoch) / _tick)
+}
+
+// tickAtMillis reports which tick covers the given millisecond instant.
+func tickAtMillis(ms int64) int64 {
+ return (ms - _epoch.UnixMilli()) / _tickMillis
+}
+
+// timeAt reports the instant the given tick starts at.
+func timeAt(tick int64) time.Time {
+ return _epoch.Add(time.Duration(tick) * _tick)
+}
+
+// latestTick reports the last tick the timeline has reached. Nothing past
+// it is ever sampled: the demo has no more happened in the future than a
+// real deployment has.
+func latestTick() int64 {
+ return tickAt(timeutil.Now())
+}
+
+// newRand returns the random stream the given seed and stream number
+// name. Every segment of every series draws from its own stream, so a
+// value never depends on which ticks were read before it.
+func newRand(seed, stream int64) *rand.Rand {
+ //nolint:gosec // demo data wants a cheap reproducible source, not a cryptographic one
+ return rand.New(rand.NewSource(seed*_seedStride + stream))
+}
+
+// normal returns a sample from N(mean, stddev) via the Box-Muller
+// transform.
+func normal(r *rand.Rand, mean, stddev float64) float64 {
+ u1 := r.Float64()
+ u2 := r.Float64()
+ z := math.Sqrt(-2.0*math.Log(u1)) * math.Cos(2.0*math.Pi*u2)
+
+ return mean + stddev*z
+}
+
+// clamp returns x clamped to [lo, hi].
+func clamp(x, lo, hi float64) float64 {
+ if x < lo {
+ return lo
+ }
+
+ if x > hi {
+ return hi
+ }
+
+ return x
+}
+
+// countAt returns the count the given bucket reports at the given tick.
+// A count is resampled from scratch every tick, so it needs no history.
+func countAt(seed, tick int64, mean, relStdDev float64) float64 {
+ v := math.Round(normal(newRand(seed, tick), mean, mean*relStdDev))
+ if v < 0 {
+ return 0
+ }
+
+ return v
+}
+
+// walkParams configures a gauge that evolves each tick via drift and mean
+// reversion.
+type walkParams struct {
+ // Min is the lower bound of the gauge value.
+ Min float64
+
+ // Max is the upper bound of the gauge value.
+ Max float64
+
+ // Start is the value the walk begins at, at the epoch.
+ Start float64
+
+ // DriftPerStep is a constant added each tick, modelling a slow
+ // linear trend. Mean reversion absorbs it, so the walk settles
+ // around Target+DriftPerStep/MeanReversion rather than running away.
+ DriftPerStep float64
+
+ // NoiseStdDev is the standard deviation of the Gaussian noise added
+ // each tick.
+ NoiseStdDev float64
+
+ // SpikeChance is the probability (0–1) of an additional spike
+ // occurring on any given tick.
+ SpikeChance float64
+
+ // SpikeStdDev is the standard deviation of the spike magnitude when
+ // one occurs.
+ SpikeStdDev float64
+
+ // MeanReversion is the fraction (0–1) of the distance to Target
+ // pulled back each tick.
+ MeanReversion float64
+
+ // Target is the attractor value for mean reversion.
+ Target float64
+}
+
+// walk replays a mean-reverting random walk. The value at a tick depends
+// only on the walk's seed and the tick's distance from the epoch, never on
+// which ticks were read before it — so every query sees the same history,
+// and a line already drawn never rewrites itself.
+type walk struct {
+ // params govern the walk's drift, noise and mean reversion.
+ params walkParams
+
+ // seed distinguishes this walk's random streams from every other
+ // series'.
+ seed int64
+
+ // mu guards checkpoints.
+ mu sync.Mutex
+
+ // checkpoints holds the walk's value at the start of each segment,
+ // grown on demand. Index i is the value at tick i*_segment.
+ checkpoints []float64
+}
+
+// newWalk creates a fresh instance of walk.
+func newWalk(seed int64, params walkParams) *walk {
+ return &walk{
+ params: params,
+ seed: seed,
+ }
+}
+
+// at returns the walk's value at the given tick.
+func (w *walk) at(tick int64) float64 {
+ if tick < 0 {
+ tick = 0
+ }
+
+ segment := tick / _segment
+
+ return replay(
+ w.checkpoint(segment),
+ w.params,
+ newRand(w.seed, segment),
+ int(tick%_segment),
+ )
+}
+
+// checkpoint returns the walk's value at the start of the given segment,
+// computing and caching every segment before it that is not cached yet.
+func (w *walk) checkpoint(segment int64) float64 {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if len(w.checkpoints) == 0 {
+ w.checkpoints = []float64{clamp(w.params.Start, w.params.Min, w.params.Max)}
+ }
+
+ for int64(len(w.checkpoints)) <= segment {
+ last := int64(len(w.checkpoints)) - 1
+
+ w.checkpoints = append(w.checkpoints, replay(
+ w.checkpoints[last],
+ w.params,
+ newRand(w.seed, last),
+ _segment,
+ ))
+ }
+
+ return w.checkpoints[segment]
+}
+
+// replay advances v by the given number of the walk's ticks.
+func replay(v float64, p walkParams, r *rand.Rand, steps int) float64 {
+ for range steps {
+ v += p.MeanReversion * (p.Target - v)
+ v += p.DriftPerStep + normal(r, 0, p.NoiseStdDev)
+
+ if r.Float64() < p.SpikeChance {
+ v += normal(r, 0, p.SpikeStdDev)
+ }
+
+ v = clamp(v, p.Min, p.Max)
+ }
+
+ return v
+}
diff --git a/server/core/internal/datasource/demo/series_test.go b/server/core/internal/datasource/demo/series_test.go
new file mode 100644
index 00000000..3a71d132
--- /dev/null
+++ b/server/core/internal/datasource/demo/series_test.go
@@ -0,0 +1,283 @@
+package demo
+
+import (
+ "math"
+ "testing"
+ "time"
+
+ "github.com/oxynote/oxynote/server/core/pkg/timeutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// _testWalk is a walk whose bounds are wide enough that clamping never
+// hides a mistake in the replay itself.
+var _testWalk = walkParams{
+ Min: 0,
+ Max: 1_000,
+ Start: 100,
+ Target: 120,
+ MeanReversion: 0.05,
+ NoiseStdDev: 5,
+ DriftPerStep: 0.1,
+ SpikeChance: 0.05,
+ SpikeStdDev: 20,
+}
+
+func Test_tickAt(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Time time.Time
+ Result int64
+ }{
+ "The epoch itself": {Time: _epoch, Result: 0},
+ "Part way into a tick": {Time: _epoch.Add(30 * time.Second), Result: 0},
+ "The next tick": {Time: _epoch.Add(time.Minute), Result: 1},
+ "An hour in": {Time: _epoch.Add(time.Hour), Result: 60},
+ "A day in": {Time: _epoch.Add(24 * time.Hour), Result: _segment},
+ "Before the epoch": {
+ Time: _epoch.Add(-time.Hour),
+ Result: -60,
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, tickAt(c.Time))
+ })
+ }
+}
+
+func Test_tickAtMillis(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ Millis int64
+ Result int64
+ }{
+ "The epoch itself": {Millis: _epoch.UnixMilli(), Result: 0},
+ "Part way into a tick": {Millis: _epoch.UnixMilli() + 30_000, Result: 0},
+ "The next tick": {Millis: _epoch.UnixMilli() + 60_000, Result: 1},
+ "A day in": {Millis: _epoch.Add(24 * time.Hour).UnixMilli(), Result: _segment},
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, tickAtMillis(c.Millis))
+ })
+ }
+}
+
+func Test_timeAt(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, _epoch, timeAt(0))
+ assert.Equal(t, _epoch.Add(time.Minute), timeAt(1))
+
+ // the two directions agree, which is what lets a sample timestamp be
+ // turned back into the tick that produced its value.
+ for _, tick := range []int64{0, 1, 59, _segment, 1_000_000} {
+ assert.Equal(t, tick, tickAt(timeAt(tick)))
+ }
+}
+
+func Test_latestTick(t *testing.T) {
+ t.Parallel()
+
+ got := latestTick()
+
+ // nothing past the present is ever sampled, and the timeline has
+ // reached the present.
+ assert.Equal(t, tickAt(timeutil.Now()), got)
+ assert.Positive(t, got)
+ assert.False(t, timeAt(got).After(timeutil.Now()))
+}
+
+func Test_newRand(t *testing.T) {
+ t.Parallel()
+
+ // the same seed and stream replay the same numbers, which is the
+ // whole basis of a frozen history.
+ assert.Equal(t, newRand(1, 2).Float64(), newRand(1, 2).Float64())
+
+ // a different seed or a different stream is a different history.
+ assert.NotEqual(t, newRand(1, 2).Float64(), newRand(2, 2).Float64())
+ assert.NotEqual(t, newRand(1, 2).Float64(), newRand(1, 3).Float64())
+
+ // neighbouring series do not overlap: seed 1's stream 0 is not seed
+ // 0's stream _seedStride read one segment later.
+ assert.NotEqual(t, newRand(1, 0).Float64(), newRand(0, 1).Float64())
+}
+
+func Test_normal(t *testing.T) {
+ t.Parallel()
+
+ // with no spread every draw is the mean.
+ assert.InDelta(t, 7.0, normal(newRand(1, 1), 7, 0), 1e-9)
+
+ // the same stream draws the same number twice.
+ assert.Equal(t, normal(newRand(1, 1), 0, 1), normal(newRand(1, 1), 0, 1))
+
+ // over many draws the sample mean lands near the stated mean.
+ r := newRand(3, 3)
+ sum := 0.0
+
+ for range 10_000 {
+ sum += normal(r, 5, 2)
+ }
+
+ assert.InDelta(t, 5.0, sum/10_000, 0.1)
+}
+
+func Test_clamp(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ X float64
+ Lo float64
+ Hi float64
+ Result float64
+ }{
+ "Within the bounds": {X: 5, Lo: 0, Hi: 10, Result: 5},
+ "Below the floor": {X: -1, Lo: 0, Hi: 10, Result: 0},
+ "Above the ceiling": {X: 11, Lo: 0, Hi: 10, Result: 10},
+ "On the floor": {X: 0, Lo: 0, Hi: 10, Result: 0},
+ "On the ceiling": {X: 10, Lo: 0, Hi: 10, Result: 10},
+ "Infinite": {X: math.Inf(1), Lo: 0, Hi: 10, Result: 10},
+ "Negative infinite": {X: math.Inf(-1), Lo: 0, Hi: 10, Result: 0},
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, c.Result, clamp(c.X, c.Lo, c.Hi))
+ })
+ }
+}
+
+func Test_countAt(t *testing.T) {
+ t.Parallel()
+
+ // a count depends on its tick alone, so reading it again answers the
+ // same and reading a neighbour does not.
+ assert.Equal(t, countAt(1, 10, 1_000, 0.15), countAt(1, 10, 1_000, 0.15))
+ assert.NotEqual(t, countAt(1, 10, 1_000, 0.15), countAt(1, 11, 1_000, 0.15))
+ assert.NotEqual(t, countAt(1, 10, 1_000, 0.15), countAt(2, 10, 1_000, 0.15))
+
+ // counts are whole and never negative, however wide the spread.
+ for tick := range int64(500) {
+ v := countAt(9, tick, 4, 5)
+
+ assert.GreaterOrEqual(t, v, 0.0)
+ assert.Equal(t, math.Trunc(v), v)
+ }
+
+ // across many ticks the average lands near the stated mean.
+ sum := 0.0
+
+ for tick := range int64(5_000) {
+ sum += countAt(4, tick, 2_000, 0.15)
+ }
+
+ assert.InDelta(t, 2_000.0, sum/5_000, 50)
+}
+
+func Test_replay(t *testing.T) {
+ t.Parallel()
+
+ // no steps is no movement.
+ assert.Equal(t, 100.0, replay(100, _testWalk, newRand(1, 1), 0))
+
+ // the same start and stream replay to the same value.
+ assert.Equal(
+ t,
+ replay(100, _testWalk, newRand(1, 1), 50),
+ replay(100, _testWalk, newRand(1, 1), 50),
+ )
+
+ // every step clamps, so a walk started far outside its bounds is
+ // inside them again after one.
+ p := _testWalk
+ p.Min = 10
+ p.Max = 20
+
+ assert.LessOrEqual(t, replay(1_000, p, newRand(1, 1), 1), 20.0)
+ assert.GreaterOrEqual(t, replay(-1_000, p, newRand(1, 1), 1), 10.0)
+}
+
+func Test_newWalk(t *testing.T) {
+ t.Parallel()
+
+ w := newWalk(42, _testWalk)
+ require.NotNil(t, w)
+
+ assert.Equal(t, int64(42), w.seed)
+ assert.Equal(t, _testWalk, w.params)
+
+ // nothing is computed until something is asked for.
+ assert.Empty(t, w.checkpoints)
+}
+
+func Test_walk_at(t *testing.T) {
+ t.Parallel()
+
+ // two walks built from the same seed answer identically, which is
+ // what makes the history the same on every install rather than only
+ // within one process.
+ a, b := newWalk(5, _testWalk), newWalk(5, _testWalk)
+
+ for _, tick := range []int64{0, 1, 100, _segment - 1, _segment, _segment + 1, 3*_segment + 7} {
+ assert.Equal(t, a.at(tick), b.at(tick), "tick %d", tick)
+ }
+
+ // reading out of order answers the same as reading in order: a value
+ // depends on its tick, not on what was read before it.
+ c := newWalk(5, _testWalk)
+ assert.Equal(t, a.at(2*_segment+3), c.at(2*_segment+3))
+
+ // the walk starts where it was told to and stays inside its bounds.
+ assert.Equal(t, _testWalk.Start, a.at(0))
+
+ for _, tick := range []int64{1, 500, _segment, 5_000} {
+ assert.GreaterOrEqual(t, a.at(tick), _testWalk.Min)
+ assert.LessOrEqual(t, a.at(tick), _testWalk.Max)
+ }
+
+ // a tick before the epoch reads as the epoch rather than running the
+ // walk backwards.
+ assert.Equal(t, a.at(0), a.at(-10))
+
+ // a segment boundary is continuous: the first tick of a segment is
+ // the value the previous segment ended on.
+ assert.Equal(t, a.checkpoint(1), a.at(_segment))
+}
+
+func Test_walk_checkpoint(t *testing.T) {
+ t.Parallel()
+
+ w := newWalk(11, _testWalk)
+
+ // the first checkpoint is the start value, clamped.
+ assert.Equal(t, _testWalk.Start, w.checkpoint(0))
+ assert.Len(t, w.checkpoints, 1)
+
+ // asking for a later one fills in every segment before it, once.
+ assert.NotZero(t, w.checkpoint(3))
+ assert.Len(t, w.checkpoints, 4)
+
+ cached := w.checkpoints[3]
+ assert.Equal(t, cached, w.checkpoint(3))
+ assert.Len(t, w.checkpoints, 4)
+
+ // a start outside the bounds is pulled inside before anything runs.
+ p := _testWalk
+ p.Start = 5_000
+
+ assert.Equal(t, p.Max, newWalk(12, p).checkpoint(0))
+}
diff --git a/server/core/internal/datasource/processor/postgresql.go b/server/core/internal/datasource/processor/postgresql.go
index 20c0e0c0..770c800b 100644
--- a/server/core/internal/datasource/processor/postgresql.go
+++ b/server/core/internal/datasource/processor/postgresql.go
@@ -27,6 +27,20 @@ const (
_queryPayloadLimit = 5 * 1024 * 1024 // 5 MB
)
+// _pgAllowedConnKeys lists the connection-string keys a data-source URL may
+// carry. The URL is organisation-supplied, and keys outside this list can
+// reach the local filesystem (sslkey, passfile, servicefile, ...) or override
+// session behavior (options), so parsing fails closed on anything else.
+var _pgAllowedConnKeys = []string{
+ "host",
+ "port",
+ "dbname",
+ "user",
+ "password",
+ "sslmode",
+ "connect_timeout",
+}
+
// PostgreSQL represents a PostgreSQL data source processor.
type PostgreSQL struct {
inp Input
@@ -261,7 +275,9 @@ func (p *PostgreSQL) connect(ctx context.Context) (*pgx.Conn, error) {
return nil, err
}
- cfg, err := pgx.ParseConfig(connStr)
+ cfg, err := pgx.ParseConfigWithOptions(connStr, pgx.ParseConfigOptions{
+ ConnStringAllowedKeys: _pgAllowedConnKeys,
+ })
if err != nil {
return nil, fmt.Errorf("error parsing postgresql connection string: %w", err)
}
diff --git a/server/core/internal/datasource/processor/postgresql_test.go b/server/core/internal/datasource/processor/postgresql_test.go
index d221f897..6a160a1a 100644
--- a/server/core/internal/datasource/processor/postgresql_test.go
+++ b/server/core/internal/datasource/processor/postgresql_test.go
@@ -693,9 +693,10 @@ func Test_PostgreSQL_connect(t *testing.T) {
_, err = p.connect(context.Background())
assert.Error(t, err)
- // the session refuses writes, and the simple protocol cannot be
+ // the session refuses writes, and connection-string keys outside the
+ // allowlist are rejected at parse, so the simple protocol cannot be
// re-enabled through the data source URL to smuggle a second statement
- // past the gate.
+ // past the gate, and the URL cannot reach the local filesystem.
cc := map[string]struct {
URL string
Query string
@@ -708,6 +709,18 @@ func Test_PostgreSQL_connect(t *testing.T) {
URL: pgTestURL(_pgUser, _pgPass) + "&default_query_exec_mode=simple_protocol",
Query: "SET transaction_read_only = off; DELETE FROM metrics",
},
+ "Disallowed sslkey key": {
+ URL: pgTestURL(_pgUser, _pgPass) + "&sslkey=/tmp/key",
+ Query: "SELECT time FROM metrics",
+ },
+ "Disallowed servicefile key": {
+ URL: pgTestURL(_pgUser, _pgPass) + "&servicefile=/tmp/service",
+ Query: "SELECT time FROM metrics",
+ },
+ "Disallowed options key": {
+ URL: pgTestURL(_pgUser, _pgPass) + "&options=-csearch_path%3Dpublic",
+ Query: "SELECT time FROM metrics",
+ },
}
for cn, c := range cc {
@@ -721,7 +734,12 @@ func Test_PostgreSQL_connect(t *testing.T) {
_, err := p.Query(context.Background(), c.Query, _testTimeRange)
assert.Error(t, err)
- // the rows are still there.
+ // the rows are still there, checked over a clean URL since the
+ // case's own URL may be the thing being rejected.
+ p = NewPostgreSQL(&InputMock{
+ URLFunc: func() string { return pgTestURL(_pgUser, _pgPass) },
+ })
+
res, err := p.Query(context.Background(), "SELECT time FROM metrics ORDER BY time", _testTimeRange)
require.NoError(t, err)
assert.Len(t, res.Rows, 2)
diff --git a/server/core/internal/datasource/processor/prometheus.go b/server/core/internal/datasource/processor/prometheus.go
index c88280f4..3504fc24 100644
--- a/server/core/internal/datasource/processor/prometheus.go
+++ b/server/core/internal/datasource/processor/prometheus.go
@@ -141,9 +141,14 @@ func (p *Prometheus) LabelNames(ctx context.Context, matchers []string, tr TimeR
return nil, fmt.Errorf("error fetching label names: %w", err)
}
+ result := make([]string, len(names))
+ for i, n := range names {
+ result[i] = string(n)
+ }
+
return &PrometheusLabelNamesResult{
Warnings: warns,
- Result: names,
+ Result: result,
}, nil
}
diff --git a/server/core/internal/datasource/runner.go b/server/core/internal/datasource/runner.go
index b797c79d..1cd7cec4 100644
--- a/server/core/internal/datasource/runner.go
+++ b/server/core/internal/datasource/runner.go
@@ -6,7 +6,9 @@ import (
"log/slog"
"net/http"
"slices"
+ "strings"
+ "github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/rs/xid"
@@ -21,6 +23,7 @@ import (
type Manager struct {
log *slog.Logger
store StatusStore
+ demo *demo.Client
}
// NewManager creates a fresh instance of Manager.
@@ -28,12 +31,13 @@ func NewManager(log *slog.Logger, store StatusStore) *Manager {
return &Manager{
log: log.With("component", "datasource"),
store: store,
+ demo: demo.NewClient(),
}
}
// Runner returns the runner that operates the given data source.
func (m *Manager) Runner(ds DataSource) Runner {
- return &runner{ds: ds, log: m.log, store: m.store}
+ return &runner{ds: ds, log: m.log, store: m.store, demo: m.demo}
}
// runner operates one data source.
@@ -54,6 +58,10 @@ type runner struct {
// client is the processor this runner's type resolves to.
client connectionTester
+
+ // demo answers the data source this process synthesizes, shared with
+ // every other runner the manager hands out.
+ demo *demo.Client
}
// Type reports what the data source speaks.
@@ -172,7 +180,12 @@ func (r *runner) ensurePrepared() error {
switch r.ds.Type {
case TypePrometheus:
- r.client = processor.NewPrometheus(newStateInput(r.ds.URL, r.ds.Credentials))
+ client, err := r.prometheusClient()
+ if err != nil {
+ return err
+ }
+
+ r.client = client
case TypePostgreSQL:
r.client = processor.NewPostgreSQL(newStateInput(r.ds.URL, r.ds.Credentials))
case TypeMariaDB, TypeMySQL:
@@ -186,6 +199,22 @@ func (r *runner) ensurePrepared() error {
return nil
}
+// prometheusClient resolves which Prometheus a data source speaks to. The
+// demo scheme names a source this process synthesizes rather than one it
+// dials, and only the one demo source exists — a URL that merely looks
+// like it is a mistake worth reporting, not a second demo.
+func (r *runner) prometheusClient() (connectionTester, error) {
+ if r.ds.URL == demo.URL {
+ return r.demo, nil
+ }
+
+ if strings.HasPrefix(r.ds.URL, demo.Scheme) {
+ return nil, demo.ErrUnknownSource
+ }
+
+ return processor.NewPrometheus(newStateInput(r.ds.URL, r.ds.Credentials)), nil
+}
+
// Runner operates one data source: it hands out the typed client for
// the operation being performed, or the reason it cannot.
//
diff --git a/server/core/internal/datasource/runner_test.go b/server/core/internal/datasource/runner_test.go
index 3e0cbfd9..6e02a8e6 100644
--- a/server/core/internal/datasource/runner_test.go
+++ b/server/core/internal/datasource/runner_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"time"
+ "github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/prometheus/client_golang/api"
@@ -394,20 +395,23 @@ func Test_runner_ensurePrepared(t *testing.T) {
cc := map[string]struct {
Type Type
+ URL string
Err error
}{
- "Prometheus": {Type: TypePrometheus},
- "PostgreSQL": {Type: TypePostgreSQL},
- "MySQL": {Type: TypeMySQL},
- "MariaDB": {Type: TypeMariaDB},
- "Unknown type": {Type: Type("bogus"), Err: assert.AnError},
+ "Prometheus": {Type: TypePrometheus},
+ "Demo Prometheus": {Type: TypePrometheus, URL: demo.URL},
+ "Unknown demo source": {Type: TypePrometheus, URL: demo.Scheme + "nope", Err: demo.ErrUnknownSource},
+ "PostgreSQL": {Type: TypePostgreSQL},
+ "MySQL": {Type: TypeMySQL},
+ "MariaDB": {Type: TypeMariaDB},
+ "Unknown type": {Type: Type("bogus"), Err: assert.AnError},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- r := prepRunner(c.Type, "", nil)
+ r := prepRunner(c.Type, c.URL, nil)
err := r.ensurePrepared()
@@ -428,3 +432,46 @@ func Test_runner_ensurePrepared(t *testing.T) {
})
}
}
+
+func Test_runner_prometheusClient(t *testing.T) {
+ t.Parallel()
+
+ cc := map[string]struct {
+ URL string
+ Client connectionTester
+ Err error
+ }{
+ "Demo data source": {
+ URL: demo.URL,
+ Client: &demo.Client{},
+ },
+ "Demo scheme naming a source that does not exist": {
+ URL: demo.Scheme + "marketing",
+ Err: demo.ErrUnknownSource,
+ },
+ "Demo scheme with no source at all": {
+ URL: demo.Scheme,
+ Err: demo.ErrUnknownSource,
+ },
+ "Real server": {
+ URL: "http://prom.test:9090",
+ Client: &processor.Prometheus{},
+ },
+ }
+
+ for cn, c := range cc {
+ t.Run(cn, func(t *testing.T) {
+ t.Parallel()
+
+ client, err := prepRunner(TypePrometheus, c.URL, nil).prometheusClient()
+ testutil.AssertEqualError(t, c.Err, err)
+
+ if err != nil {
+ assert.Nil(t, client)
+ return
+ }
+
+ assert.IsType(t, c.Client, client)
+ })
+ }
+}
diff --git a/server/core/internal/server/internal/github/http.go b/server/core/internal/server/internal/github/http.go
index 37582519..253a6f04 100644
--- a/server/core/internal/server/internal/github/http.go
+++ b/server/core/internal/server/internal/github/http.go
@@ -7,7 +7,7 @@ import (
"net/http"
"strconv"
- gogithub "github.com/google/go-github/v72/github"
+ gogithub "github.com/google/go-github/v88/github"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/server/internal/apps"
diff --git a/server/core/internal/server/internal/org/http.go b/server/core/internal/server/internal/org/http.go
index 4baa9592..8f7df82b 100644
--- a/server/core/internal/server/internal/org/http.go
+++ b/server/core/internal/server/internal/org/http.go
@@ -12,6 +12,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
"github.com/oxynote/oxynote/server/core/internal/datasource"
+ "github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/search"
@@ -32,14 +33,13 @@ var ErrNoOrganizationMembers = errutil.New(http.StatusBadRequest, "organization.
// Handler holds dependencies required for organization-related operations.
type Handler struct {
- log *slog.Logger
- db DB
- storer Storer
- githubMan *github.Manager
- webchangeClient *webchange.Client
- searchJobs *search.Jobs
- logoLocation string
- demoPrometheusURL string
+ log *slog.Logger
+ db DB
+ storer Storer
+ githubMan *github.Manager
+ webchangeClient *webchange.Client
+ searchJobs *search.Jobs
+ logoLocation string
}
// NewHandler creates a new handler instance with the provided logger and database.
@@ -51,17 +51,15 @@ func NewHandler(
webchangeClient *webchange.Client,
searchJobs *search.Jobs,
logoLocationFormat string,
- demoPrometheusURL string,
) *Handler {
return &Handler{
- log: log,
- db: db,
- storer: storer,
- githubMan: githubMan,
- webchangeClient: webchangeClient,
- searchJobs: searchJobs,
- logoLocation: logoLocationFormat,
- demoPrometheusURL: demoPrometheusURL,
+ log: log,
+ db: db,
+ storer: storer,
+ githubMan: githubMan,
+ webchangeClient: webchangeClient,
+ searchJobs: searchJobs,
+ logoLocation: logoLocationFormat,
}
}
@@ -73,19 +71,6 @@ func (h *Handler) InitializeOrganization(w http.ResponseWriter, r *http.Request)
return
}
- if h.demoPrometheusURL == "" {
- h.log.Warn("skipping initial demo data source insertion since no URL is configured")
-
- httpserver.Respond(
- h.log,
- w,
- nil,
- http.StatusOK,
- )
-
- return
- }
-
members, err := h.db.FetchOrganizationMembers(r.Context(), id)
if err != nil {
httpserver.RespondError(h.log, w, err)
@@ -318,7 +303,7 @@ func (h *Handler) insertDemoDataSource(ctx context.Context, organizationID strin
ds := datasource.NewDataSource(datasource.CreateInput{
Type: datasource.TypePrometheus,
Name: "Demo",
- URL: h.demoPrometheusURL,
+ URL: demo.URL,
Credentials: []byte(`{}`),
}, organizationID)
diff --git a/server/core/internal/server/internal/org/http_test.go b/server/core/internal/server/internal/org/http_test.go
index b3949b1e..ce5dc102 100644
--- a/server/core/internal/server/internal/org/http_test.go
+++ b/server/core/internal/server/internal/org/http_test.go
@@ -16,6 +16,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
"github.com/oxynote/oxynote/server/core/internal/datasource"
+ "github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/document/hook/processor"
@@ -93,7 +94,6 @@ func Test_NewHandler(t *testing.T) {
webchangeClient,
searchJobs,
"loc",
- "http://prom.test",
)
require.NotNil(t, hdl)
assert.NotNil(t, hdl.log)
@@ -103,7 +103,6 @@ func Test_NewHandler(t *testing.T) {
assert.Same(t, webchangeClient, hdl.webchangeClient)
assert.Same(t, searchJobs, hdl.searchJobs)
assert.Equal(t, "loc", hdl.logoLocation)
- assert.Equal(t, "http://prom.test", hdl.demoPrometheusURL)
}
func Test_Handler_InitializeOrganization(t *testing.T) {
@@ -135,7 +134,7 @@ func Test_Handler_InitializeOrganization(t *testing.T) {
assert.Equal(t, "Demo", ff[0].Ds.Name)
assert.Equal(t, datasource.TypePrometheus, ff[0].Ds.Type)
- assert.Equal(t, "http://prom.test", ff[0].Ds.URL)
+ assert.Equal(t, demo.URL, ff[0].Ds.URL)
assert.Equal(t, "org2", ff[0].Ds.OrganizationID)
}
}
@@ -188,7 +187,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) {
Members []string
MembersErr error
BeginErr error
- NoProm bool
OmitID bool
Checks []check
}{
@@ -197,14 +195,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) {
OmitID: true,
Checks: checks(
hasResp(http.StatusNotFound, `{"code":"general","message":"not found"}`),
- wasInsertDocumentCalled(0),
- ),
- },
- "No demo Prometheus URL configured": {
- Tx: &TxMock{},
- NoProm: true,
- Checks: checks(
- hasResp(http.StatusOK, ""),
wasInsertDataSourceCalled(0),
wasInsertDocumentCalled(0),
),
@@ -341,12 +331,6 @@ func Test_Handler_InitializeOrganization(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
- promURL := "http://prom.test"
-
- if c.NoProm {
- promURL = ""
- }
-
db := c.DB
if db == nil {
db = &DBMock{}
@@ -357,10 +341,9 @@ func Test_Handler_InitializeOrganization(t *testing.T) {
}
hdl := Handler{
- log: slog.New(slog.DiscardHandler),
- db: withTx(db, c.Tx, c.BeginErr),
- searchJobs: search.NewJobs(true),
- demoPrometheusURL: promURL,
+ log: slog.New(slog.DiscardHandler),
+ db: withTx(db, c.Tx, c.BeginErr),
+ searchJobs: search.NewJobs(true),
}
req := httptest.NewRequest(http.MethodPost, "http://test.com/", http.NoBody)
diff --git a/server/core/internal/server/internal/slack/event.go b/server/core/internal/server/internal/slack/event.go
index 966bc7d7..789a7aeb 100644
--- a/server/core/internal/server/internal/slack/event.go
+++ b/server/core/internal/server/internal/slack/event.go
@@ -23,7 +23,7 @@ func (h *Handler) handleChannelOpenEvent(ctx context.Context, teamID, userID, ch
return fmt.Errorf("failed to get slack client: %w", err)
}
- user, err := client.GetUserInfo(userID)
+ user, err := client.GetUserInfoContext(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
diff --git a/server/core/internal/server/internal/slack/http.go b/server/core/internal/server/internal/slack/http.go
index db5d1c2e..98513bd1 100644
--- a/server/core/internal/server/internal/slack/http.go
+++ b/server/core/internal/server/internal/slack/http.go
@@ -2,7 +2,6 @@
package slack
import (
- "bytes"
"context"
"encoding/json"
"fmt"
@@ -355,7 +354,7 @@ func (h *Handler) HandleCommand(w http.ResponseWriter, r *http.Request) {
if !app.OrganizationID.Valid { //nolint:nestif // the branching is sequential and readable
var user *goslack.User
- user, err = client.GetUserInfo(payload.User.ID)
+ user, err = client.GetUserInfoContext(r.Context(), payload.User.ID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
@@ -429,35 +428,15 @@ func (h *Handler) HandleCommand(w http.ResponseWriter, r *http.Request) {
// sendEphemeralResponse sends an ephemeral response to a Slack interaction.
func (h *Handler) sendEphemeralResponse(ctx context.Context, responseURL, text string) error {
- body := struct {
- Text string `json:"text"`
- }{
- Text: text,
- }
-
- data, err := json.Marshal(body)
- if err != nil {
- return fmt.Errorf("failed to marshal response body: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, responseURL, bytes.NewBuffer(data))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := h.client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to send response: %w", err)
- }
- defer resp.Body.Close() //nolint:errcheck // error provides no meaningful info
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("received non-OK response: %s", resp.Status)
- }
-
- return nil
+ return goslack.PostWebhookCustomHTTPContext(
+ ctx,
+ responseURL,
+ h.client,
+ &goslack.WebhookMessage{
+ Text: text,
+ ResponseType: goslack.ResponseTypeEphemeral,
+ },
+ )
}
// DB is an interface that handles communication with the document database.
diff --git a/server/core/internal/server/internal/slack/http_test.go b/server/core/internal/server/internal/slack/http_test.go
index 4f1751a5..0849101f 100644
--- a/server/core/internal/server/internal/slack/http_test.go
+++ b/server/core/internal/server/internal/slack/http_test.go
@@ -17,6 +17,7 @@ import (
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/rs/xid"
+ goslack "github.com/slack-go/slack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@@ -124,6 +125,17 @@ func responseServer(t *testing.T) (*httptest.Server, *int) {
return
}
+ var body struct {
+ ResponseType string `json:"response_type"` //nolint:tagliatelle // slack wire format
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil ||
+ body.ResponseType != goslack.ResponseTypeEphemeral {
+ w.WriteHeader(http.StatusBadRequest)
+
+ return
+ }
+
hits++
w.WriteHeader(http.StatusOK)
diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go
index ac8c26e8..837ed526 100644
--- a/server/core/internal/server/router.go
+++ b/server/core/internal/server/router.go
@@ -125,7 +125,7 @@ func (s *Server) internalRouter() chi.Router {
r.Get("/version", s.fetchVersion)
r.Mount("/debug", middleware.Profiler())
r.Handle("/metrics", promhttp.InstrumentMetricHandler(
- s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{}),
+ s.fc, promhttp.HandlerFor(s.fc, promhttp.HandlerOpts{CoalesceGather: true}),
))
r.Post("/organizations/{organizationId}/initialize", s.handlers.organization.InitializeOrganization)
diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go
index b5d5e584..46054c84 100644
--- a/server/core/internal/server/server.go
+++ b/server/core/internal/server/server.go
@@ -48,9 +48,6 @@ type Options struct {
// PublicURL is the public URL of the server.
PublicURL string
- // DemoPrometheusURL is the URL of the demo Prometheus instance.
- DemoPrometheusURL string
-
// Port specifies which port should be used by the server.
Port uint
@@ -172,7 +169,6 @@ func NewServer(
webchangeClient,
searchJobs,
opts.PublicURL+_organizationLogoLocation,
- opts.DemoPrometheusURL,
)
srv.handlers.document = document.NewHandler(log, db, githubMan, webchangeClient, searchGateway, searchJobs, notifier, storageClient)
srv.handlers.comment = comment.NewHandler(log, db, notifier)
diff --git a/server/core/internal/server/server_test.go b/server/core/internal/server/server_test.go
index 961b9f56..0d3fa89b 100644
--- a/server/core/internal/server/server_test.go
+++ b/server/core/internal/server/server_test.go
@@ -155,11 +155,10 @@ func Test_NewServer(t *testing.T) {
srv, err := NewServer(
log,
Options{
- PublicURL: "http://test.com",
- DemoPrometheusURL: "http://prom.test",
- Port: 8080,
- Origins: []string{"http://localhost:3000"},
- Auth: AuthOptions{BetterAuthURL: "http://auth.test"},
+ PublicURL: "http://test.com",
+ Port: 8080,
+ Origins: []string{"http://localhost:3000"},
+ Auth: AuthOptions{BetterAuthURL: "http://auth.test"},
MCP: MCPOptions{
SessionURL: "http://auth.test/api/internal/mcp/session",
ResourceURL: "http://test.com/core/api/mcp",
diff --git a/server/core/internal/storage/client_test.go b/server/core/internal/storage/client_test.go
index d5a8c3dc..3ebfcc9f 100644
--- a/server/core/internal/storage/client_test.go
+++ b/server/core/internal/storage/client_test.go
@@ -161,7 +161,7 @@ func newFakeS3Server(t *testing.T, f *fakeS3) string {
// fakeS3 is a minimal in-memory S3 implementation covering only the
// requests the minio client performs for the storage package: bucket
-// location lookup, bucket creation, multipart upload, object stat,
+// location lookup, bucket creation, object upload, object stat,
// object retrieval and object removal.
type fakeS3 struct {
mu sync.Mutex
@@ -176,16 +176,13 @@ type fakeS3 struct {
// contentTypes maps completed object keys to their content type.
contentTypes map[string]string
- // parts accumulates uploaded multipart data per object key.
- parts map[string][]byte
-
// failBucket forces the bucket existence probe to fail.
failBucket bool
// failMakeBucket forces bucket creation to fail.
failMakeBucket bool
- // failUpload forces multipart upload initiation to fail.
+ // failUpload forces object uploads to fail.
failUpload bool
// failStat forces object stat requests to fail.
@@ -204,7 +201,7 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer f.mu.Unlock()
// the maps are initialized independently: a fixture that seeds only
- // objects still needs the other two.
+ // objects still needs the other one.
if f.objects == nil {
f.objects = make(map[string][]byte)
}
@@ -213,10 +210,6 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.contentTypes = make(map[string]string)
}
- if f.parts == nil {
- f.parts = make(map[string][]byte)
- }
-
segments := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2)
var key string
@@ -234,14 +227,10 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.handleHeadBucket(w)
case key == "" && r.Method == http.MethodPut:
f.handleMakeBucket(w)
- case r.Method == http.MethodPost && query.Has("uploads"):
- f.handleInitiateUpload(w, r, key)
case r.Method == http.MethodPut && r.Header.Get("X-Amz-Copy-Source") != "":
f.handleCopy(w, r, key)
- case r.Method == http.MethodPut && query.Get("uploadId") != "":
- f.handleUploadPart(w, r, key)
- case r.Method == http.MethodPost && query.Get("uploadId") != "":
- f.handleCompleteUpload(w, key)
+ case r.Method == http.MethodPut:
+ f.handlePut(w, r, key)
case r.Method == http.MethodHead:
f.handleStat(w, key)
case r.Method == http.MethodGet:
@@ -295,46 +284,22 @@ func (f *fakeS3) handleMakeBucket(w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
}
-// handleInitiateUpload serves multipart upload initiation, recording
-// the object's content type provided by the client.
-func (f *fakeS3) handleInitiateUpload(w http.ResponseWriter, r *http.Request, key string) {
+// handlePut stores an object uploaded through a single PUT request,
+// recording the object's content type provided by the client.
+func (f *fakeS3) handlePut(w http.ResponseWriter, r *http.Request, key string) {
if f.failUpload {
writeS3Error(w, http.StatusForbidden, "AccessDenied")
return
}
+ f.objects[key] = readS3Body(r)
f.contentTypes[key] = r.Header.Get("Content-Type")
- fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant
- w,
- `%stest-upload`,
- key,
- )
-}
-
-// handleUploadPart accumulates a single multipart upload part.
-func (f *fakeS3) handleUploadPart(w http.ResponseWriter, r *http.Request, key string) {
- f.parts[key] = append(f.parts[key], readS3Body(r)...)
-
- w.Header().Set("ETag", `"part-etag"`)
+ w.Header().Set("ETag", `"test-etag"`)
w.WriteHeader(http.StatusOK)
}
-// handleCompleteUpload finalizes a multipart upload by promoting the
-// accumulated parts to a stored object.
-func (f *fakeS3) handleCompleteUpload(w http.ResponseWriter, key string) {
- f.objects[key] = f.parts[key]
-
- delete(f.parts, key)
-
- fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant
- w,
- `test-bucket%s"test-etag"`,
- key,
- )
-}
-
// handleStat serves object metadata requests.
func (f *fakeS3) handleStat(w http.ResponseWriter, key string) {
if f.failStat {
diff --git a/server/core/internal/storage/object.go b/server/core/internal/storage/object.go
index 07141f8a..72bf7e99 100644
--- a/server/core/internal/storage/object.go
+++ b/server/core/internal/storage/object.go
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"path"
+ "slices"
"github.com/minio/minio-go/v7"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
@@ -70,12 +71,23 @@ func (c *Client) Upload(ctx context.Context, folder, id string, r io.Reader) err
key := path.Join(folder, id)
+ // the object is fully buffered rather than streamed: the read side is
+ // already capped at _maxObjectSize, and a seekable reader with an exact
+ // size keeps the client's transient-error retries (disabled for
+ // non-seekable streams) and a single-part upload.
+ rest, err := io.ReadAll(r)
+ if err != nil {
+ return fmt.Errorf("reading object: %w", err)
+ }
+
+ data := slices.Concat(buf[:n], rest)
+
_, err = c.client.PutObject(
ctx,
c.bucket,
key,
- io.MultiReader(bytes.NewReader(buf[:n]), r), // Since we read some bytes for content detection, we need to prepend them back.
- -1,
+ bytes.NewReader(data),
+ int64(len(data)),
minio.PutObjectOptions{
ContentType: ct,
},
diff --git a/server/core/pkg/sentryutil/sentryutil.go b/server/core/pkg/sentryutil/sentryutil.go
index f73180a8..1ca9024d 100644
--- a/server/core/pkg/sentryutil/sentryutil.go
+++ b/server/core/pkg/sentryutil/sentryutil.go
@@ -63,6 +63,9 @@ func Setup(cfg Config) (func(), error) {
Dsn: cfg.DSN,
Release: cfg.Release,
Environment: cfg.Environment,
+ // nothing in this codebase emits Sentry Logs, so keep the
+ // default-on telemetry machinery switched off.
+ DisableLogs: true,
BeforeSend: func(e *sentry.Event, _ *sentry.EventHint) *sentry.Event {
// NOTE: Remove default info.
e.Modules = nil
diff --git a/server/core/pkg/sentryutil/sentryutil_test.go b/server/core/pkg/sentryutil/sentryutil_test.go
index 00e1de0f..6d9a87bb 100644
--- a/server/core/pkg/sentryutil/sentryutil_test.go
+++ b/server/core/pkg/sentryutil/sentryutil_test.go
@@ -21,6 +21,7 @@ func Test_Setup(t *testing.T) {
closer, err = Setup(Config{})
require.NoError(t, err)
require.NotNil(t, closer)
+ assert.True(t, sentry.CurrentHub().Client().Options().DisableLogs)
assert.NotPanics(t, func() {
closer()
})