From 14146db8eccbbcdbfadfc4bcff79691fa645a5e7 Mon Sep 17 00:00:00 2001 From: gscho Date: Tue, 30 Jun 2026 09:57:13 -0400 Subject: [PATCH 1/5] Make the gemfast cmd run server if no subcommand is provided. Signed-off-by: gscho --- .gitignore | 4 +++- cmd/gemfast-server/root.go | 4 ++++ cmd/gemfast-server/start.go | 3 +++ internal/api/api.go | 6 ++++++ internal/config/config.go | 15 ++++----------- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 4530fab..89f069e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ omnibus/pkg tmp !test/fixtures/spec/*.gem dist/ -bin/ \ No newline at end of file +bin/ +.vercel +.env* diff --git a/cmd/gemfast-server/root.go b/cmd/gemfast-server/root.go index 6c3dbc7..54cf73d 100644 --- a/cmd/gemfast-server/root.go +++ b/cmd/gemfast-server/root.go @@ -9,6 +9,10 @@ import ( var rootCmd = &cobra.Command{ Use: "gemfast-server", Short: "Gemfast is a rubygems server written in Go", + // When invoked with no subcommand, default to running the server (same as "start"). + Run: func(cmd *cobra.Command, args []string) { + start() + }, } func Execute() { diff --git a/cmd/gemfast-server/start.go b/cmd/gemfast-server/start.go index 3b4e866..8163250 100644 --- a/cmd/gemfast-server/start.go +++ b/cmd/gemfast-server/start.go @@ -27,6 +27,9 @@ var configPath string func init() { startCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to the config file") + // Expose the same flag on the root command so it works when invoking + // gemfast-server with no subcommand (defaults to start). + rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to the config file") rootCmd.AddCommand(startCmd) } diff --git a/internal/api/api.go b/internal/api/api.go index 214a53f..3209a1e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "os" "path/filepath" "strings" @@ -46,6 +47,11 @@ func (api *API) Run() { api.loadMiddleware() api.registerRoutes() port := fmt.Sprintf(":%d", api.cfg.Port) + // Honor the PORT env var (e.g. injected by Vercel) when set, falling back + // to the configured/default port otherwise. + if p := os.Getenv("PORT"); p != "" { + port = ":" + p + } if api.cfg.Mirrors[0].Enabled { log.Info().Str("detail", api.cfg.Mirrors[0].Upstream).Msg("mirroring upstream gem server") } diff --git a/internal/config/config.go b/internal/config/config.go index 7293139..3fa6887 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,6 @@ import ( "os" "os/user" "path/filepath" - "runtime" "github.com/gemfast/server/internal/utils" "github.com/hashicorp/hcl/v2/hclsimple" @@ -125,16 +124,10 @@ func (c *Config) setDefaultServerConfig() { } configureLogLevel(c.LogLevel) if c.Dir == "" { - if c.user.Username == "root" { - c.Dir = "/var/lib/gemfast/data" - } else if runtime.GOOS == "darwin" { - c.Dir = fmt.Sprintf("%s/Library/Application Support/gemfast", c.user.HomeDir) - } else if runtime.GOOS == "linux" { - c.Dir = fmt.Sprintf("%s/gemfast", os.Getenv("XDG_DATA_HOME")) - if c.Dir == "/gemfast" { - c.Dir = fmt.Sprintf("%s/.local/share/gemfast", c.user.HomeDir) - } - } + // NOTE: hardcoded to /tmp for the Vercel test deployment, where /tmp + // is the only writable location. This is intentionally not + // platform-specific for now. + c.Dir = "/tmp/gemfast" } if c.GemDir == "" { c.GemDir = fmt.Sprintf("%s/gems", c.Dir) From 220012524d38c8e2879630ba15cdb5dce76b7eff Mon Sep 17 00:00:00 2001 From: gscho Date: Tue, 30 Jun 2026 16:41:57 -0400 Subject: [PATCH 2/5] Add telemetry middleware and spans using otelgin. Signed-off-by: gscho --- cmd/gemfast-server/start.go | 18 ++++ go.mod | 72 ++++++++----- go.sum | 165 +++++++++++++++++++---------- internal/api/api.go | 6 ++ internal/api/rubygems.go | 108 ++++++++++++++++++- internal/cve/gemadvisorydb.go | 12 +++ internal/indexer/indexer.go | 36 ++++++- internal/middleware/github.go | 28 +++-- internal/middleware/otel_enrich.go | 68 ++++++++++++ internal/middleware/token.go | 3 + internal/telemetry/telemetry.go | 113 ++++++++++++++++++++ 11 files changed, 533 insertions(+), 96 deletions(-) create mode 100644 internal/middleware/otel_enrich.go create mode 100644 internal/telemetry/telemetry.go diff --git a/cmd/gemfast-server/start.go b/cmd/gemfast-server/start.go index 8163250..ed7749f 100644 --- a/cmd/gemfast-server/start.go +++ b/cmd/gemfast-server/start.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "os" "time" @@ -10,10 +11,14 @@ import ( "github.com/gemfast/server/internal/db" "github.com/gemfast/server/internal/filter" "github.com/gemfast/server/internal/indexer" + "github.com/gemfast/server/internal/telemetry" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +// serviceVersion is overridable at link time (-ldflags "-X .../cmd.serviceVersion=v1.2.3"). +var serviceVersion = "dev" + var startCmd = &cobra.Command{ Use: "start", Short: "Starts the gemfast rubygems server", @@ -41,6 +46,19 @@ func start() { cfg := config.NewConfig() log.Info().Msg("starting services") + // Initialize OpenTelemetry tracing (exports to Honeycomb by default). + ctx := context.Background() + shutdownTracer, err := telemetry.Init(ctx, serviceVersion, cfg) + if err != nil { + log.Warn().Err(err).Msg("failed to initialize tracing; continuing without it") + } else { + defer func() { + if err := shutdownTracer(context.Background()); err != nil { + log.Warn().Err(err).Msg("error shutting down tracer provider") + } + }() + } + // Connect to the database database, err := db.NewDB(cfg) if err != nil { diff --git a/go.mod b/go.mod index 0a4d065..482757a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gemfast/server -go 1.24.3 +go 1.25.0 require ( github.com/akyoto/cache v1.0.6 @@ -9,7 +9,7 @@ require ( github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce github.com/casbin/casbin/v2 v2.109.0 github.com/gin-contrib/sessions v1.0.4 - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.12.0 github.com/go-git/go-git/v5 v5.16.2 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/hashicorp/hcl/v2 v2.24.0 @@ -17,11 +17,17 @@ require ( github.com/rs/zerolog v1.34.0 github.com/sethvargo/go-password v0.3.1 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/zsais/go-gin-prometheus v1.0.0 go.etcd.io/bbolt v1.4.2 - golang.org/x/crypto v0.45.0 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + golang.org/x/crypto v0.52.0 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b gopkg.in/yaml.v3 v3.0.1 ) @@ -35,49 +41,57 @@ require ( github.com/aquasecurity/go-version v0.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect - github.com/bytedance/sonic v1.13.3 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/casbin/govaluate v1.8.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.8.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect @@ -85,18 +99,26 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/arch v0.19.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 1648755..35e6b2e 100644 --- a/go.sum +++ b/go.sum @@ -34,23 +34,25 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/casbin/casbin/v2 v2.109.0 h1:/Rxcqa8V9t6/mMleX4laRtc/mduA+oYdZr449Rd1lD0= github.com/casbin/casbin/v2 v2.109.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco= github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/casbin/govaluate v1.8.0 h1:1dUaV/I0LFP2tcY1uNQEb6wBCbp8GMTcC/zhwQDWvZo= github.com/casbin/govaluate v1.8.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -64,16 +66,17 @@ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVo github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -84,6 +87,11 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= @@ -92,14 +100,15 @@ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/Nu github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.8.1 h1:JuZRFlqLM5cWF6A+waL8AKVuCcqvKOuhJtUQI+L3ez0= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.8.1/go.mod h1:wS4gNoLalDSJxo/SpngzPQ2BN4uuZVLCmbM4S3vd4+Y= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -107,17 +116,23 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +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/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -132,10 +147,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -160,8 +173,8 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -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-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -173,8 +186,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -189,6 +202,10 @@ github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2 github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -213,6 +230,7 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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/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= @@ -220,9 +238,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -232,8 +251,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= @@ -248,23 +267,55 @@ github.com/zsais/go-gin-prometheus v1.0.0/go.mod h1:BLzchNYsehhyPNY31G0YgK/Q6BaD go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= -golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= -golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +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/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k= +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/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs= +go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro= +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/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -282,32 +333,39 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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-20190902080502-41f04d3bba15/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/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= -gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= @@ -317,4 +375,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/api/api.go b/internal/api/api.go index 3209a1e..5c75842 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ginprometheus "github.com/zsais/go-gin-prometheus" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) const adminAPIPath = "/admin/api/v1" @@ -63,6 +64,11 @@ func (api *API) Run() { } func (api *API) loadMiddleware() { + // Order matters - otelgin should come first in the chain + api.router.Use(otelgin.Middleware("gemfast-server")) + // Enriches the otelgin span with user identity, session hash, and + // error.category once the handler chain completes. + api.router.Use(middleware.OtelEnrich()) acl := middleware.NewACL(api.cfg) api.tokenMiddleware = middleware.NewTokenMiddleware(acl, api.db) api.githubMiddleware = middleware.NewGitHubMiddleware(api.cfg, acl, api.db) diff --git a/internal/api/rubygems.go b/internal/api/rubygems.go index 6eac0ba..2178471 100644 --- a/internal/api/rubygems.go +++ b/internal/api/rubygems.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "fmt" "io" @@ -19,11 +20,20 @@ import ( "github.com/gemfast/server/internal/indexer" "github.com/gemfast/server/internal/marshal" "github.com/gemfast/server/internal/spec" + "github.com/gemfast/server/internal/telemetry" "github.com/gemfast/server/internal/utils" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) +// upstreamHTTPClient propagates trace context and emits a client span per call +// to the upstream mirror (rubygems.org by default). +var upstreamHTTPClient = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} + type RubyGemsHandler struct { cfg *config.Config db *db.DB @@ -73,6 +83,11 @@ func (h *RubyGemsHandler) localGemspecRzHandler(c *gin.Context) { func (h *RubyGemsHandler) localGemHandler(c *gin.Context) { fileName := c.Param("gem") + span := trace.SpanFromContext(c.Request.Context()) + span.SetAttributes( + attribute.String("gem.filename", fileName), + attribute.String("gem.source", h.cfg.PrivateGemsNamespace), + ) fc := strings.Split(fileName, "")[0] // first character fp := filepath.Join(h.cfg.GemDir, h.cfg.PrivateGemsNamespace, fc, fileName) c.FileAttachment(fp, fileName) @@ -85,7 +100,12 @@ func (h *RubyGemsHandler) localIndexHandler(c *gin.Context) { } func (h *RubyGemsHandler) localDependenciesHandler(c *gin.Context) { + span := trace.SpanFromContext(c.Request.Context()) gemQuery := c.Query("gems") + span.SetAttributes( + attribute.String("gem.source", h.cfg.PrivateGemsNamespace), + attribute.Int("gem.query.count", strings.Count(gemQuery, ",")+1), + ) log.Trace().Str("detail", gemQuery).Msg("received gems") if gemQuery == "" { c.Status(http.StatusOK) @@ -144,12 +164,20 @@ func (h *RubyGemsHandler) localDependenciesJSONHandler(c *gin.Context) { } func (h *RubyGemsHandler) localUploadGemHandler(c *gin.Context) { + span := trace.SpanFromContext(c.Request.Context()) + span.SetAttributes( + attribute.String("upload.path", "rubygems-push"), + attribute.String("gem.source", h.cfg.PrivateGemsNamespace), + ) var bodyBytes []byte if c.Request.Body != nil { bodyBytes, _ = io.ReadAll(c.Request.Body) } + span.SetAttributes(attribute.Int("upload.size_bytes", len(bodyBytes))) tmpfile, err := os.CreateTemp(h.cfg.Dir+"/tmp", "*.gem") if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-create-tmpfile")) log.Error().Err(err).Msg("failed to create tmp file") c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to index gem: %v", err)) return @@ -158,11 +186,15 @@ func (h *RubyGemsHandler) localUploadGemHandler(c *gin.Context) { err = os.WriteFile(tmpfile.Name(), bodyBytes, 0644) if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-write-tmpfile")) log.Error().Err(err).Str("detail", tmpfile.Name()).Msg("failed to save uploaded file") c.String(http.StatusInternalServerError, fmt.Sprintf("failed to index gem: %v", err)) return } - if err = h.saveAndReindexLocalGem(h.cfg.PrivateGemsNamespace, tmpfile); err != nil { + if err = h.saveAndReindexLocalGem(c.Request.Context(), h.cfg.PrivateGemsNamespace, tmpfile); err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-save-reindex")) log.Error().Err(err).Msg("failed to reindex gem") c.String(http.StatusInternalServerError, fmt.Sprintf("failed to index gem: %v", err)) return @@ -171,10 +203,18 @@ func (h *RubyGemsHandler) localUploadGemHandler(c *gin.Context) { } func (h *RubyGemsHandler) localYankHandler(c *gin.Context) { + span := trace.SpanFromContext(c.Request.Context()) g := c.Query("gem") v := c.Query("version") p := c.Query("platform") + span.SetAttributes( + attribute.String("gem.name", g), + attribute.String("gem.version", v), + attribute.String("gem.platform", p), + attribute.String("gem.source", h.cfg.PrivateGemsNamespace), + ) if g == "" || v == "" { + span.SetAttributes(attribute.String("exception.slug", "err-yank-missing-params")) c.String(http.StatusBadRequest, "must provide both gem and version query parameters") return } @@ -244,14 +284,27 @@ func (h *RubyGemsHandler) localInfoHandler(c *gin.Context) { } func (h *RubyGemsHandler) geminaboxUploadGem(c *gin.Context) { + span := trace.SpanFromContext(c.Request.Context()) + span.SetAttributes( + attribute.String("upload.path", "geminabox"), + attribute.String("gem.source", h.cfg.PrivateGemsNamespace), + ) file, err := c.FormFile("file") if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-form-file-missing")) log.Error().Err(err).Msg("failed to read form file") c.String(http.StatusBadRequest, "failed to read form file parameter") return } + span.SetAttributes( + attribute.String("upload.filename", file.Filename), + attribute.Int64("upload.size_bytes", file.Size), + ) tmpfile, err := os.CreateTemp(h.cfg.Dir+"/tmp", "*.gem") if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-create-tmpfile")) log.Error().Err(err).Msg("failed to create tmp file") c.String(http.StatusInternalServerError, "failed to index gem") return @@ -259,11 +312,15 @@ func (h *RubyGemsHandler) geminaboxUploadGem(c *gin.Context) { defer os.Remove(tmpfile.Name()) if err = c.SaveUploadedFile(file, tmpfile.Name()); err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-save-uploaded-file")) log.Error().Err(err).Str("detail", tmpfile.Name()).Msg("failed to save uploaded file") c.String(http.StatusInternalServerError, "failed to index gem") return } - if err = h.saveAndReindexLocalGem(h.cfg.PrivateGemsNamespace, tmpfile); err != nil { + if err = h.saveAndReindexLocalGem(c.Request.Context(), h.cfg.PrivateGemsNamespace, tmpfile); err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-upload-save-reindex")) log.Error().Err(err).Msg("failed to reindex gem") c.String(http.StatusInternalServerError, "failed to index gem") return @@ -291,12 +348,30 @@ func (h *RubyGemsHandler) fetchGemVersions(source, gemQuery string) ([]*db.Gem, return gemVersions, nil } -func (h *RubyGemsHandler) saveAndReindexLocalGem(source string, tmpfile *os.File) error { +func (h *RubyGemsHandler) saveAndReindexLocalGem(ctx context.Context, source string, tmpfile *os.File) error { + ctx, span := telemetry.Tracer().Start(ctx, "rubygems.saveAndReindexLocalGem") + defer span.End() + span.SetAttributes( + attribute.String("gem.source", source), + attribute.String("gem.action", "upload"), + ) s, err := spec.FromFile(h.cfg.Dir+"/tmp", tmpfile.Name()) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "spec.FromFile failed") + span.SetAttributes(attribute.String("exception.slug", "err-save-reindex-spec-parse")) log.Error().Err(err).Msg("failed to read spec from tmpfile") return err } + span.SetAttributes( + attribute.String("gem.name", s.Name), + attribute.String("gem.version", s.Version), + attribute.String("gem.platform", s.OriginalPlatform), + ) + span.AddEvent("spec.parsed", trace.WithAttributes( + attribute.String("gem.name", s.Name), + attribute.String("gem.version", s.Version), + )) fc := strings.Split(s.Name, "")[0] // first character var fp string if s.OriginalPlatform == "ruby" { @@ -307,14 +382,25 @@ func (h *RubyGemsHandler) saveAndReindexLocalGem(source string, tmpfile *os.File utils.MkDirs(path.Dir(fp)) err = os.Rename(tmpfile.Name(), fp) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "rename tmpfile failed") + span.SetAttributes(attribute.String("exception.slug", "err-save-reindex-rename")) log.Error().Err(err).Str("detail", fp).Msg("failed to rename tmpfile") return err } + span.AddEvent("gem.file.persisted", trace.WithAttributes( + attribute.String("path", fp), + )) + span.AddEvent("indexer.add.start") err = h.indexer.AddGemToIndex(source, fp) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "AddGemToIndex failed") + span.SetAttributes(attribute.String("exception.slug", "err-save-reindex-add-index")) log.Error().Err(err).Str("detail", s.Name).Msg("failed to add gem to index") return err } + span.AddEvent("indexer.add.complete") return nil } @@ -349,7 +435,13 @@ func (h *RubyGemsHandler) mirroredGemspecRzHandler(c *gin.Context) { c.String(http.StatusInternalServerError, "Failed to fetch quick marshal") return } - resp, err := http.Get(path) + req, err := http.NewRequestWithContext(c.Request.Context(), "GET", path, nil) + if err != nil { + log.Error().Err(err).Str("detail", path).Msg("failed to build upstream request") + c.String(http.StatusInternalServerError, "Failed to build upstream request") + return + } + resp, err := upstreamHTTPClient.Do(req) if err != nil { log.Error().Err(err).Str("detail", path).Msg("failed to connect to upstream") c.String(http.StatusInternalServerError, "Failed to connect to upstream") @@ -409,7 +501,13 @@ func (h *RubyGemsHandler) mirroredGemHandler(c *gin.Context) { c.String(http.StatusInternalServerError, "Failed to fetch gem file from upstream") return } - resp, err := http.Get(path) + req, err := http.NewRequestWithContext(c.Request.Context(), "GET", path, nil) + if err != nil { + log.Error().Err(err).Str("detail", path).Msg("failed to build upstream request") + c.String(http.StatusInternalServerError, "Failed to build upstream request") + return + } + resp, err := upstreamHTTPClient.Do(req) if err != nil { log.Error().Err(err).Str("detail", path).Msg("failed to connect to upstream") c.String(http.StatusInternalServerError, "Failed to connect to upstream") diff --git a/internal/cve/gemadvisorydb.go b/internal/cve/gemadvisorydb.go index 597845a..20a0360 100644 --- a/internal/cve/gemadvisorydb.go +++ b/internal/cve/gemadvisorydb.go @@ -1,6 +1,7 @@ package cve import ( + "context" "fmt" "time" @@ -11,7 +12,10 @@ import ( "gopkg.in/yaml.v3" "github.com/gemfast/server/internal/config" + "github.com/gemfast/server/internal/telemetry" git "github.com/go-git/go-git/v5" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "github.com/akyoto/cache" ggv "github.com/aquasecurity/go-gem-version" @@ -52,13 +56,21 @@ func NewGemAdvisoryDB(cfg *config.Config) *GemAdvisoryDB { } func (g *GemAdvisoryDB) Refresh() error { + _, span := telemetry.Tracer().Start(context.Background(), "advisorydb.Refresh") + defer span.End() + span.SetAttributes(attribute.Bool("advisorydb.enabled", g.cfg.CVE.Enabled)) err := g.updateAdvisoryRepo() if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("exception.slug", "err-advisorydb-update-repo")) log.Warn().Err(err).Msg("failed to update ruby-advisory-db") } g.db.Close() err = g.cacheAdvisoryDB("/gems") if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "cacheAdvisoryDB failed") + span.SetAttributes(attribute.String("exception.slug", "err-advisorydb-cache")) log.Error().Err(err).Msg("failed to cache github.com/rubysec/ruby-advisory-db") return fmt.Errorf("failed to cache github.com/rubysec/ruby-advisory-db: %w", err) } diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 3a05918..b2eb179 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/gzip" "compress/zlib" + "context" "crypto/sha1" "errors" "fmt" @@ -22,7 +23,10 @@ import ( "github.com/gemfast/server/internal/db" "github.com/gemfast/server/internal/marshal" "github.com/gemfast/server/internal/spec" + "github.com/gemfast/server/internal/telemetry" "github.com/gemfast/server/internal/utils" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "golang.org/x/exp/slices" "github.com/rs/zerolog/log" @@ -104,6 +108,9 @@ func NewIndexer(cfg *config.Config, db *db.DB) (*Indexer, error) { } func (indexer *Indexer) GenerateIndex() error { + ctx, span := telemetry.Tracer().Start(context.Background(), "indexer.GenerateIndex") + defer span.End() + _ = ctx utils.MkDirs(indexer.quickMarshalDir) utils.MkDirs(indexer.cfg.GemDir) utils.MkDirs(indexer.cfg.GemDir + "/" + indexer.cfg.PrivateGemsNamespace) @@ -112,7 +119,9 @@ func (indexer *Indexer) GenerateIndex() error { _, specsMissing := os.Stat(fmt.Sprintf("%s/specs.4.8.gz", indexer.cfg.Dir)) _, prereleaseSpecsMissing := os.Stat(fmt.Sprintf("%s/prerelease_specs.4.8.gz", indexer.cfg.Dir)) _, latestSpecsMissing := os.Stat(fmt.Sprintf("%s/latest_specs.4.8.gz", indexer.cfg.Dir)) - if specsMissing != nil || prereleaseSpecsMissing != nil || latestSpecsMissing != nil { + indexBuilt := specsMissing != nil || prereleaseSpecsMissing != nil || latestSpecsMissing != nil + span.SetAttributes(attribute.Bool("indexer.built_from_scratch", indexBuilt)) + if indexBuilt { indexer.buildIndicies() indexer.installIndicies() } @@ -411,6 +420,13 @@ func (indexer *Indexer) updateSpecsIndex(updated []*spec.Spec, src string, dest // TODO: refactor this to not reopen the gemspec files that have been uploaded func (indexer *Indexer) UpdateIndex(source string, updatedGems []string) error { + _, span := telemetry.Tracer().Start(context.Background(), "indexer.UpdateIndex") + defer span.End() + span.SetAttributes( + attribute.String("gem.source", source), + attribute.Int("gem.updated_count", len(updatedGems)), + ) + lock.Lock() defer lock.Unlock() defer os.RemoveAll(indexer.dir) @@ -418,12 +434,18 @@ func (indexer *Indexer) UpdateIndex(source string, updatedGems []string) error { specs, err := indexer.mapGemsToSpecs(updatedGems) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "mapGemsToSpecs failed") + span.SetAttributes(attribute.String("exception.slug", "err-indexer-map-gems-to-specs")) log.Error().Err(err).Str("detail", source).Msg("failed to update index - unable to map gems to specs") return err } err = indexer.db.SaveGemVersions(source, specs) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "SaveGemVersions failed") + span.SetAttributes(attribute.String("exception.slug", "err-indexer-save-gem-versions")) log.Error().Err(err).Str("detail", source).Msg("failed to update index - unable to save gem dependencies") return err } @@ -431,6 +453,11 @@ func (indexer *Indexer) UpdateIndex(source string, updatedGems []string) error { indexer.buildMarshalGemspecs(specs, true) pre, rel, latest := spec.PartitionSpecs(specs) + span.SetAttributes( + attribute.Int("gem.specs.release_count", len(rel)), + attribute.Int("gem.specs.latest_count", len(latest)), + attribute.Int("gem.specs.prerelease_count", len(pre)), + ) // TODO: capture errors from these goroutines ch := make(chan int, 3) go indexer.updateSpecsIndex(rel, indexer.destSpecsIdx, indexer.specsIdx, ch) @@ -530,6 +557,13 @@ func (indexer *Indexer) compressAndMoveIndices() error { } func (indexer *Indexer) RemoveGemFromIndex(name string, version string, platform string) error { + _, span := telemetry.Tracer().Start(context.Background(), "indexer.RemoveGemFromIndex") + defer span.End() + span.SetAttributes( + attribute.String("gem.name", name), + attribute.String("gem.version", version), + attribute.String("gem.platform", platform), + ) lock.Lock() defer lock.Unlock() if platform == "" { diff --git a/internal/middleware/github.go b/internal/middleware/github.go index a705025..ce1b741 100644 --- a/internal/middleware/github.go +++ b/internal/middleware/github.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -21,8 +22,13 @@ import ( "github.com/gin-gonic/gin" "github.com/juliangruber/go-intersect" "github.com/tidwall/gjson" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) +// tracedHTTPClient returns an http.Client whose transport propagates the +// W3C traceparent header and creates a client span per request. +var tracedHTTPClient = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} + type OAuthLogin struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` @@ -115,7 +121,7 @@ func (ghm *GitHubMiddleware) GitHubCallbackHandler(c *gin.Context) { login := OAuthLogin{ClientID: ghm.cfg.Auth.GitHubClientId, ClientSecret: ghm.cfg.Auth.GitHubClientSecret, Code: code} jsonData, _ := json.Marshal(login) bodyReader := bytes.NewBuffer(jsonData) - req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bodyReader) + req, err := http.NewRequestWithContext(c.Request.Context(), "POST", "https://github.com/login/oauth/access_token", bodyReader) if err != nil { log.Error().Err(err).Msg("failed to create POST login/oauth/access_token request") c.String(http.StatusInternalServerError, "failed to create POST login/oauth/access_token request") @@ -124,7 +130,7 @@ func (ghm *GitHubMiddleware) GitHubCallbackHandler(c *gin.Context) { } req.Header.Set("Content-Type", "application/json; charset=UTF-8") req.Header.Set("Accept", "application/json") - res, err := http.DefaultClient.Do(req) + res, err := tracedHTTPClient.Do(req) if err != nil { log.Error().Err(err).Msg("failed to get an access token from github") c.String(http.StatusInternalServerError, "failed to get an access token from github") @@ -141,7 +147,7 @@ func (ghm *GitHubMiddleware) GitHubCallbackHandler(c *gin.Context) { } json := string(body) at := gjson.Get(json, "access_token").String() - user, err := ghm.authenticateGitHubUser(at) + user, err := ghm.authenticateGitHubUser(c.Request.Context(), at) if err != nil { log.Error().Err(err).Msg("failed to authenticate github user") c.String(http.StatusForbidden, fmt.Sprintf("failed to fetch github user with provided token: %v", err)) @@ -169,13 +175,13 @@ func (ghm *GitHubMiddleware) GitHubCallbackHandler(c *gin.Context) { c.Abort() } -func (ghm *GitHubMiddleware) authenticateGitHubUser(at string) (*db.User, error) { - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) +func (ghm *GitHubMiddleware) authenticateGitHubUser(ctx context.Context, at string) (*db.User, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil) if err != nil { return nil, fmt.Errorf("failed to create GET user request: %w", err) } req.Header.Set("Authorization", "Bearer "+at) - res, err := http.DefaultClient.Do(req) + res, err := tracedHTTPClient.Do(req) if err != nil { return nil, err } else if res.StatusCode >= 400 { @@ -191,7 +197,7 @@ func (ghm *GitHubMiddleware) authenticateGitHubUser(at string) (*db.User, error) if username == "" { return nil, fmt.Errorf("user login not returned from github") } - err = ghm.userMemberOfRequiredOrg(at) + err = ghm.userMemberOfRequiredOrg(ctx, at) if err != nil { return nil, err } @@ -218,13 +224,13 @@ func (ghm *GitHubMiddleware) authenticateGitHubUser(at string) (*db.User, error) return user, nil } -func (ghm *GitHubMiddleware) userMemberOfRequiredOrg(at string) error { - req, err := http.NewRequest("GET", "https://api.github.com/user/orgs", nil) +func (ghm *GitHubMiddleware) userMemberOfRequiredOrg(ctx context.Context, at string) error { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/orgs", nil) if err != nil { return fmt.Errorf("failed to create GET user/orgs request: %w", err) } req.Header.Set("Authorization", "Bearer "+at) - res, err := http.DefaultClient.Do(req) + res, err := tracedHTTPClient.Do(req) if err != nil { return err } else if res.StatusCode >= 400 { @@ -287,7 +293,7 @@ func (ghm *GitHubMiddleware) GitHubMiddlewareFunc() gin.HandlerFunc { } role := claims[RoleKey].(string) ghAccessToken := claims[GitHubTokenKey].(string) - _, err = ghm.authenticateGitHubUser(ghAccessToken) + _, err = ghm.authenticateGitHubUser(c.Request.Context(), ghAccessToken) if err != nil { log.Error().Err(err).Msg("failed to authenticate github user") if browser { diff --git a/internal/middleware/otel_enrich.go b/internal/middleware/otel_enrich.go new file mode 100644 index 0000000..c28fe1c --- /dev/null +++ b/internal/middleware/otel_enrich.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/gemfast/server/internal/db" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// OtelEnrich attaches investigation-useful attributes to the active server span +// after the handler runs: authenticated user identity (when present), an opaque +// session id hash, and an error.category derived from the final HTTP status. +// +// Must be installed AFTER otelgin (so a span exists) but ordering relative to +// auth middleware does not matter — c.Next() drains the chain before we read +// identity, status, or cookies. +func OtelEnrich() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + span := trace.SpanFromContext(c.Request.Context()) + if !span.SpanContext().IsValid() { + return + } + + if v, ok := c.Get(IdentityKey); ok { + if u, ok := v.(*db.User); ok && u != nil { + attrs := []attribute.KeyValue{ + attribute.String("user.id", u.Username), + attribute.String("user.role", u.Role), + } + if u.Type != "" { + attrs = append(attrs, attribute.String("user.auth_type", u.Type)) + } + span.SetAttributes(attrs...) + } + } + + if cookie, err := c.Cookie("gemfast"); err == nil && cookie != "" { + sum := sha256.Sum256([]byte(cookie)) + span.SetAttributes(attribute.String("session.id_hash", hex.EncodeToString(sum[:8]))) + } + + if cat := categorizeStatus(c.Writer.Status()); cat != "" { + span.SetAttributes(attribute.String("error.category", cat)) + } + } +} + +func categorizeStatus(status int) string { + switch { + case status == 400: + return "validation" + case status == 401, status == 403: + return "auth" + case status == 404: + return "not_found" + case status == 405: + return "policy" + case status >= 500: + return "internal" + default: + return "" + } +} diff --git a/internal/middleware/token.go b/internal/middleware/token.go index 7e827b4..61a14d1 100644 --- a/internal/middleware/token.go +++ b/internal/middleware/token.go @@ -55,6 +55,9 @@ func (t *TokenMiddleware) TokenMiddlewareFunc() gin.HandlerFunc { return } if ok { + // Make identity available to downstream handlers/middleware + // (e.g. OtelEnrich attributes). + c.Set(IdentityKey, user) c.Next() } else { c.String(http.StatusForbidden, fmt.Sprintf("user does not have access to the request %s %s", c.Request.Method, c.Request.URL.Path)) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..d3d5d1a --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,113 @@ +package telemetry + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/gemfast/server/internal/config" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + oteltrace "go.opentelemetry.io/otel/trace" +) + +const ( + tracerName = "github.com/gemfast/server" + defaultServiceName = "gemfast-server" +) + +// Tracer is the package-wide tracer used by custom instrumentation. +func Tracer() oteltrace.Tracer { + return otel.Tracer(tracerName) +} + +// Init configures the global OpenTelemetry tracer provider and returns a +// shutdown function. The exporter is OTLP/HTTP pointing at Honeycomb by default. +// +// Configuration is read from environment variables (standard OTel vars). If +// OTEL_EXPORTER_OTLP_ENDPOINT is not set, the Honeycomb endpoint is used. +// HONEYCOMB_API_KEY is honored as a convenience so an explicit +// OTEL_EXPORTER_OTLP_HEADERS value is not required. +func Init(ctx context.Context, serviceVersion string, cfg *config.Config) (func(context.Context) error, error) { + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" { + os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://api.honeycomb.io") + } + if os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL") == "" { + os.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + } + if os.Getenv("OTEL_EXPORTER_OTLP_HEADERS") == "" { + if apiKey := os.Getenv("HONEYCOMB_API_KEY"); apiKey != "" { + os.Setenv("OTEL_EXPORTER_OTLP_HEADERS", fmt.Sprintf("x-honeycomb-team=%s", apiKey)) + } + } + if os.Getenv("OTEL_SERVICE_NAME") == "" { + os.Setenv("OTEL_SERVICE_NAME", defaultServiceName) + } + + exporter, err := otlptracehttp.New(ctx) + if err != nil { + return nil, fmt.Errorf("create otlp http exporter: %w", err) + } + + deploymentEnv := os.Getenv("DEPLOYMENT_ENV") + if deploymentEnv == "" { + deploymentEnv = "local" + } + + attrs := []attribute.KeyValue{ + semconv.ServiceName(os.Getenv("OTEL_SERVICE_NAME")), + semconv.ServiceVersion(serviceVersion), + semconv.DeploymentEnvironment(deploymentEnv), + } + if cfg != nil { + if cfg.Auth != nil { + attrs = append(attrs, attribute.String("auth.type", cfg.Auth.Type)) + } + if len(cfg.Mirrors) > 0 && cfg.Mirrors[0] != nil { + m := cfg.Mirrors[0] + attrs = append(attrs, + attribute.String("mirror.upstream", m.Upstream), + attribute.String("mirror.hostname", m.Hostname), + attribute.Bool("mirror.enabled", m.Enabled), + ) + } + } + + res, err := resource.New(ctx, + resource.WithFromEnv(), + resource.WithHost(), + resource.WithProcess(), + resource.WithTelemetrySDK(), + resource.WithAttributes(attrs...), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + log.Info().Str("endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")). + Str("service", os.Getenv("OTEL_SERVICE_NAME")). + Msg("opentelemetry tracing initialized") + + return func(shutdownCtx context.Context) error { + shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 5*time.Second) + defer cancel() + return tp.Shutdown(shutdownCtx) + }, nil +} From 1638d53c8424030b21c7068705a483fd56b4b10a Mon Sep 17 00:00:00 2001 From: gscho Date: Wed, 1 Jul 2026 08:53:56 -0400 Subject: [PATCH 3/5] Add an otel collector dockerfile. Signed-off-by: gscho --- deploy/otel-collector/README.md | 39 ++++++++++++++ deploy/otel-collector/collector.yaml | 67 ++++++++++++++++++++++++ deploy/otel-collector/docker-compose.yml | 14 +++++ 3 files changed, 120 insertions(+) create mode 100644 deploy/otel-collector/README.md create mode 100644 deploy/otel-collector/collector.yaml create mode 100644 deploy/otel-collector/docker-compose.yml diff --git a/deploy/otel-collector/README.md b/deploy/otel-collector/README.md new file mode 100644 index 0000000..574f1a3 --- /dev/null +++ b/deploy/otel-collector/README.md @@ -0,0 +1,39 @@ +# Local OTel Collector for gemfast + +Routes gemfast-server traces through an OpenTelemetry Collector before they +reach Honeycomb, applying two transforms: + +1. **Attribute enrichment** — every span gets `collector.processed=true` and + `collector.deployment_env=`. +2. **Tail sampling** — full traces are buffered for 5s, then: + - **Always kept**: traces with any error-status span, any span over 500ms, + and all gem upload traces (`gem.action=upload`). + - **Probabilistically kept**: 10% of remaining healthy traces. + +## Run + +```bash +cd deploy/otel-collector +HONEYCOMB_API_KEY=... DEPLOYMENT_ENV=local docker compose up -d +``` + +Then point gemfast-server at the local collector instead of Honeycomb: + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + ./gemfast-server start -c test/fixtures/gemfast-local.hcl +``` + +To verify the transform fired: in Honeycomb, run +`COUNT WHERE collector.processed = true` — pre-collector traces won't have the +attribute. + +To verify the sampling fired: compare `COUNT` of `GET /up` (cheap, healthy, +high-volume) against `COUNT` of `gem.action=upload` over the same window. The +upload traces should be kept 1:1; the `/up` traces should drop ~90%. + +## Stop + +```bash +docker compose down +``` diff --git a/deploy/otel-collector/collector.yaml b/deploy/otel-collector/collector.yaml new file mode 100644 index 0000000..6538dee --- /dev/null +++ b/deploy/otel-collector/collector.yaml @@ -0,0 +1,67 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + # Transform #1: enrich every span with a marker proving traffic went through + # the collector and tag the pipeline's deployment env. + attributes/enrich: + actions: + - key: collector.processed + value: true + action: insert + - key: collector.deployment_env + value: ${env:DEPLOYMENT_ENV} + action: insert + + # Transform #2: tail-based sampling — buffer complete traces, then keep + # interesting ones (errors + slow) and sample healthy ones at 10%. + tail_sampling: + decision_wait: 5s + num_traces: 50000 + expected_new_traces_per_sec: 100 + policies: + - name: keep-errors + type: status_code + status_code: { status_codes: [ERROR] } + - name: keep-slow-traces + type: latency + latency: { threshold_ms: 500 } + - name: keep-uploads + type: string_attribute + string_attribute: + key: gem.action + values: ["upload"] + - name: sample-healthy + type: probabilistic + probabilistic: { sampling_percentage: 10 } + + batch: + send_batch_size: 512 + timeout: 1s + +exporters: + otlphttp/honeycomb: + endpoint: https://api.honeycomb.io + headers: + x-honeycomb-team: ${env:HONEYCOMB_API_KEY} + + # Mirror to stdout for local debugging. + debug: + verbosity: basic + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [attributes/enrich, tail_sampling, batch] + exporters: [otlphttp/honeycomb, debug] diff --git a/deploy/otel-collector/docker-compose.yml b/deploy/otel-collector/docker-compose.yml new file mode 100644 index 0000000..f7b1812 --- /dev/null +++ b/deploy/otel-collector/docker-compose.yml @@ -0,0 +1,14 @@ +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:0.116.1 + container_name: gemfast-otel-collector + command: ["--config=/etc/otel/collector.yaml"] + volumes: + - ./collector.yaml:/etc/otel/collector.yaml:ro + environment: + HONEYCOMB_API_KEY: ${HONEYCOMB_API_KEY:?HONEYCOMB_API_KEY must be set (e.g. in .env)} + DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:-local} + ports: + - "4317:4317" # OTLP/gRPC + - "4318:4318" # OTLP/HTTP + - "13133:13133" # health_check From 936f0f675e2e713a2f16574f6a7419f3855cb5be Mon Sep 17 00:00:00 2001 From: gscho Date: Wed, 1 Jul 2026 08:54:16 -0400 Subject: [PATCH 4/5] Add scripts and a python client for testing. Signed-off-by: gscho --- scripts/exercise_otel.sh | 92 ++++ scripts/python-client/README.md | 87 ++++ scripts/python-client/gem_list.py | 77 +++ scripts/python-client/load_gems.py | 622 +++++++++++++++++++++++++ scripts/python-client/requirements.txt | 7 + scripts/python-client/upload_gem.py | 124 +++++ test/fixtures/gemfast-local.hcl | 52 +++ 7 files changed, 1061 insertions(+) create mode 100755 scripts/exercise_otel.sh create mode 100644 scripts/python-client/README.md create mode 100644 scripts/python-client/gem_list.py create mode 100644 scripts/python-client/load_gems.py create mode 100644 scripts/python-client/requirements.txt create mode 100644 scripts/python-client/upload_gem.py create mode 100644 test/fixtures/gemfast-local.hcl diff --git a/scripts/exercise_otel.sh b/scripts/exercise_otel.sh new file mode 100755 index 0000000..2f287fb --- /dev/null +++ b/scripts/exercise_otel.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Drive heterogeneous traffic against a running gemfast-server with local-auth +# config so the OTel custom attributes (user.*, error.category, gem.*, mirror.*) +# populate. Requires `jq`. +# +# Usage: +# bash scripts/exercise_otel.sh [BASE_URL] +# +# Defaults to http://localhost:2020. + +set -euo pipefail + +BASE_URL="${1:-http://localhost:2020}" +SAMPLE_GEM="${SAMPLE_GEM:-test/fixtures/spec/nokogiri-1.15.3-arm64-darwin.gem}" + +if ! command -v jq >/dev/null 2>&1; then + echo "exercise_otel.sh requires jq" >&2 + exit 1 +fi + +if [[ ! -f "$SAMPLE_GEM" ]]; then + echo "sample gem not found at $SAMPLE_GEM" >&2 + exit 1 +fi + +log() { printf '\n[exercise] %s\n' "$*"; } + +log "1. Health probes (anonymous)" +for _ in 1 2 3 4 5; do + curl -fsS -o /dev/null "$BASE_URL/up" +done + +log "2. Mirror redirects (anonymous; auto-instrumented)" +for gem in rails rake bundler minitest activerecord; do + curl -fsS -o /dev/null "$BASE_URL/api/v1/dependencies?gems=$gem" || true + curl -fsS -o /dev/null "$BASE_URL/info/$gem" || true + curl -fsS -o /dev/null "$BASE_URL/versions" || true +done + +log "3. Bad auth attempts (populate error.category=auth, user not yet set)" +curl -fsS -o /dev/null -u 'eve:wrong-password' "$BASE_URL/private/api/v1/dependencies?gems=foo" || true +curl -fsS -o /dev/null -u 'eve:wrong-password' "$BASE_URL/private/names" || true +curl -fsS -o /dev/null "$BASE_URL/private/info/missing-gem-x" || true + +log "4. Login as alice (write role)" +ALICE_TOKEN=$(curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice-pw"}' \ + "$BASE_URL/admin/api/v1/login" | jq -r '.token') +[[ -n "$ALICE_TOKEN" && "$ALICE_TOKEN" != "null" ]] || { echo "login failed"; exit 1; } + +log "5. Mint an API token for alice" +ALICE_API_TOKEN=$(curl -fsS -X POST -H "Authorization: Bearer $ALICE_TOKEN" \ + "$BASE_URL/admin/api/v1/token" | jq -r '.token') +[[ -n "$ALICE_API_TOKEN" && "$ALICE_API_TOKEN" != "null" ]] || { echo "token mint failed"; exit 1; } + +log "6. Browse admin endpoints as alice (user.* attrs populate via JWT path)" +for _ in 1 2 3; do + curl -fsS -o /dev/null -H "Authorization: Bearer $ALICE_TOKEN" "$BASE_URL/admin/api/v1/auth" + curl -fsS -o /dev/null -H "Authorization: Bearer $ALICE_TOKEN" "$BASE_URL/admin/api/v1/users" + curl -fsS -o /dev/null -H "Authorization: Bearer $ALICE_TOKEN" "$BASE_URL/admin/api/v1/stats/db" +done + +log "7. Upload sample gem as alice (token-auth; user.* via fixed token middleware)" +curl -fsS -o /dev/null -u "alice:$ALICE_API_TOKEN" \ + --data-binary @"$SAMPLE_GEM" \ + -H 'Content-Type: application/octet-stream' \ + "$BASE_URL/private/api/v1/gems" || true + +log "8. Hit private read endpoints as alice" +for _ in 1 2 3 4 5; do + curl -fsS -o /dev/null -u "alice:$ALICE_API_TOKEN" "$BASE_URL/private/info/nokogiri" || true + curl -fsS -o /dev/null -u "alice:$ALICE_API_TOKEN" "$BASE_URL/private/api/v1/dependencies?gems=nokogiri" || true + curl -fsS -o /dev/null -u "alice:$ALICE_API_TOKEN" "$BASE_URL/private/names" || true +done + +log "9. Bob (read role) tries to mint a token — should 403 → error.category=auth" +BOB_TOKEN=$(curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"username":"bob","password":"bob-pw"}' \ + "$BASE_URL/admin/api/v1/login" | jq -r '.token' || echo "") +if [[ -n "$BOB_TOKEN" && "$BOB_TOKEN" != "null" ]]; then + curl -fsS -o /dev/null -H "Authorization: Bearer $BOB_TOKEN" "$BASE_URL/admin/api/v1/users" || true +fi + +log "10. Trigger a not-found path (error.category=not_found)" +curl -fsS -o /dev/null "$BASE_URL/this-route-does-not-exist" || true +curl -fsS -o /dev/null -u "alice:$ALICE_API_TOKEN" "$BASE_URL/private/info/does-not-exist-zzz" || true + +log "11. Yank the gem we uploaded" +curl -fsS -X DELETE -u "alice:$ALICE_API_TOKEN" \ + "$BASE_URL/private/api/v1/gems/yank?gem=nokogiri&version=1.15.3&platform=arm64-darwin" || true + +log "Done. Generated ~60 spans across HTTP, indexer, advisorydb, and outbound mirror calls." diff --git a/scripts/python-client/README.md b/scripts/python-client/README.md new file mode 100644 index 0000000..e0f2476 --- /dev/null +++ b/scripts/python-client/README.md @@ -0,0 +1,87 @@ +# Python clients for gemfast-server + +Two scripts that drive the local gemfast-server over real HTTP, primarily +to populate OpenTelemetry traces in Honeycomb. + +| Script | Purpose | +|---|---| +| `upload_gem.py` | One-shot cross-language trace propagation demo: login → token → upload a single sample gem. | +| `load_gems.py` | Hour-long mixed-traffic load generator: ~100 direct uploads + ~100 mirror cache fetches + continuous index polling + auth churn across six users. | + +## Prerequisites + +1. **OTel collector running**, with `HONEYCOMB_API_KEY` exported: + ```sh + cd deploy/otel-collector + HONEYCOMB_API_KEY=hcaik_... DEPLOYMENT_ENV=local docker compose up -d + ``` +2. **gemfast-server running** against the fixture HCL (which seeds the + six users this script logs in as: `alice`, `carol`, `dave`, `erin`, + `frank` with role `write` and `bob` with role `read`): + ```sh + OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + ./gemfast-server start -c test/fixtures/gemfast-local.hcl + ``` +3. Python deps: + ```sh + cd scripts/python-client + pip install -r requirements.txt + ``` + +## `load_gems.py` + +```sh +python load_gems.py +``` + +Default behaviour: 60 minutes, four worker pools, ~200 gem "installs" +total, traces exported to the local collector at `http://localhost:4318` +(the collector adds `x-honeycomb-team` and forwards to Honeycomb). + +### Environment variables + +| Var | Default | Notes | +|---|---|---| +| `BASE_URL` | `http://localhost:2020` | gemfast-server | +| `RUBYGEMS_API` | `https://rubygems.org` | upstream gem metadata + binary source | +| `DURATION_SECONDS` | `3600` | total wall-clock runtime | +| `UPLOAD_WORKERS` | `2` | direct-upload concurrency | +| `PROXY_WORKERS` | `3` | mirror-cache GET concurrency | +| `INDEX_WORKERS` | `3` | index/metadata GET concurrency | +| `TARGET_UPLOADS` | `100` | shapes per-worker sleep so the run hits this count | +| `TARGET_PROXY_FETCHES` | `100` | same, for proxy fetches | +| `GEM_LIMIT` | `250` | first N entries of `gem_list.py` to draw from | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | set to `https://api.honeycomb.io` to send direct (then `HONEYCOMB_API_KEY` is required on the script too) | +| `DEPLOYMENT_ENV` | `local` | resource attribute | +| `LOAD_RUN_ID` | random UUID | tag every span for one run | + +### Smoke test + +```sh +DURATION_SECONDS=60 TARGET_UPLOADS=3 TARGET_PROXY_FETCHES=5 python load_gems.py +``` + +Should print a one-line progress summary at the 60s mark and exit 0. + +### What to look for in Honeycomb + +- `COUNT GROUP BY user.username` — non-zero for all five write users. +- `COUNT WHERE name = "POST /private/api/v1/gems" GROUP BY gem.name` — + ~100 distinct gems. +- `COUNT WHERE name = "GET /gems/:gem"` — the mirror-cache traffic. +- `COUNT WHERE error.category = "auth"` — from the deliberate + bad-password attempts in the auth churn worker. +- Trace view on any `client.upload_gem` span — should show child server + spans from `gemfast-server`, confirming cross-language propagation. + +### Local-disk sanity check + +After a run, the proxy worker's cache misses leave files on disk: +```sh +ls /tmp/gemfast/gems/rubygems.org/*/ | wc -l +``` +should be in the high tens. + +## `upload_gem.py` + +Single-trace smoke test — see its module docstring. diff --git a/scripts/python-client/gem_list.py b/scripts/python-client/gem_list.py new file mode 100644 index 0000000..7893d92 --- /dev/null +++ b/scripts/python-client/gem_list.py @@ -0,0 +1,77 @@ +"""Curated list of popular RubyGems used by the load generator.""" + +GEMS = [ + "rake", "rails", "activesupport", "activerecord", "actionpack", "actionview", + "actionmailer", "actioncable", "activejob", "activemodel", "activestorage", + "actionmailbox", "actiontext", "railties", "sprockets", "sprockets-rails", + "puma", "unicorn", "thin", "webrick", "rack", "rack-test", "rack-protection", + "rack-attack", "rack-cors", "rack-proxy", "rackup", + "nokogiri", "nokogumbo", "loofah", "rails-html-sanitizer", + "json", "multi_json", "oj", "yajl-ruby", "psych", "toml-rb", + "sidekiq", "resque", "delayed_job", "sucker_punch", "good_job", "shoryuken", + "devise", "warden", "omniauth", "omniauth-oauth2", "omniauth-google-oauth2", + "omniauth-github", "doorkeeper", "pundit", "cancancan", "rolify", + "pg", "mysql2", "sqlite3", "redis", "redis-rails", "redis-namespace", "bunny", + "mongo", "mongoid", "elasticsearch", "elasticsearch-rails", "searchkick", + "kaminari", "will_paginate", "pagy", + "rspec", "rspec-rails", "rspec-core", "rspec-mocks", "rspec-expectations", + "rspec-support", "minitest", "minitest-reporters", "test-unit", + "factory_bot", "factory_bot_rails", "faker", "ffaker", "vcr", "webmock", + "capybara", "selenium-webdriver", "cucumber", "cucumber-rails", "rails-controller-testing", + "simplecov", "simplecov-html", "simplecov-lcov", "coveralls", "codeclimate-test-reporter", + "rubocop", "rubocop-rails", "rubocop-rspec", "rubocop-performance", "rubocop-rake", + "rubocop-minitest", "rubocop-ast", "standard", + "reek", "fasterer", "flay", "flog", "brakeman", "bundler-audit", "ruby_audit", + "yard", "rdoc", "kramdown", "redcarpet", "commonmarker", "asciidoctor", + "byebug", "pry", "pry-byebug", "pry-rails", "pry-doc", "pry-stack_explorer", + "debug", "ruby-debug-ide", "stackprof", "memory_profiler", "benchmark-ips", + "rufo", "syntax_tree", "prettier_print", + "httparty", "rest-client", "faraday", "faraday-net_http", "faraday-retry", + "faraday-multipart", "faraday-follow_redirects", "typhoeus", "excon", + "http", "net-http", "net-http-persistent", "down", "open-uri-cached", + "aws-sdk-core", "aws-sdk-s3", "aws-sdk-sns", "aws-sdk-sqs", "aws-sdk-secretsmanager", + "aws-sdk-ssm", "aws-sdk-dynamodb", "aws-sdk-cloudwatch", "aws-sdk-cloudwatchlogs", + "google-cloud-storage", "google-cloud-pubsub", "google-cloud-firestore", + "azure-storage-blob", + "stripe", "braintree", "paypal-checkout-sdk", "twilio-ruby", "sendgrid-ruby", + "mailgun-ruby", "postmark-rails", "mail", "mail-iso-2022-jp", "premailer-rails", + "letter_opener", "letter_opener_web", + "sentry-ruby", "sentry-rails", "sentry-sidekiq", "bugsnag", "rollbar", + "honeybadger", "appsignal", "newrelic_rpm", "ddtrace", "scout_apm", + "opentelemetry-sdk", "opentelemetry-api", "opentelemetry-exporter-otlp", + "opentelemetry-instrumentation-all", "opentelemetry-instrumentation-rails", + "opentelemetry-instrumentation-rack", "opentelemetry-instrumentation-net_http", + "opentelemetry-instrumentation-sidekiq", "opentelemetry-instrumentation-pg", + "prometheus-client", "yabeda", "yabeda-prometheus", "statsd-ruby", + "lograge", "semantic_logger", "logstash-logger", "amazing_print", "awesome_print", + "colorize", "rainbow", "tty-prompt", "tty-progressbar", "tty-spinner", "tty-table", + "tty-command", "tty-screen", "tty-cursor", "pastel", + "thor", "gli", "commander", "slop", "optimist", "dry-cli", "clamp", + "dry-validation", "dry-struct", "dry-types", "dry-monads", "dry-container", + "dry-system", "dry-configurable", "dry-events", "dry-effects", "dry-schema", + "trailblazer", "reform", "cells", "interactor", "wisper", "aasm", "state_machines", + "state_machines-activerecord", "paper_trail", "audited", "logidze", + "draper", "active_decorator", "active_interaction", "active_model_serializers", + "jsonapi-serializer", "fast_jsonapi", "blueprinter", "alba", "panko_serializer", + "graphql", "graphql-batch", "graphiql-rails", "apollo_upload_server", + "jbuilder", "jb", + "view_component", "phlex", "phlex-rails", "tailwindcss-rails", "tailwindcss-ruby", + "stimulus-rails", "turbo-rails", "importmap-rails", "jsbundling-rails", + "cssbundling-rails", "propshaft", "dartsass-rails", "vite_ruby", "vite_rails", + "image_processing", "mini_magick", "ruby-vips", "image_optim", "rmagick", + "carrierwave", "shrine", "paperclip", "refile", "active_storage_validations", + "redcarpet", "rouge", "coderay", "pygments.rb", + "geocoder", "country_select", "countries", "timezone", "tzinfo", "tzinfo-data", + "chronic", "chronic_duration", "groupdate", "month", "business_time", + "money", "money-rails", "monetize", "eu_central_bank", "google_currency", + "i18n", "i18n-tasks", "rails-i18n", "globalize", "mobility", "translatomatic", + "concurrent-ruby", "parallel", "ruby-progressbar", "tqdm", + "websocket-client-simple", "websocket-driver", "em-websocket", "faye-websocket", + "puma-status", + "rabbitmq-rabbit", "sneakers", "kafka-rb", "ruby-kafka", "racecar", + "graphql-ruby", "neo4j", + "validates_email_format_of", "valid_email2", "phony", "phonelib", + "uuid", "securerandom", "ulid-ruby", "snowflake-id", + "bcrypt", "argon2", "scrypt", "rbnacl", "openssl-extensions", + "jwt", "jwk", "ruby-jwt-pro", +] diff --git a/scripts/python-client/load_gems.py b/scripts/python-client/load_gems.py new file mode 100644 index 0000000..6b900db --- /dev/null +++ b/scripts/python-client/load_gems.py @@ -0,0 +1,622 @@ +""" +Gemfast load generator. + +Runs four concurrent worker pools against a local gemfast-server for a +configurable duration (default 1 hour): + + - uploader : JWT login + API token mint per write-user, then + downloads a gem from rubygems.org and POSTs it to + `/private/api/v1/gems` (Basic auth). + - proxy_fetcher : anonymous GET `/gems/-.gem` — first hit + is a cache miss, server fetches upstream and indexes. + - index_poller : anonymous reads against the mirror index endpoints + (`/specs.4.8.gz`, `/info/`, `/versions`, etc.). + - auth_churn : background login + refresh-token + bad-password + traffic across all users to populate auth telemetry. + +Honeycomb plumbing: the script exports its own spans via OTLP/HTTP. By +default it targets the local OTel collector at http://localhost:4318; +the collector is what holds HONEYCOMB_API_KEY and fans traces out to +api.honeycomb.io. If OTEL_EXPORTER_OTLP_ENDPOINT is set directly to +api.honeycomb.io, the script falls back to attaching x-honeycomb-team +itself (matches scripts/python-client/upload_gem.py:32-37). + +Run: + cd scripts/python-client + pip install -r requirements.txt + # collector must already be up with HONEYCOMB_API_KEY exported + python load_gems.py +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import random +import signal +import sys +import time +import uuid +from dataclasses import dataclass, field +from typing import Optional + +import httpx +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from gem_list import GEMS + + +WRITE_USERS = [ + ("alice", "alice-pw"), + ("carol", "carol-pw"), + ("dave", "dave-pw"), + ("erin", "erin-pw"), + ("frank", "frank-pw"), +] +READ_USERS = [("bob", "bob-pw")] +ALL_USERS = WRITE_USERS + READ_USERS + +INDEX_ENDPOINTS = [ + "/specs.4.8.gz", + "/latest_specs.4.8.gz", + "/prerelease_specs.4.8.gz", + "/versions", +] + + +@dataclass +class Config: + base_url: str + rubygems_api: str + duration_s: int + upload_workers: int + proxy_workers: int + index_workers: int + target_uploads: int + target_proxy: int + gem_limit: int + deployment_env: str + run_id: str + + +@dataclass +class Counters: + uploaded: int = 0 + proxied: int = 0 + polls: int = 0 + auth_events: int = 0 + errors: int = 0 + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def bump(self, key: str, n: int = 1) -> None: + async with self.lock: + setattr(self, key, getattr(self, key) + n) + + +@dataclass +class GemMeta: + name: str + version: str + platform: str + + @property + def filename(self) -> str: + if self.platform and self.platform != "ruby": + return f"{self.name}-{self.version}-{self.platform}.gem" + return f"{self.name}-{self.version}.gem" + + +def load_config() -> Config: + return Config( + base_url=os.environ.get("BASE_URL", "http://localhost:2020").rstrip("/"), + rubygems_api=os.environ.get("RUBYGEMS_API", "https://rubygems.org").rstrip("/"), + duration_s=int(os.environ.get("DURATION_SECONDS", "3600")), + upload_workers=int(os.environ.get("UPLOAD_WORKERS", "2")), + proxy_workers=int(os.environ.get("PROXY_WORKERS", "3")), + index_workers=int(os.environ.get("INDEX_WORKERS", "3")), + target_uploads=int(os.environ.get("TARGET_UPLOADS", "100")), + target_proxy=int(os.environ.get("TARGET_PROXY_FETCHES", "100")), + gem_limit=int(os.environ.get("GEM_LIMIT", "250")), + deployment_env=os.environ.get("DEPLOYMENT_ENV", "local"), + run_id=os.environ.get("LOAD_RUN_ID", str(uuid.uuid4())), + ) + + +def configure_tracing(cfg: Config) -> trace.Tracer: + endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + if not endpoint: + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://localhost:4318" + endpoint = os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] + if "honeycomb.io" in endpoint and not os.environ.get("OTEL_EXPORTER_OTLP_HEADERS"): + api_key = os.environ.get("HONEYCOMB_API_KEY", "") + if api_key: + os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"x-honeycomb-team={api_key}" + os.environ.setdefault("OTEL_SERVICE_NAME", "gemfast-load-generator") + + resource = Resource.create( + { + "service.name": os.environ["OTEL_SERVICE_NAME"], + "deployment.environment": cfg.deployment_env, + "client.language": "python", + "load.run_id": cfg.run_id, + "load.duration_seconds": cfg.duration_s, + } + ) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + HTTPXClientInstrumentor().instrument() + return trace.get_tracer(__name__) + + +class GemMetaCache: + """Resolves and caches `` -> GemMeta via rubygems.org public API.""" + + def __init__(self, http: httpx.AsyncClient, rubygems_api: str) -> None: + self._http = http + self._api = rubygems_api + self._cache: dict[str, GemMeta] = {} + self._negative: set[str] = set() + self._lock = asyncio.Lock() + + async def get(self, name: str) -> Optional[GemMeta]: + async with self._lock: + if name in self._cache: + return self._cache[name] + if name in self._negative: + return None + try: + resp = await self._http.get( + f"{self._api}/api/v1/gems/{name}.json", timeout=15.0 + ) + except httpx.HTTPError: + async with self._lock: + self._negative.add(name) + return None + if resp.status_code != 200: + async with self._lock: + self._negative.add(name) + return None + data = resp.json() + meta = GemMeta( + name=data["name"], + version=data["version"], + platform=data.get("platform", "ruby") or "ruby", + ) + async with self._lock: + self._cache[name] = meta + return meta + + +class GemfastClient: + """Per-user JWT + API-token holder. Re-mints on a background refresh.""" + + def __init__( + self, + http: httpx.AsyncClient, + base_url: str, + username: str, + password: str, + ) -> None: + self._http = http + self._base = base_url + self.username = username + self._password = password + self._jwt: Optional[str] = None + self._api_token: Optional[str] = None + self._lock = asyncio.Lock() + + async def ensure(self) -> str: + async with self._lock: + if self._api_token: + return self._api_token + await self._login_locked() + await self._mint_token_locked() + assert self._api_token is not None + return self._api_token + + async def refresh(self) -> None: + async with self._lock: + await self._login_locked() + await self._mint_token_locked() + + async def _login_locked(self) -> None: + resp = await self._http.post( + f"{self._base}/admin/api/v1/login", + json={"username": self.username, "password": self._password}, + timeout=15.0, + ) + resp.raise_for_status() + self._jwt = resp.json()["token"] + + async def _mint_token_locked(self) -> None: + assert self._jwt is not None + resp = await self._http.post( + f"{self._base}/admin/api/v1/token", + headers={"Authorization": f"Bearer {self._jwt}"}, + timeout=15.0, + ) + resp.raise_for_status() + self._api_token = resp.json()["token"] + + @property + def jwt(self) -> Optional[str]: + return self._jwt + + def basic_header(self) -> dict[str, str]: + assert self._api_token is not None + raw = f"{self.username}:{self._api_token}".encode() + return {"Authorization": f"Basic {base64.b64encode(raw).decode()}"} + + +class GemPool: + """Shared name pool: uploaders take exclusively; proxies sample freely.""" + + def __init__(self, names: list[str]) -> None: + self._all = list(names) + random.shuffle(self._all) + self._upload_queue = list(self._all) + self._lock = asyncio.Lock() + + async def take_for_upload(self) -> Optional[str]: + async with self._lock: + if not self._upload_queue: + return None + return self._upload_queue.pop() + + def sample_for_proxy(self) -> str: + return random.choice(self._all) + + +async def upload_worker( + worker_id: int, + cfg: Config, + tracer: trace.Tracer, + upstream: httpx.AsyncClient, + gemfast: httpx.AsyncClient, + clients: list[GemfastClient], + meta_cache: GemMetaCache, + pool: GemPool, + counters: Counters, + deadline: float, + mean_sleep_s: float, +) -> None: + while time.monotonic() < deadline: + name = await pool.take_for_upload() + if name is None: + return + client = random.choice(clients) + with tracer.start_as_current_span("client.upload_gem") as span: + span.set_attribute("worker.kind", "uploader") + span.set_attribute("worker.id", worker_id) + span.set_attribute("user.username", client.username) + span.set_attribute("gem.name", name) + span.set_attribute("load.run_id", cfg.run_id) + try: + meta = await meta_cache.get(name) + if meta is None: + span.set_attribute("error", True) + span.set_attribute("exception.slug", "rubygems-meta-missing") + await counters.bump("errors") + continue + span.set_attribute("gem.version", meta.version) + span.set_attribute("gem.platform", meta.platform) + + gem_url = f"{cfg.rubygems_api}/gems/{meta.filename}" + fetch = await upstream.get(gem_url, timeout=45.0) + if fetch.status_code != 200: + span.set_attribute("error", True) + span.set_attribute("rubygems.status_code", fetch.status_code) + span.set_attribute("exception.slug", "rubygems-download-failed") + await counters.bump("errors") + continue + gem_bytes = fetch.content + span.set_attribute("gem.size_bytes", len(gem_bytes)) + + await client.ensure() + upload = await gemfast.post( + f"{cfg.base_url}/private/api/v1/gems", + content=gem_bytes, + headers={ + **client.basic_header(), + "Content-Type": "application/octet-stream", + }, + timeout=60.0, + ) + span.set_attribute("upload.status_code", upload.status_code) + if upload.status_code >= 400: + span.set_attribute("error", True) + span.set_attribute( + "exception.slug", f"err-upload-{upload.status_code}" + ) + await counters.bump("errors") + else: + await counters.bump("uploaded") + except Exception as exc: + span.record_exception(exc) + span.set_attribute("error", True) + span.set_attribute("exception.slug", "upload-exception") + await counters.bump("errors") + + await asyncio.sleep(max(1.0, random.expovariate(1.0 / mean_sleep_s))) + + +async def proxy_worker( + worker_id: int, + cfg: Config, + tracer: trace.Tracer, + gemfast: httpx.AsyncClient, + meta_cache: GemMetaCache, + pool: GemPool, + counters: Counters, + deadline: float, + mean_sleep_s: float, +) -> None: + while time.monotonic() < deadline: + name = pool.sample_for_proxy() + with tracer.start_as_current_span("client.proxy_fetch") as span: + span.set_attribute("worker.kind", "proxy_fetcher") + span.set_attribute("worker.id", worker_id) + span.set_attribute("gem.name", name) + span.set_attribute("load.run_id", cfg.run_id) + try: + meta = await meta_cache.get(name) + if meta is None: + span.set_attribute("error", True) + span.set_attribute("exception.slug", "rubygems-meta-missing") + await counters.bump("errors") + else: + span.set_attribute("gem.version", meta.version) + span.set_attribute("gem.filename", meta.filename) + resp = await gemfast.get( + f"{cfg.base_url}/gems/{meta.filename}", timeout=90.0 + ) + span.set_attribute("proxy.status_code", resp.status_code) + span.set_attribute("proxy.bytes", len(resp.content)) + if resp.status_code >= 400: + span.set_attribute("error", True) + span.set_attribute( + "exception.slug", f"err-proxy-{resp.status_code}" + ) + await counters.bump("errors") + else: + await counters.bump("proxied") + except Exception as exc: + span.record_exception(exc) + span.set_attribute("error", True) + span.set_attribute("exception.slug", "proxy-exception") + await counters.bump("errors") + + await asyncio.sleep(max(1.0, random.expovariate(1.0 / mean_sleep_s))) + + +async def index_worker( + worker_id: int, + cfg: Config, + tracer: trace.Tracer, + gemfast: httpx.AsyncClient, + pool: GemPool, + counters: Counters, + deadline: float, + mean_sleep_s: float, +) -> None: + while time.monotonic() < deadline: + choice = random.random() + with tracer.start_as_current_span("client.index_poll") as span: + span.set_attribute("worker.kind", "index_poller") + span.set_attribute("worker.id", worker_id) + span.set_attribute("load.run_id", cfg.run_id) + try: + if choice < 0.35: + gems = ",".join( + pool.sample_for_proxy() + for _ in range(random.randint(3, 5)) + ) + url = f"{cfg.base_url}/api/v1/dependencies?gems={gems}" + endpoint = "/api/v1/dependencies" + elif choice < 0.6: + name = pool.sample_for_proxy() + url = f"{cfg.base_url}/info/{name}" + endpoint = "/info/:gem" + span.set_attribute("gem.name", name) + else: + endpoint = random.choice(INDEX_ENDPOINTS) + url = f"{cfg.base_url}{endpoint}" + span.set_attribute("index.endpoint", endpoint) + resp = await gemfast.get(url, timeout=30.0, follow_redirects=False) + span.set_attribute("index.status_code", resp.status_code) + await counters.bump("polls") + except Exception as exc: + span.record_exception(exc) + span.set_attribute("error", True) + span.set_attribute("exception.slug", "index-exception") + await counters.bump("errors") + + await asyncio.sleep(max(0.5, random.expovariate(1.0 / mean_sleep_s))) + + +async def auth_churn_worker( + cfg: Config, + tracer: trace.Tracer, + gemfast: httpx.AsyncClient, + clients: list[GemfastClient], + counters: Counters, + deadline: float, + mean_sleep_s: float, +) -> None: + while time.monotonic() < deadline: + action = random.random() + with tracer.start_as_current_span("client.auth_churn") as span: + span.set_attribute("worker.kind", "auth_churn") + span.set_attribute("load.run_id", cfg.run_id) + try: + if action < 0.5: + client = random.choice(clients) + await client.refresh() + span.set_attribute("auth.action", "relogin") + span.set_attribute("user.username", client.username) + elif action < 0.85: + client = random.choice( + [c for c in clients if c.jwt is not None] + ) + resp = await gemfast.get( + f"{cfg.base_url}/admin/api/v1/refresh-token", + headers={"Authorization": f"Bearer {client.jwt}"}, + timeout=10.0, + ) + span.set_attribute("auth.action", "refresh-token") + span.set_attribute("user.username", client.username) + span.set_attribute("auth.status_code", resp.status_code) + else: + user = random.choice(ALL_USERS)[0] + resp = await gemfast.post( + f"{cfg.base_url}/admin/api/v1/login", + json={"username": user, "password": "wrong-pw"}, + timeout=10.0, + ) + span.set_attribute("auth.action", "bad-password") + span.set_attribute("user.username", user) + span.set_attribute("auth.status_code", resp.status_code) + if resp.status_code >= 400: + span.set_attribute("error.category", "auth") + await counters.bump("auth_events") + except Exception as exc: + span.record_exception(exc) + span.set_attribute("error", True) + span.set_attribute("exception.slug", "auth-churn-exception") + await counters.bump("errors") + + await asyncio.sleep(max(1.0, random.expovariate(1.0 / mean_sleep_s))) + + +async def reporter(counters: Counters, deadline: float) -> None: + interval = 60.0 + start = time.monotonic() + while time.monotonic() < deadline: + await asyncio.sleep(interval) + elapsed = int(time.monotonic() - start) + print( + f"[load_gems] t={elapsed}s " + f"uploaded={counters.uploaded} " + f"proxied={counters.proxied} " + f"polls={counters.polls} " + f"auth_events={counters.auth_events} " + f"errors={counters.errors}", + flush=True, + ) + + +async def run() -> int: + cfg = load_config() + tracer = configure_tracing(cfg) + + seen: set[str] = set() + deduped = [g for g in GEMS if not (g in seen or seen.add(g))] + gem_names = deduped[: cfg.gem_limit] + pool = GemPool(gem_names) + counters = Counters() + + deadline = time.monotonic() + cfg.duration_s + + upload_mean = max(5.0, cfg.duration_s * cfg.upload_workers / max(1, cfg.target_uploads)) + proxy_mean = max(5.0, cfg.duration_s * cfg.proxy_workers / max(1, cfg.target_proxy)) + + print( + f"[load_gems] run_id={cfg.run_id} duration={cfg.duration_s}s " + f"target_uploads={cfg.target_uploads} target_proxy={cfg.target_proxy} " + f"upload_mean_sleep={upload_mean:.1f}s proxy_mean_sleep={proxy_mean:.1f}s", + flush=True, + ) + + timeout = httpx.Timeout(60.0, connect=10.0) + limits = httpx.Limits(max_connections=64, max_keepalive_connections=32) + + async with httpx.AsyncClient(timeout=timeout, limits=limits, http2=False) as gemfast_http, \ + httpx.AsyncClient(timeout=timeout, limits=limits, http2=False) as upstream_http: + + meta_cache = GemMetaCache(upstream_http, cfg.rubygems_api) + clients = [ + GemfastClient(gemfast_http, cfg.base_url, u, p) + for (u, p) in WRITE_USERS + ] + + loop = asyncio.get_running_loop() + stop = asyncio.Event() + + def _signal() -> None: + stop.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _signal) + except NotImplementedError: + pass + + tasks: list[asyncio.Task] = [] + for i in range(cfg.upload_workers): + tasks.append(asyncio.create_task( + upload_worker(i, cfg, tracer, upstream_http, gemfast_http, + clients, meta_cache, pool, counters, deadline, + upload_mean), + name=f"uploader-{i}", + )) + for i in range(cfg.proxy_workers): + tasks.append(asyncio.create_task( + proxy_worker(i, cfg, tracer, gemfast_http, meta_cache, pool, + counters, deadline, proxy_mean), + name=f"proxy-{i}", + )) + for i in range(cfg.index_workers): + tasks.append(asyncio.create_task( + index_worker(i, cfg, tracer, gemfast_http, pool, counters, + deadline, 6.0), + name=f"index-{i}", + )) + tasks.append(asyncio.create_task( + auth_churn_worker(cfg, tracer, gemfast_http, clients, counters, + deadline, 45.0), + name="auth-churn", + )) + tasks.append(asyncio.create_task(reporter(counters, deadline), + name="reporter")) + + async def waiter() -> None: + while time.monotonic() < deadline and not stop.is_set(): + await asyncio.sleep(1.0) + + await waiter() + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + print( + f"[load_gems] DONE run_id={cfg.run_id} " + f"uploaded={counters.uploaded} " + f"proxied={counters.proxied} " + f"polls={counters.polls} " + f"auth_events={counters.auth_events} " + f"errors={counters.errors}", + flush=True, + ) + + provider = trace.get_tracer_provider() + if hasattr(provider, "shutdown"): + provider.shutdown() + return 0 + + +def main() -> int: + try: + return asyncio.run(run()) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/python-client/requirements.txt b/scripts/python-client/requirements.txt new file mode 100644 index 0000000..e34b943 --- /dev/null +++ b/scripts/python-client/requirements.txt @@ -0,0 +1,7 @@ +opentelemetry-api>=1.27.0 +opentelemetry-sdk>=1.27.0 +opentelemetry-exporter-otlp-proto-http>=1.27.0 +opentelemetry-instrumentation-requests>=0.48b0 +opentelemetry-instrumentation-httpx>=0.48b0 +requests>=2.32.0 +httpx[http2]>=0.27.0 diff --git a/scripts/python-client/upload_gem.py b/scripts/python-client/upload_gem.py new file mode 100644 index 0000000..073d0c6 --- /dev/null +++ b/scripts/python-client/upload_gem.py @@ -0,0 +1,124 @@ +""" +Cross-language trace propagation demo. + +Starts a root span in `gemfast-python-client`, mints a JWT against the local +gemfast server, fetches a token, and uploads a sample gem — all under one +trace. The `requests` auto-instrumentation injects the W3C `traceparent` +header on every outbound call, so the gemfast Go server picks up the parent +trace and emits its own server span as a child. + +Run from the repo root: + + cd scripts/python-client + pip install -r requirements.txt + BASE_URL=http://localhost:2020 \ + HONEYCOMB_API_KEY=... \ + python upload_gem.py +""" + +import os +import sys + +import requests +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.requests import RequestsInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def configure_tracing() -> trace.Tracer: + if not os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"): + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://api.honeycomb.io" + if not os.environ.get("OTEL_EXPORTER_OTLP_HEADERS"): + api_key = os.environ.get("HONEYCOMB_API_KEY", "") + if api_key: + os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"x-honeycomb-team={api_key}" + if not os.environ.get("OTEL_SERVICE_NAME"): + os.environ["OTEL_SERVICE_NAME"] = "gemfast-python-client" + + resource = Resource.create( + { + "service.name": os.environ["OTEL_SERVICE_NAME"], + "deployment.environment": os.environ.get("DEPLOYMENT_ENV", "local"), + "client.language": "python", + } + ) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + RequestsInstrumentor().instrument() # auto-inject traceparent on every requests call + return trace.get_tracer(__name__) + + +def main() -> int: + base_url = os.environ.get("BASE_URL", "http://localhost:2020").rstrip("/") + gem_path = os.environ.get( + "SAMPLE_GEM", "../../test/fixtures/spec/nokogiri-1.15.3-arm64-darwin.gem" + ) + username = os.environ.get("GEMFAST_USER", "alice") + password = os.environ.get("GEMFAST_PASSWORD", "alice-pw") + + if not os.path.exists(gem_path): + print(f"sample gem not found at {gem_path}", file=sys.stderr) + return 1 + + tracer = configure_tracing() + + with tracer.start_as_current_span("client.upload_gem") as span: + span.set_attribute("client.language", "python") + span.set_attribute("gem.path", gem_path) + span.set_attribute("gemfast.base_url", base_url) + span.set_attribute("gemfast.username", username) + + # 1. Login → JWT + login_resp = requests.post( + f"{base_url}/admin/api/v1/login", + json={"username": username, "password": password}, + timeout=10, + ) + login_resp.raise_for_status() + jwt = login_resp.json()["token"] + span.add_event("login.complete") + + # 2. Mint API token + token_resp = requests.post( + f"{base_url}/admin/api/v1/token", + headers={"Authorization": f"Bearer {jwt}"}, + timeout=10, + ) + token_resp.raise_for_status() + api_token = token_resp.json()["token"] + span.add_event("api_token.minted") + + # 3. Upload gem + with open(gem_path, "rb") as gem_file: + upload_resp = requests.post( + f"{base_url}/private/api/v1/gems", + data=gem_file.read(), + auth=(username, api_token), + headers={"Content-Type": "application/octet-stream"}, + timeout=30, + ) + span.set_attribute("upload.status_code", upload_resp.status_code) + span.set_attribute("upload.response_size_bytes", len(upload_resp.content)) + span.add_event("upload.complete") + + if upload_resp.status_code >= 400: + span.set_attribute("error", True) + span.set_attribute( + "exception.slug", f"err-py-upload-{upload_resp.status_code}" + ) + print( + f"upload failed: {upload_resp.status_code} {upload_resp.text}", + file=sys.stderr, + ) + return 1 + + print(f"OK uploaded {gem_path} ({upload_resp.status_code})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/fixtures/gemfast-local.hcl b/test/fixtures/gemfast-local.hcl new file mode 100644 index 0000000..0e4746f --- /dev/null +++ b/test/fixtures/gemfast-local.hcl @@ -0,0 +1,52 @@ +# Local-auth config used by the OTel verification harness. +# Run: GEMFAST_CONFIG_FILE=test/fixtures/gemfast-local.hcl ./gemfast-server + +port = 2020 +log_level = "info" +dir = "/tmp/gemfast" + +auth "local" { + admin_password = "test-admin-pw" + default_user_role = "write" + allow_anonymous_read = false + + user { + username = "alice" + password = "alice-pw" + role = "write" + } + + user { + username = "bob" + password = "bob-pw" + role = "read" + } + + user { + username = "carol" + password = "carol-pw" + role = "write" + } + + user { + username = "dave" + password = "dave-pw" + role = "write" + } + + user { + username = "erin" + password = "erin-pw" + role = "write" + } + + user { + username = "frank" + password = "frank-pw" + role = "write" + } +} + +mirror "https://rubygems.org" { + enabled = true +} From 285891755cd59c121eb0e3ee97623ce57831ca9e Mon Sep 17 00:00:00 2001 From: gscho Date: Wed, 1 Jul 2026 09:28:47 -0400 Subject: [PATCH 5/5] Attributes for cache hits. Signed-off-by: gscho --- internal/api/rubygems.go | 18 ++++++++++++++++++ internal/middleware/otel_enrich.go | 14 +++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/api/rubygems.go b/internal/api/rubygems.go index 2178471..1120343 100644 --- a/internal/api/rubygems.go +++ b/internal/api/rubygems.go @@ -421,7 +421,12 @@ func (h *RubyGemsHandler) mirroredGemspecRzHandler(c *gin.Context) { } } fp := filepath.Join(h.cfg.Dir, "quick/Marshal.4.8", fileName) + span := trace.SpanFromContext(c.Request.Context()) if _, err := os.Stat(fp); errors.Is(err, os.ErrNotExist) { + span.SetAttributes( + attribute.Bool("mirror.cache_hit", false), + attribute.String("mirror.source", "upstream"), + ) out, err := os.Create(fp) if err != nil { log.Error().Err(err).Msg("failed to create gemspec.rz file") @@ -462,6 +467,10 @@ func (h *RubyGemsHandler) mirroredGemspecRzHandler(c *gin.Context) { return } } else { + span.SetAttributes( + attribute.Bool("mirror.cache_hit", true), + attribute.String("mirror.source", "local"), + ) log.Trace().Msg("serving existing gemspec.rz") } c.FileAttachment(fp, fileName) @@ -485,8 +494,13 @@ func (h *RubyGemsHandler) mirroredGemHandler(c *gin.Context) { } fc := strings.Split(fileName, "")[0] // first character fp := filepath.Join(h.cfg.GemDir, h.cfg.Mirrors[0].Hostname, fc, fileName) + span := trace.SpanFromContext(c.Request.Context()) info, err := os.Stat(fp) if (err != nil && errors.Is(err, os.ErrNotExist)) || info.Size() == 0 { + span.SetAttributes( + attribute.Bool("mirror.cache_hit", false), + attribute.String("mirror.source", "upstream"), + ) utils.MkDirs(path.Dir(fp)) out, err := os.Create(fp) if err != nil { @@ -534,6 +548,10 @@ func (h *RubyGemsHandler) mirroredGemHandler(c *gin.Context) { return } } else { + span.SetAttributes( + attribute.Bool("mirror.cache_hit", true), + attribute.String("mirror.source", "local"), + ) log.Trace().Msg("serving existing gem") } c.FileAttachment(fp, fileName) diff --git a/internal/middleware/otel_enrich.go b/internal/middleware/otel_enrich.go index c28fe1c..15448c8 100644 --- a/internal/middleware/otel_enrich.go +++ b/internal/middleware/otel_enrich.go @@ -1,9 +1,6 @@ package middleware import ( - "crypto/sha256" - "encoding/hex" - "github.com/gemfast/server/internal/db" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/attribute" @@ -11,12 +8,12 @@ import ( ) // OtelEnrich attaches investigation-useful attributes to the active server span -// after the handler runs: authenticated user identity (when present), an opaque -// session id hash, and an error.category derived from the final HTTP status. +// after the handler runs: authenticated user identity (when present) and an +// error.category derived from the final HTTP status. // // Must be installed AFTER otelgin (so a span exists) but ordering relative to // auth middleware does not matter — c.Next() drains the chain before we read -// identity, status, or cookies. +// identity or status. func OtelEnrich() gin.HandlerFunc { return func(c *gin.Context) { c.Next() @@ -39,11 +36,6 @@ func OtelEnrich() gin.HandlerFunc { } } - if cookie, err := c.Cookie("gemfast"); err == nil && cookie != "" { - sum := sha256.Sum256([]byte(cookie)) - span.SetAttributes(attribute.String("session.id_hash", hex.EncodeToString(sum[:8]))) - } - if cat := categorizeStatus(c.Writer.Status()); cat != "" { span.SetAttributes(attribute.String("error.category", cat)) }