From 3df7bd049cb333d8065fe9234cf884f88efac20d Mon Sep 17 00:00:00 2001 From: Rick Riensche Date: Wed, 1 Jul 2026 12:45:43 -0700 Subject: [PATCH 1/4] fix: add X-LaunchDarkly-Instance-Id to browser CORS allowlist (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `X-LaunchDarkly-Instance-Id` to `browser.DefaultAllowedHeaders` — the `Access-Control-Allow-Headers` value for the browser JS subrouter. ## Context Ensure CORS allowed headers include instance ID Only the browser JS subrouter needs this — the server-side and mobile subrouters bypass CORS. Not blocking today, but keeps Relay consistent with what underlying backend systems now advertise and avoids dropped browser preflights if a browser JS SDK begins sending the header. ## Changes - `internal/browser/cors.go`: append `"X-LaunchDarkly-Instance-Id"` to `DefaultAllowedHeaders`. ## Testing `go test ./internal/browser/... ./internal/middleware/...` → `ok` The existing CORS tests reference `DefaultAllowedHeaders` symbolically, so they cover the new value automatically. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Low Risk** > Single-header CORS allowlist change for browser JS with no auth or data-path impact. > > **Overview** > Adds **`X-LaunchDarkly-Instance-Id`** to `browser.DefaultAllowedHeaders`, so browser JS traffic gets that name in **`Access-Control-Allow-Headers`** via `SetCORSHeaders` and the CORS middleware. > > This aligns Relay with backends that advertise the instance ID header and avoids future browser preflight failures if the JS SDK starts sending it. Scope is the browser subrouter only; existing tests that assert against `DefaultAllowedHeaders` pick up the new value automatically. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d5324d076ec4dfc1d762733fee3a8b5e5c74982f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). Co-authored-by: Claude Opus 4.8 (1M context) (cherry picked from commit 710b5beca93b4c8b5bae6dfe39e1dd7abbfda911) --- internal/browser/cors.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/browser/cors.go b/internal/browser/cors.go index 10d0b90e..bc1b4105 100644 --- a/internal/browser/cors.go +++ b/internal/browser/cors.go @@ -29,6 +29,7 @@ var DefaultAllowedHeaders = strings.Join([]string{ //nolint:gochecknoglobals "X-LaunchDarkly-User-Agent", "X-LaunchDarkly-Payload-ID", "X-LaunchDarkly-Wrapper", + "X-LaunchDarkly-Instance-Id", events.EventSchemaHeader, events.TagsHeader, }, ",") From 41a4a98f19d47e93aac043df2c2eabd914e35058 Mon Sep 17 00:00:00 2001 From: Kane Parkinson <93555788+kparkinson-ld@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:02:49 -0700 Subject: [PATCH 2/4] fix(relay): bound REPORT eval request body size (SEC-8503) (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Requirements** - [x] I have added test coverage for new or changed functionality - [ ] I have followed the repository's pull request submission guidelines - [ ] I have validated my changes against all supported platform versions **Related issues** [SEC-8503](https://launchdarkly.atlassian.net/browse/SEC-8503) — CSA-01 LaunchDarkly Relay Proxy - Denial of Service (pen test finding). **Describe the solution you've provided** `getClientSideContextProperties` handled `REPORT` requests by reading the entire request body into memory with an unbounded `io.ReadAll(req.Body)`. Because the `http.Server` also has no `ReadTimeout` (intentional, to support streaming endpoints), an attacker could send an arbitrarily large body to the eval endpoints and drive the process to OOM. This adds a new **opt-in** config option that bounds the read: - `maxClientRequestBodySize` / `MAX_CLIENT_REQUEST_BODY_SIZE` on `[Main]` (`ct.OptBase2Bytes`). - **Default is unset = no limit**, preserving existing v8 behavior (backwards compatible). When set, the body is wrapped with `http.MaxBytesReader`, and oversized bodies get `413 Request Entity Too Large` instead of exhausting memory. ```go bodyReader := req.Body if maxBodySize.IsDefined() { bodyReader = http.MaxBytesReader(w, req.Body, int64(maxBodySize.GetOrElse(0))) } body, readErr := io.ReadAll(bodyReader) // errors.As(readErr, &maxBytesErr) -> 413 ``` The value is threaded through `evaluateAllFeatureFlags` → `getClientSideContextProperties` and `pingStreamHandlerWithContext`, so it applies at the single point where the context body is read — covering the client-side (`/sdk/evalx/{envId}/context|user`), mobile (`/msdk/evalx/context|user`), server-side evalx REPORT, and the streaming eval REPORT endpoints. A non-positive value (e.g. `0B`) is now rejected at config load by `validateMaxClientRequestBodySize` (mirroring `validateMaxInboundPayloadSize`), so an invalid limit fails fast at startup rather than silently `413`-ing all eval traffic at runtime. **Note on v9:** per review feedback, on v8 this defaults to no limit for backwards compatibility. In v9 (a major version) we can set a reasonable default (e.g. 5 MiB) that customers can override. **Describe alternatives you've considered** - A hard-coded default limit (first revision): rejected in review because customers may rely on larger bodies and there was no way to change it. - Reusing `Events.MaxInboundPayloadSize`: it defaults to unlimited and is semantically scoped to event payloads. - Setting a server-wide `ReadTimeout`: rejected because the streaming endpoints deliberately keep connections open. **Additional context** `go build`, `go vet`, `golangci-lint run`, and `go test ./relay/... ./config/...` all pass. Tests: `TestReportFlagEvalRejectsOversizedBodyWhenLimitConfigured` (413 when configured), `TestReportFlagEvalAllowsLargeBodyWhenNoLimitConfigured` (unbounded read when unset), and an invalid-config case rejecting `0B` at config load. [SEC-8503]: https://launchdarkly.atlassian.net/browse/SEC-8503 Link to Devin session: https://app.devin.ai/sessions/b730421ffad64b2e9bbeb41caa956ae8 Requested by: @kparkinson-ld (cherry picked from commit 75896347c19c94e7d84ffe523d61eb18d5eca14b) --- config/config.go | 1 + config/config_validation.go | 11 ++++++++ config/test_data_configs_invalid_test.go | 12 +++++++++ config/test_data_configs_valid_test.go | 11 ++++++++ docs/configuration.md | 3 +++ relay/relay_endpoints.go | 34 +++++++++++++++++++----- relay/relay_endpoints_benchmark_test.go | 3 ++- relay/relay_endpoints_test.go | 34 ++++++++++++++++++++++-- relay/relay_routes.go | 29 ++++++++++---------- 9 files changed, 114 insertions(+), 24 deletions(-) diff --git a/config/config.go b/config/config.go index 4570e051..791c8407 100644 --- a/config/config.go +++ b/config/config.go @@ -153,6 +153,7 @@ type MainConfig struct { GracefulShutdownTimeout ct.OptDuration `conf:"GRACEFUL_SHUTDOWN_TIMEOUT"` HeartbeatInterval ct.OptDuration `conf:"HEARTBEAT_INTERVAL"` MaxClientConnectionTime ct.OptDuration `conf:"MAX_CLIENT_CONNECTION_TIME"` + MaxClientRequestBodySize ct.OptBase2Bytes `conf:"MAX_CLIENT_REQUEST_BODY_SIZE"` PingStreamJitterTime ct.OptDuration `conf:"PING_STREAM_JITTER_TIME"` DisconnectedStatusTime ct.OptDuration `conf:"DISCONNECTED_STATUS_TIME"` TLSEnabled bool `conf:"TLS_ENABLED"` diff --git a/config/config_validation.go b/config/config_validation.go index 75bb6153..c3d39e5c 100644 --- a/config/config_validation.go +++ b/config/config_validation.go @@ -17,6 +17,7 @@ var ( errOfflineModePropertiesWithNoFile = errors.New("must specify offline mode filename if other offline mode properties are set") errOfflineModeWithEnvironments = errors.New("cannot configure specific environments if offline mode is enabled") errMaxInboundPayloadSize = errors.New("max inbound payload size must be greater than zero") + errMaxClientRequestBodySize = errors.New("max client request body size must be greater than zero") errAutoConfWithoutDBDisambig = errors.New(`when using auto-configuration with database storage, database prefix (or,` + ` if using DynamoDB, table name) must be specified and must contain "` + AutoConfigEnvironmentIDPlaceholder + `"`) errRedisURLWithHostAndPort = errors.New("please specify Redis URL or host/port, but not both") @@ -85,6 +86,7 @@ func ValidateConfig(c *Config, loggers ldlog.Loggers) error { validateOfflineMode(&result, c) validateCredentialCleanupInterval(&result, c) validateMaxInboundPayloadSize(&result, c) + validateMaxClientRequestBodySize(&result, c) return result.GetError() } @@ -234,6 +236,15 @@ func validateMaxInboundPayloadSize(result *ct.ValidationResult, c *Config) { } } +func validateMaxClientRequestBodySize(result *ct.ValidationResult, c *Config) { + if c.Main.MaxClientRequestBodySize.IsDefined() { + size := c.Main.MaxClientRequestBodySize.GetOrElse(0) + if size <= 0 { + result.AddError(nil, errMaxClientRequestBodySize) + } + } +} + func validateConfigDatabases(result *ct.ValidationResult, c *Config, loggers ldlog.Loggers) { normalizeRedisConfig(result, c) diff --git a/config/test_data_configs_invalid_test.go b/config/test_data_configs_invalid_test.go index 9d583bbc..23d4bf6f 100644 --- a/config/test_data_configs_invalid_test.go +++ b/config/test_data_configs_invalid_test.go @@ -43,9 +43,21 @@ func makeInvalidConfigs() []testDataInvalidConfig { makeInvalidConfigDynamoDBNoPrefixOrTableName(), makeInvalidConfigDynamoDBAutoConfNoPrefixOrTableName(), makeInvalidConfigMultipleDatabases(), + makeInvalidConfigMaxClientRequestBodySize("0B"), } } +func makeInvalidConfigMaxClientRequestBodySize(size string) testDataInvalidConfig { + c := testDataInvalidConfig{name: "max client request body size " + size} + c.envVarsError = errMaxClientRequestBodySize.Error() + c.envVars = map[string]string{"MAX_CLIENT_REQUEST_BODY_SIZE": size} + c.fileContent = ` +[Main] +MaxClientRequestBodySize = ` + size + ` +` + return c +} + func makeInvalidConfigMissingSDKKey() testDataInvalidConfig { c := testDataInvalidConfig{name: "environment without SDK key"} c.fileContent = ` diff --git a/config/test_data_configs_valid_test.go b/config/test_data_configs_valid_test.go index c668c7b3..91f41bff 100644 --- a/config/test_data_configs_valid_test.go +++ b/config/test_data_configs_valid_test.go @@ -49,6 +49,14 @@ func mustOptIntGreaterThanZero(n int) ct.OptIntGreaterThanZero { return o } +func mustOptBase2Bytes(s string) ct.OptBase2Bytes { + o, err := ct.NewOptBase2BytesFromString(s) + if err != nil { + panic(err) + } + return o +} + func newOptURLAbsoluteMustBeValid(urlString string) ct.OptURLAbsolute { o, err := ct.NewOptURLAbsoluteFromString(urlString) if err != nil { @@ -110,6 +118,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig { IgnoreConnectionErrors: true, HeartbeatInterval: ct.NewOptDuration(90 * time.Second), MaxClientConnectionTime: ct.NewOptDuration(30 * time.Minute), + MaxClientRequestBodySize: mustOptBase2Bytes("5MiB"), DisconnectedStatusTime: ct.NewOptDuration(3 * time.Minute), TLSEnabled: true, TLSCert: "cert", @@ -161,6 +170,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig { "IGNORE_CONNECTION_ERRORS": "1", "HEARTBEAT_INTERVAL": "90s", "MAX_CLIENT_CONNECTION_TIME": "30m", + "MAX_CLIENT_REQUEST_BODY_SIZE": "5MiB", "DISCONNECTED_STATUS_TIME": "3m", "TLS_ENABLED": "1", "TLS_CERT": "cert", @@ -203,6 +213,7 @@ ExitAlways = 1 IgnoreConnectionErrors = 1 HeartbeatInterval = 90s MaxClientConnectionTime = 30m +MaxClientRequestBodySize = "5MiB" PingStreamJitterTime = 5m DisconnectedStatusTime = 3m TLSEnabled = 1 diff --git a/docs/configuration.md b/docs/configuration.md index ab1dc530..19a2c596 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,6 +58,7 @@ For **Duration** settings, the value should be be an integer followed by `ms`, ` | `gracefulShutdownTimeout` | `GRACEFUL_SHUTDOWN_TIMEOUT` | Duration | `30s` | How long the Relay Proxy should wait for active connections to complete before forcefully shutting down when receiving a termination signal. This allows for graceful shutdown of the server, ensuring that in-flight requests are completed. The value should be a duration string like `30s` or `1m`. | | `heartbeatInterval` | `HEARTBEAT_INTERVAL` | Number | `3m` | Interval for heartbeat messages to prevent read timeouts on streaming connections. Assumed to be in seconds if no unit is specified. | | `maxClientConnectionTime` | `MAX_CLIENT_CONNECTION_TIME` | Duration | none | Maximum amount of time that Relay will allow a streaming connection from an SDK client to remain open. _(3)_ | +| `maxClientRequestBodySize` | `MAX_CLIENT_REQUEST_BODY_SIZE` | Unit | none | Maximum size of a `REPORT` request body that Relay will read when evaluating flags for a client-side, mobile, or server-side SDK. _(9)_ | | `disconnectedStatusTime` | `DISCONNECTED_STATUS_TIME` | Duration | `1m` | How long a stream connection can be interrupted before Relay reports the status as "disconnected." _(4)_ | | `tlsEnabled` | `TLS_ENABLED` | Boolean | `false` | Enable TLS on the Relay Proxy. Read: [Using TLS](./tls.md). | | `tlsCert` | `TLS_CERT` | String | | Required if `tlsEnabled` is true. Path to TLS certificate file. | @@ -132,6 +133,8 @@ To learn more, read [Forwarding events](./events.md). _(7)_ See note _(1)_ above. The default value for `eventsUri` is `https://events.launchdarkly.com`. _(8)_ The `maxInboundPayloadSize` setting is used to limit the size of the payload that the Relay Proxy will accept from an SDK. This is an optional safety feature to prevent the Relay Proxy from being overwhelmed by a very large payload. The default value is `0B` which provides no restriction on the payload size. The value should be a number followed by a unit: `B` for bytes, `KiB` for kibibytes, `MiB` for mebibytes, `GiB` for gibibytes, `TiB` for tebibytes, `PiB` for pebibytes, or `EiB` for exbibytes. For example, `100MiB` is 100 mebibytes. +_(9)_ The optional `maxClientRequestBodySize` setting limits how much of a `REPORT` evaluation request body the Relay Proxy will read into memory before decoding the context, protecting the process from memory exhaustion caused by oversized request bodies. It applies to the `evalx` context/user endpoints for client-side, mobile, and server-side SDKs. By default it is unset, meaning there is no limit (preserving existing behavior). When set, requests whose body exceeds the limit receive an HTTP `413 Request Entity Too Large` response. The value uses the same units as `maxInboundPayloadSize` (for example, `5MiB`). + ### File section: `[Environment "NAME"]` diff --git a/relay/relay_endpoints.go b/relay/relay_endpoints.go index 02fec6d1..8eac81ea 100644 --- a/relay/relay_endpoints.go +++ b/relay/relay_endpoints.go @@ -4,6 +4,7 @@ import ( "crypto/sha1" //nolint:gosec // we're not using SHA1 for encryption, just for generating an insecure hash "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -20,6 +21,7 @@ import ( "github.com/launchdarkly/ld-relay/v8/internal/streams" "github.com/launchdarkly/ld-relay/v8/internal/util" + ct "github.com/launchdarkly/go-configtypes" "github.com/launchdarkly/go-jsonstream/v3/jwriter" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" ldevents "github.com/launchdarkly/go-sdk-events/v3" @@ -33,6 +35,7 @@ import ( func getClientSideContextProperties( clientCtx relayenv.EnvContext, sdkKind basictypes.SDKKind, + maxBodySize ct.OptBase2Bytes, req *http.Request, w http.ResponseWriter, ) (ldcontext.Context, bool) { @@ -45,7 +48,24 @@ func getClientSideContextProperties( _, _ = w.Write([]byte("Content-Type must be application/json.")) return ldContext, false } - body, _ := io.ReadAll(req.Body) + bodyReader := req.Body + if maxBodySize.IsDefined() { + bodyReader = http.MaxBytesReader(w, req.Body, int64(maxBodySize.GetOrElse(0))) + } + body, readErr := io.ReadAll(bodyReader) + if readErr != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(readErr, &maxBytesErr) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write(util.ErrorJSONMsg("Request body exceeds maximum allowed size.")) + return ldContext, false + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(util.ErrorJSONMsg(readErr.Error())) + return ldContext, false + } contextDecodeErr = json.Unmarshal(body, &ldContext) } else { base64Context := mux.Vars(req)["context"] // this assumes we have used {context} as a placeholder in the route @@ -88,12 +108,12 @@ func pingStreamHandler(streamProvider streams.StreamProvider) http.Handler { // This handler is used for client-side streaming endpoints that require context properties. Currently it is // implemented the same as the ping stream once we have validated the context. -func pingStreamHandlerWithContext(sdkKind basictypes.SDKKind, streamProvider streams.StreamProvider) http.Handler { +func pingStreamHandlerWithContext(sdkKind basictypes.SDKKind, maxBodySize ct.OptBase2Bytes, streamProvider streams.StreamProvider) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { clientCtx := middleware.GetEnvContextInfo(req.Context()) clientCtx.Env.GetLoggers().Debug("Application requested client-side ping stream") - if _, ok := getClientSideContextProperties(clientCtx.Env, sdkKind, req, w); ok { + if _, ok := getClientSideContextProperties(clientCtx.Env, sdkKind, maxBodySize, req, w); ok { clientCtx.Env.GetStreamHandler(streamProvider, clientCtx.Credential).ServeHTTP(w, req) } }) @@ -184,19 +204,19 @@ func bulkEventHandler(sdkKind basictypes.SDKKind, eventsKind ldevents.EventDataK // /sdk/evalx/{envId}/user (REPORT) // /sdk/evalx/users/{context} (GET - with SDK key auth; this is a Relay-only endpoint) // /sdk/evalx/user (REPORT - with SDK key auth; this is a Relay-only endpoint) -func evaluateAllFeatureFlags(sdkKind basictypes.SDKKind) func(w http.ResponseWriter, req *http.Request) { +func evaluateAllFeatureFlags(sdkKind basictypes.SDKKind, maxBodySize ct.OptBase2Bytes) func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) { - evaluateAllShared(w, req, sdkKind) + evaluateAllShared(w, req, sdkKind, maxBodySize) } } -func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basictypes.SDKKind) { +func evaluateAllShared(w http.ResponseWriter, req *http.Request, sdkKind basictypes.SDKKind, maxBodySize ct.OptBase2Bytes) { clientCtx := middleware.GetEnvContextInfo(req.Context()) client := clientCtx.Env.GetClient() store := clientCtx.Env.GetStore() loggers := clientCtx.Env.GetLoggers() - ldContext, ok := getClientSideContextProperties(clientCtx.Env, sdkKind, req, w) + ldContext, ok := getClientSideContextProperties(clientCtx.Env, sdkKind, maxBodySize, req, w) if !ok { return } diff --git a/relay/relay_endpoints_benchmark_test.go b/relay/relay_endpoints_benchmark_test.go index d6a9d335..f4ab4b1a 100644 --- a/relay/relay_endpoints_benchmark_test.go +++ b/relay/relay_endpoints_benchmark_test.go @@ -10,6 +10,7 @@ import ( "github.com/launchdarkly/ld-relay/v8/internal/sharedtest" "github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testenv" + ct "github.com/launchdarkly/go-configtypes" "github.com/launchdarkly/go-sdk-common/v3/lduser" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" @@ -53,6 +54,6 @@ func BenchmarkEvaluateAllFlags(b *testing.B) { for i := 0; i < b.N; i++ { req := buildPreRoutedRequest("REPORT", userData, headers, nil, ctx) resp := httptest.NewRecorder() - evaluateAllFeatureFlags(basictypes.JSClientSDK)(resp, req) + evaluateAllFeatureFlags(basictypes.JSClientSDK, ct.OptBase2Bytes{})(resp, req) } } diff --git a/relay/relay_endpoints_test.go b/relay/relay_endpoints_test.go index ddb3142a..222ed631 100644 --- a/relay/relay_endpoints_test.go +++ b/relay/relay_endpoints_test.go @@ -12,6 +12,7 @@ import ( st "github.com/launchdarkly/ld-relay/v8/internal/sharedtest" "github.com/launchdarkly/ld-relay/v8/internal/sharedtest/testenv" + ct "github.com/launchdarkly/go-configtypes" "github.com/launchdarkly/go-test-helpers/v3/jsonhelpers" "github.com/gorilla/mux" @@ -35,7 +36,7 @@ func TestReportFlagEvalFailsWithUninitializedClientAndStore(t *testing.T) { ctx := testenv.NewTestEnvContext("", false, st.MakeStoreWithData(false)) req := buildPreRoutedRequest("REPORT", []byte(`{"key": "my-user"}`), headers, nil, ctx) resp := httptest.NewRecorder() - evaluateAllFeatureFlags(basictypes.JSClientSDK)(resp, req) + evaluateAllFeatureFlags(basictypes.JSClientSDK, ct.OptBase2Bytes{})(resp, req) assert.Equal(t, http.StatusServiceUnavailable, resp.Code) @@ -44,13 +45,42 @@ func TestReportFlagEvalFailsWithUninitializedClientAndStore(t *testing.T) { assert.JSONEq(t, `{"message":"Service not initialized"}`, string(b)) } +func TestReportFlagEvalRejectsOversizedBodyWhenLimitConfigured(t *testing.T) { + headers := make(http.Header) + headers.Set("Content-Type", "application/json") + ctx := testenv.NewTestEnvContext("", false, st.MakeStoreWithData(true)) + + maxBodySize, _ := ct.NewOptBase2BytesFromString("1KiB") + oversized := make([]byte, 1024*2) + for i := range oversized { + oversized[i] = 'a' + } + req := buildPreRoutedRequest("REPORT", oversized, headers, nil, ctx) + resp := httptest.NewRecorder() + evaluateAllFeatureFlags(basictypes.JSClientSDK, maxBodySize)(resp, req) + + assert.Equal(t, http.StatusRequestEntityTooLarge, resp.Code) +} + +func TestReportFlagEvalAllowsLargeBodyWhenNoLimitConfigured(t *testing.T) { + headers := make(http.Header) + headers.Set("Content-Type", "application/json") + ctx := testenv.NewTestEnvContext("", false, st.MakeStoreWithData(true)) + + req := buildPreRoutedRequest("REPORT", jsonhelpers.ToJSON(st.BasicUserForTestFlags), headers, nil, ctx) + resp := httptest.NewRecorder() + evaluateAllFeatureFlags(basictypes.JSClientSDK, ct.OptBase2Bytes{})(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) +} + func TestReportFlagEvalWorksWithUninitializedClientButInitializedStore(t *testing.T) { headers := make(http.Header) headers.Set("Content-Type", "application/json") ctx := testenv.NewTestEnvContext("", false, st.MakeStoreWithData(true)) req := buildPreRoutedRequest("REPORT", jsonhelpers.ToJSON(st.BasicUserForTestFlags), headers, nil, ctx) resp := httptest.NewRecorder() - evaluateAllFeatureFlags(basictypes.JSClientSDK)(resp, req) + evaluateAllFeatureFlags(basictypes.JSClientSDK, ct.OptBase2Bytes{})(resp, req) assert.Equal(t, http.StatusOK, resp.Code) diff --git a/relay/relay_routes.go b/relay/relay_routes.go index b845dcf7..2d169244 100644 --- a/relay/relay_routes.go +++ b/relay/relay_routes.go @@ -47,6 +47,7 @@ func (r *Relay) makeRouter() *mux.Router { mobileKeySelector := middleware.SelectEnvironmentByAuthorizationKey(basictypes.MobileSDK, environmentGetters) jsClientSelector := middleware.SelectEnvironmentByAuthorizationKey(basictypes.JSClientSDK, environmentGetters) offlineMode := r.config.OfflineMode.FileDataSource != "" + maxClientRequestBodySize := r.config.Main.MaxClientRequestBodySize // Client-side evaluation (for JS, not mobile) jsClientSideMiddlewareStack := func(subrouter *mux.Router) mux.MiddlewareFunc { @@ -65,10 +66,10 @@ func (r *Relay) makeRouter() *mux.Router { clientSideSdkEvalXRouter := router.PathPrefix("/sdk/evalx/{envId}/").Subrouter() clientSideSdkEvalXRouter.Use(jsClientSideMiddlewareStack(clientSideSdkEvalXRouter)) - clientSideSdkEvalXRouter.HandleFunc("/contexts/{context}", evaluateAllFeatureFlags(basictypes.JSClientSDK)).Methods("GET", "OPTIONS") - clientSideSdkEvalXRouter.HandleFunc("/context", evaluateAllFeatureFlags(basictypes.JSClientSDK)).Methods("REPORT", "OPTIONS") - clientSideSdkEvalXRouter.HandleFunc("/users/{context}", evaluateAllFeatureFlags(basictypes.JSClientSDK)).Methods("GET", "OPTIONS") - clientSideSdkEvalXRouter.HandleFunc("/user", evaluateAllFeatureFlags(basictypes.JSClientSDK)).Methods("REPORT", "OPTIONS") + clientSideSdkEvalXRouter.HandleFunc("/contexts/{context}", evaluateAllFeatureFlags(basictypes.JSClientSDK, maxClientRequestBodySize)).Methods("GET", "OPTIONS") + clientSideSdkEvalXRouter.HandleFunc("/context", evaluateAllFeatureFlags(basictypes.JSClientSDK, maxClientRequestBodySize)).Methods("REPORT", "OPTIONS") + clientSideSdkEvalXRouter.HandleFunc("/users/{context}", evaluateAllFeatureFlags(basictypes.JSClientSDK, maxClientRequestBodySize)).Methods("GET", "OPTIONS") + clientSideSdkEvalXRouter.HandleFunc("/user", evaluateAllFeatureFlags(basictypes.JSClientSDK, maxClientRequestBodySize)).Methods("REPORT", "OPTIONS") serverSideMiddlewareStack := middleware.Chain( sdkKeySelector, @@ -82,12 +83,12 @@ func (r *Relay) makeRouter() *mux.Router { // serverSideSdkRouter.Use(serverSideMiddlewareStack) serverSideEvalXRouter := serverSideSdkRouter.PathPrefix("/evalx/").Subrouter() - serverSideEvalXRouter.Handle("/contexts/{context}", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK))))).Methods("GET") - serverSideEvalXRouter.Handle("/context", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK))))).Methods("REPORT") + serverSideEvalXRouter.Handle("/contexts/{context}", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK, maxClientRequestBodySize))))).Methods("GET") + serverSideEvalXRouter.Handle("/context", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK, maxClientRequestBodySize))))).Methods("REPORT") // /users and /user are obsolete names for /contexts and /context, still used by some supported SDKs; the handler is // the same, because in both cases LD accepts any valid user *or* context JSON. - serverSideEvalXRouter.Handle("/users/{context}", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK))))).Methods("GET") - serverSideEvalXRouter.Handle("/user", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK))))).Methods("REPORT") + serverSideEvalXRouter.Handle("/users/{context}", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK, maxClientRequestBodySize))))).Methods("GET") + serverSideEvalXRouter.Handle("/user", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(evaluateAllFeatureFlags(basictypes.ServerSDK, maxClientRequestBodySize))))).Methods("REPORT") // PHP SDK endpoints serverSideSdkRouter.Handle("/flags", serverSideMiddlewareStack(middleware.PollingRequestCount(http.HandlerFunc(pollAllFlagsHandler)))).Methods("GET") @@ -104,16 +105,16 @@ func (r *Relay) makeRouter() *mux.Router { msdkRouter.Use(mobileMiddlewareStack) msdkEvalXRouter := msdkRouter.PathPrefix("/evalx/").Subrouter() - msdkEvalXRouter.HandleFunc("/contexts/{context}", evaluateAllFeatureFlags(basictypes.MobileSDK)).Methods("GET") - msdkEvalXRouter.HandleFunc("/context", evaluateAllFeatureFlags(basictypes.MobileSDK)).Methods("REPORT") + msdkEvalXRouter.HandleFunc("/contexts/{context}", evaluateAllFeatureFlags(basictypes.MobileSDK, maxClientRequestBodySize)).Methods("GET") + msdkEvalXRouter.HandleFunc("/context", evaluateAllFeatureFlags(basictypes.MobileSDK, maxClientRequestBodySize)).Methods("REPORT") // /users and /user are obsolete names for /contexts and /context, still used by some supported SDKs; the handler is // the same, because in both cases LD accepts any valid user *or* context JSON. - msdkEvalXRouter.HandleFunc("/users/{context}", evaluateAllFeatureFlags(basictypes.MobileSDK)).Methods("GET") - msdkEvalXRouter.HandleFunc("/user", evaluateAllFeatureFlags(basictypes.MobileSDK)).Methods("REPORT") + msdkEvalXRouter.HandleFunc("/users/{context}", evaluateAllFeatureFlags(basictypes.MobileSDK, maxClientRequestBodySize)).Methods("GET") + msdkEvalXRouter.HandleFunc("/user", evaluateAllFeatureFlags(basictypes.MobileSDK, maxClientRequestBodySize)).Methods("REPORT") mobileStreamRouter := router.PathPrefix("/meval").Subrouter() mobileStreamRouter.Use(mobileMiddlewareStack, middleware.Streaming) - mobilePingWithUser := pingStreamHandlerWithContext(basictypes.MobileSDK, r.mobileStreamProvider) + mobilePingWithUser := pingStreamHandlerWithContext(basictypes.MobileSDK, maxClientRequestBodySize, r.mobileStreamProvider) mobileStreamRouter.Handle("", middleware.UsageActivityStreamMonitoring(metrics.MobilePlatformCategory, middleware.CountMobileConns(mobilePingWithUser))).Methods("REPORT") mobileStreamRouter.Handle("/{context}", middleware.UsageActivityStreamMonitoring(metrics.MobilePlatformCategory, middleware.CountMobileConns(mobilePingWithUser))).Methods("GET") @@ -121,7 +122,7 @@ func (r *Relay) makeRouter() *mux.Router { middleware.UsageActivityStreamMonitoring(metrics.MobilePlatformCategory, middleware.CountMobileConns(middleware.Streaming(pingStreamHandler(r.mobileStreamProvider)))))).Methods("GET") jsPing := pingStreamHandler(r.jsClientStreamProvider) - jsPingWithUser := pingStreamHandlerWithContext(basictypes.JSClientSDK, r.jsClientStreamProvider) + jsPingWithUser := pingStreamHandlerWithContext(basictypes.JSClientSDK, maxClientRequestBodySize, r.jsClientStreamProvider) clientSidePingRouter := router.PathPrefix("/ping/{envId}").Subrouter() clientSidePingRouter.Use(jsClientSideMiddlewareStack(clientSidePingRouter), middleware.Streaming) From 307995db9be41e432421cda0b8cb51598c015ebf Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 20 Jul 2026 11:13:05 -0400 Subject: [PATCH 3/4] feat: Make usage metrics event publisher capacity configurable (#750) (cherry picked from commit 71d475ae93aae3c94650a911b150bc0e64a3806a) --- config/config.go | 17 +++++++++++ config/config_validation.go | 18 ++++++++++++ config/config_validation_test.go | 39 +++++++++++++++++++++++++ config/test_data_configs_valid_test.go | 3 ++ docs/configuration.md | 6 ++-- internal/events/event_publisher.go | 36 +++++++++++++++++++---- internal/events/event_publisher_test.go | 37 +++++++++++++++++++++++ internal/relayenv/env_context_impl.go | 4 ++- 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 config/config_validation_test.go diff --git a/config/config.go b/config/config.go index 791c8407..5fe62830 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,17 @@ const ( // DefaultEventCapacity is the default value for EventsConfig.Capacity if not specified. DefaultEventCapacity = 1000 + // DefaultMetricsCapacity is the default value for EventsConfig.MetricsCapacity if not specified. + // This is the maximum queue capacity for the usage-metrics event publisher, which emits one event + // per concurrent unique connection on each flush. It is set well above DefaultEventCapacity because + // high-concurrency nodes routinely exceed 1000 unique connections. + DefaultMetricsCapacity = 10000 + + // DefaultMetricsInitialCapacity is the number of events the usage-metrics publisher queue + // preallocates space for. The queue grows on demand from this size up to MetricsCapacity, so that + // the higher maximum does not reserve all of its memory up front on nodes that never reach it. + DefaultMetricsInitialCapacity = 1000 + // DefaultHeartbeatInterval is the default value for MainConfig.HeartBeatInterval if not specified. DefaultHeartbeatInterval = time.Minute * 3 @@ -95,6 +106,11 @@ const ( // credentials to be revoked nearly instantaneously. It is not necessarily a recommendation. // It likely doesn't make sense to use an interval this frequent in production use-cases. minimumCredentialCleanupInterval = 100 * time.Millisecond + // minimumMetricsCapacity is the smallest value accepted for EventsConfig.MetricsCapacity. Usage + // metrics are how LaunchDarkly reports on account usage, so we do not allow the maximum queue + // capacity to be shrunk below the historical default of 1000; smaller configured values are + // clamped up to this floor. + minimumMetricsCapacity = 1000 ) // DefaultLoggers is the default logging configuration used by Relay. @@ -197,6 +213,7 @@ type EventsConfig struct { SendEvents bool `conf:"USE_EVENTS"` FlushInterval ct.OptDuration `conf:"EVENTS_FLUSH_INTERVAL"` Capacity ct.OptIntGreaterThanZero `conf:"EVENTS_CAPACITY"` + MetricsCapacity ct.OptIntGreaterThanZero `conf:"EVENTS_METRICS_CAPACITY"` InlineUsers bool `conf:"EVENTS_INLINE_USERS"` MaxInboundPayloadSize ct.OptBase2Bytes `conf:"EVENTS_MAX_INBOUND_PAYLOAD_SIZE"` } diff --git a/config/config_validation.go b/config/config_validation.go index c3d39e5c..6fd17a56 100644 --- a/config/config_validation.go +++ b/config/config_validation.go @@ -31,6 +31,8 @@ var ( errInvalidCredentialCleanupInterval = fmt.Errorf("expired credential cleanup interval must be >= %s", minimumCredentialCleanupInterval) ) +const warnMetricsCapacityBelowMinimum = "configured usage metrics event capacity of %d is below the minimum of %d; using %[2]d instead" + func errEnvironmentWithNoSDKKey(envName string) error { return fmt.Errorf("SDK key is required for environment %q", envName) } @@ -87,6 +89,7 @@ func ValidateConfig(c *Config, loggers ldlog.Loggers) error { validateCredentialCleanupInterval(&result, c) validateMaxInboundPayloadSize(&result, c) validateMaxClientRequestBodySize(&result, c) + validateMetricsCapacity(c, loggers) return result.GetError() } @@ -245,6 +248,21 @@ func validateMaxClientRequestBodySize(result *ct.ValidationResult, c *Config) { } } +// validateMetricsCapacity enforces the minimum queue capacity for the usage-metrics event publisher. +// Rather than fail startup on a too-small value, it clamps the value up to the minimum and warns, so +// that a misconfiguration never prevents Relay from running while still protecting usage telemetry. +func validateMetricsCapacity(c *Config, loggers ldlog.Loggers) { + if !c.Events.MetricsCapacity.IsDefined() { + return + } + if c.Events.MetricsCapacity.GetOrElse(0) < minimumMetricsCapacity { + loggers.Warnf(warnMetricsCapacityBelowMinimum, c.Events.MetricsCapacity.GetOrElse(0), minimumMetricsCapacity) + // This value is a constant known to be greater than zero, so the constructor cannot fail. + clamped, _ := ct.NewOptIntGreaterThanZero(minimumMetricsCapacity) + c.Events.MetricsCapacity = clamped + } +} + func validateConfigDatabases(result *ct.ValidationResult, c *Config, loggers ldlog.Loggers) { normalizeRedisConfig(result, c) diff --git a/config/config_validation_test.go b/config/config_validation_test.go new file mode 100644 index 00000000..571b176f --- /dev/null +++ b/config/config_validation_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/go-sdk-common/v3/ldlog" + "github.com/launchdarkly/go-sdk-common/v3/ldlogtest" +) + +func TestValidateMetricsCapacity(t *testing.T) { + t.Run("unset value is left undefined", func(t *testing.T) { + var c Config + mockLog := ldlogtest.NewMockLog() + require.NoError(t, ValidateConfig(&c, mockLog.Loggers)) + assert.False(t, c.Events.MetricsCapacity.IsDefined()) + assert.Len(t, mockLog.GetOutput(ldlog.Warn), 0) + }) + + t.Run("value at or above the minimum is left unchanged", func(t *testing.T) { + var c Config + c.Events.MetricsCapacity = mustOptIntGreaterThanZero(2000) + mockLog := ldlogtest.NewMockLog() + require.NoError(t, ValidateConfig(&c, mockLog.Loggers)) + assert.Equal(t, 2000, c.Events.MetricsCapacity.GetOrElse(0)) + assert.Len(t, mockLog.GetOutput(ldlog.Warn), 0) + }) + + t.Run("value below the minimum is clamped up and warns", func(t *testing.T) { + var c Config + c.Events.MetricsCapacity = mustOptIntGreaterThanZero(500) + mockLog := ldlogtest.NewMockLog() + require.NoError(t, ValidateConfig(&c, mockLog.Loggers)) + assert.Equal(t, minimumMetricsCapacity, c.Events.MetricsCapacity.GetOrElse(0)) + mockLog.AssertMessageMatch(t, true, ldlog.Warn, "usage metrics event capacity of 500 is below the minimum of 1000") + }) +} diff --git a/config/test_data_configs_valid_test.go b/config/test_data_configs_valid_test.go index 91f41bff..9822295d 100644 --- a/config/test_data_configs_valid_test.go +++ b/config/test_data_configs_valid_test.go @@ -135,6 +135,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig { EventsURI: newOptURLAbsoluteMustBeValid("http://events"), FlushInterval: ct.NewOptDuration(120 * time.Second), Capacity: mustOptIntGreaterThanZero(500), + MetricsCapacity: mustOptIntGreaterThanZero(50000), InlineUsers: true, MaxInboundPayloadSize: ct.OptBase2Bytes{}, } @@ -183,6 +184,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig { "EVENTS_HOST": "http://events", "EVENTS_FLUSH_INTERVAL": "120s", "EVENTS_CAPACITY": "500", + "EVENTS_METRICS_CAPACITY": "50000", "EVENTS_INLINE_USERS": "1", "LD_ENV_earth": "earth-sdk", "LD_MOBILE_KEY_earth": "earth-mob", @@ -230,6 +232,7 @@ SendEvents = 1 EventsUri = "http://events" FlushInterval = 120s Capacity = 500 +MetricsCapacity = 50000 InlineUsers = 1 [Environment "earth"] diff --git a/docs/configuration.md b/docs/configuration.md index 19a2c596..1f76b3ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,7 +58,7 @@ For **Duration** settings, the value should be be an integer followed by `ms`, ` | `gracefulShutdownTimeout` | `GRACEFUL_SHUTDOWN_TIMEOUT` | Duration | `30s` | How long the Relay Proxy should wait for active connections to complete before forcefully shutting down when receiving a termination signal. This allows for graceful shutdown of the server, ensuring that in-flight requests are completed. The value should be a duration string like `30s` or `1m`. | | `heartbeatInterval` | `HEARTBEAT_INTERVAL` | Number | `3m` | Interval for heartbeat messages to prevent read timeouts on streaming connections. Assumed to be in seconds if no unit is specified. | | `maxClientConnectionTime` | `MAX_CLIENT_CONNECTION_TIME` | Duration | none | Maximum amount of time that Relay will allow a streaming connection from an SDK client to remain open. _(3)_ | -| `maxClientRequestBodySize` | `MAX_CLIENT_REQUEST_BODY_SIZE` | Unit | none | Maximum size of a `REPORT` request body that Relay will read when evaluating flags for a client-side, mobile, or server-side SDK. _(9)_ | +| `maxClientRequestBodySize` | `MAX_CLIENT_REQUEST_BODY_SIZE` | Unit | none | Maximum size of a `REPORT` request body that Relay will read when evaluating flags for a client-side, mobile, or server-side SDK. _(10)_ | | `disconnectedStatusTime` | `DISCONNECTED_STATUS_TIME` | Duration | `1m` | How long a stream connection can be interrupted before Relay reports the status as "disconnected." _(4)_ | | `tlsEnabled` | `TLS_ENABLED` | Boolean | `false` | Enable TLS on the Relay Proxy. Read: [Using TLS](./tls.md). | | `tlsCert` | `TLS_CERT` | String | | Required if `tlsEnabled` is true. Path to TLS certificate file. | @@ -127,13 +127,15 @@ To learn more, read [Forwarding events](./events.md). | `eventsUri` | `EVENTS_HOST` | URI | _(7)_ | URI for the LaunchDarkly events service | | `flushInterval` | `EVENTS_FLUSH_INTERVAL` | Duration | `5s` | Controls how long the SDK buffers events before sending them back to our server. If your server generates many events per second, we suggest decreasing the flush interval and/or increasing capacity to meet your needs. | | `capacity` | `EVENTS_CAPACITY` | Number | `1000` | Maximum number of events to accumulate for each flush interval. | +| `metricsCapacity` | `EVENTS_METRICS_CAPACITY` | Number | `10000` | Queue capacity for the usage metrics event publisher, which reports connection usage to LaunchDarkly independently of `capacity`. See note _(9)_. | | `inlineUsers` | `EVENTS_INLINE_USERS` | Boolean | `false` | When enabled, individual events (if full event tracking is enabled for the feature flag) will contain all non-private user attributes. | | `maxInboundPayloadSize` | `EVENTS_MAX_INBOUND_PAYLOAD_SIZE` | Unit | _(8)_ | Maximum size of an event payload the Relay Proxy will accept from an SDK. | _(7)_ See note _(1)_ above. The default value for `eventsUri` is `https://events.launchdarkly.com`. _(8)_ The `maxInboundPayloadSize` setting is used to limit the size of the payload that the Relay Proxy will accept from an SDK. This is an optional safety feature to prevent the Relay Proxy from being overwhelmed by a very large payload. The default value is `0B` which provides no restriction on the payload size. The value should be a number followed by a unit: `B` for bytes, `KiB` for kibibytes, `MiB` for mebibytes, `GiB` for gibibytes, `TiB` for tebibytes, `PiB` for pebibytes, or `EiB` for exbibytes. For example, `100MiB` is 100 mebibytes. +_(9)_ The `metricsCapacity` setting controls the queue for usage metrics events, which report connection usage back to LaunchDarkly and are separate from the analytics events governed by `capacity`. The Relay Proxy emits one usage metrics event per concurrent unique connection on each flush, so this should be set to at least the number of concurrent unique connections you expect a single node to serve. This is the maximum capacity: the default is `10000` and the minimum is `1000` (smaller values are clamped up to `1000` with a warning). The queue is an in-memory buffer held per environment that starts small and grows on demand up to this maximum, so its memory footprint tracks the number of concurrent connections actually served rather than the configured maximum. -_(9)_ The optional `maxClientRequestBodySize` setting limits how much of a `REPORT` evaluation request body the Relay Proxy will read into memory before decoding the context, protecting the process from memory exhaustion caused by oversized request bodies. It applies to the `evalx` context/user endpoints for client-side, mobile, and server-side SDKs. By default it is unset, meaning there is no limit (preserving existing behavior). When set, requests whose body exceeds the limit receive an HTTP `413 Request Entity Too Large` response. The value uses the same units as `maxInboundPayloadSize` (for example, `5MiB`). +_(10)_ The optional `maxClientRequestBodySize` setting limits how much of a `REPORT` evaluation request body the Relay Proxy will read into memory before decoding the context, protecting the process from memory exhaustion caused by oversized request bodies. It applies to the `evalx` context/user endpoints for client-side, mobile, and server-side SDKs. By default it is unset, meaning there is no limit (preserving existing behavior). When set, requests whose body exceeds the limit receive an HTTP `413 Request Entity Too Large` response. The value uses the same units as `maxInboundPayloadSize` (for example, `5MiB`). ### File section: `[Environment "NAME"]` diff --git a/internal/events/event_publisher.go b/internal/events/event_publisher.go index fd373ced..19c64aab 100644 --- a/internal/events/event_publisher.go +++ b/internal/events/event_publisher.go @@ -103,10 +103,11 @@ type HTTPEventPublisher struct { disableQueue chan interface{} disabled bool - queues map[EventPayloadMetadata]*publisherQueue - capacity int - overflowed bool - lock sync.RWMutex + queues map[EventPayloadMetadata]*publisherQueue + capacity int + initialCapacity int + overflowed bool + lock sync.RWMutex } type eventBatch struct { @@ -159,6 +160,19 @@ func (o OptionCapacity) apply(p *HTTPEventPublisher) error { return nil } +// OptionInitialCapacity specifies how many events to preallocate space for in each event queue. +// The queue still grows on demand (via append) up to OptionCapacity, and events are only dropped +// once OptionCapacity is reached; this option only controls the initial allocation so that a +// publisher with a large capacity does not reserve all of that memory up front. If unset, or not +// smaller than the capacity, the full capacity is preallocated, preserving the original behavior. +type OptionInitialCapacity int + +//nolint:unparam // the error result is required by the OptionType interface +func (o OptionInitialCapacity) apply(p *HTTPEventPublisher) error { + p.initialCapacity = int(o) + return nil +} + // NewHTTPEventPublisher creates a new HTTPEventPublisher. func NewHTTPEventPublisher(authKey credential.SDKCredential, httpConfig httpconfig.HTTPConfig, loggers ldlog.Loggers, options ...OptionType) (*HTTPEventPublisher, error) { closer := make(chan struct{}) @@ -243,10 +257,22 @@ func NewHTTPEventPublisher(authKey credential.SDKCredential, httpConfig httpconf return p, nil } +// initialQueueCapacity returns the number of events to preallocate space for in a new queue. +// It is the smaller of the configured initial capacity and the maximum capacity; when no initial +// capacity is configured (<= 0), the full maximum capacity is preallocated, which is the original +// behavior. The queue can still grow (via append) up to the maximum capacity regardless. +func initialQueueCapacity(capacity, initialCapacity int) int { + if initialCapacity > 0 && initialCapacity < capacity { + return initialCapacity + } + return capacity +} + func (p *HTTPEventPublisher) append(batch eventBatch) { queue := p.queues[batch.metadata] if queue == nil { - queue = &publisherQueue{events: make([]json.RawMessage, 0, p.capacity)} + // The queue still grows up to p.capacity via append regardless of the initial allocation. + queue = &publisherQueue{events: make([]json.RawMessage, 0, initialQueueCapacity(p.capacity, p.initialCapacity))} p.queues[batch.metadata] = queue } available := p.capacity - len(queue.events) diff --git a/internal/events/event_publisher_test.go b/internal/events/event_publisher_test.go index c44e9ba2..6a67ce76 100644 --- a/internal/events/event_publisher_test.go +++ b/internal/events/event_publisher_test.go @@ -189,6 +189,43 @@ func TestHTTPEventPublisherCapacity(t *testing.T) { }) } +func TestInitialQueueCapacity(t *testing.T) { + // Unset initial capacity preallocates the full capacity -- the original behavior, used by the + // analytics publisher, which never sets OptionInitialCapacity. + assert.Equal(t, 1000, initialQueueCapacity(1000, 0)) + assert.Equal(t, 10000, initialQueueCapacity(10000, 0)) + // A smaller initial capacity is used as-is, letting the queue start small and grow. + assert.Equal(t, 1000, initialQueueCapacity(10000, 1000)) + // The initial allocation is never larger than the maximum capacity. + assert.Equal(t, 1000, initialQueueCapacity(1000, 1000)) + assert.Equal(t, 1000, initialQueueCapacity(1000, 5000)) +} + +func TestHTTPEventPublisherInitialCapacityGrowsToCapacity(t *testing.T) { + // With an initial capacity smaller than the (maximum) capacity, the queue must still grow past + // the initial allocation and only drop events once the maximum capacity is reached. + mockLog := ldlogtest.NewMockLog() + defer mockLog.DumpIfTestFailed(t) + handler, requestsCh := httphelpers.RecordingHandler(httphelpers.HandlerWithStatus(202)) + httphelpers.WithServer(handler, func(server *httptest.Server) { + publisher, _ := NewHTTPEventPublisher(config.SDKKey("my-key"), defaultHTTPConfig(), mockLog.Loggers, + OptionBaseURI(server.URL), OptionCapacity(3), OptionInitialCapacity(1)) + defer publisher.Close() + publisher.Publish(EventPayloadMetadata{}, json.RawMessage(`"a"`)) + publisher.Publish(EventPayloadMetadata{}, json.RawMessage(`"b"`)) + publisher.Publish(EventPayloadMetadata{}, json.RawMessage(`"c"`)) + publisher.Publish(EventPayloadMetadata{}, json.RawMessage(`"d"`)) + publisher.Flush() + r := helpers.RequireValue(t, requestsCh, time.Second) + + uncompressed, err := util.DecompressGzipData(r.Body) + assert.NoError(t, err) + + // The queue grew from the initial capacity of 1 up to the capacity of 3, then dropped "d". + m.In(t).Assert(uncompressed, m.JSONStrEqual(`["a","b","c"]`)) + }) +} + func TestHTTPEventPublisherErrorRetry(t *testing.T) { testRecoverableError := func(t *testing.T, errorHandler http.Handler) { mockLog := ldlogtest.NewMockLog() diff --git a/internal/relayenv/env_context_impl.go b/internal/relayenv/env_context_impl.go index ce23392f..ce17d5b7 100644 --- a/internal/relayenv/env_context_impl.go +++ b/internal/relayenv/env_context_impl.go @@ -311,7 +311,9 @@ func NewEnvContext( pubLoggers := envLoggers pubLoggers.SetPrefix(logPrefix + " (usage metrics)") eventsPublisher, err := events.NewHTTPEventPublisher(envConfig.SDKKey, httpConfig, pubLoggers, - events.OptionBaseURI(eventsURI)) + events.OptionBaseURI(eventsURI), + events.OptionCapacity(allConfig.Events.MetricsCapacity.GetOrElse(config.DefaultMetricsCapacity)), + events.OptionInitialCapacity(config.DefaultMetricsInitialCapacity)) if err != nil { return nil, errInitPublisher(err) } From 2cf0c684e276294b9e31a07c57340e76e9a10041 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 31 Jul 2026 15:49:36 -0700 Subject: [PATCH 4/4] fix(deps): bump otel and klauspost/compress to patch disclosed CVEs Docker Scout was failing on every v8 image variant (alpine, distroless, distroless-debug) with two findings: - CVE-2026-41178 (medium) in go.opentelemetry.io/otel v1.43.0, fixed in v1.44.0. Bumping otel carried otel/metric and otel/trace to v1.44.0 as well, keeping the family consistent; grpc did not need to move. - GHSA-259r-337f-4rfw in github.com/klauspost/compress v1.18.5, which affects 1.16.0 through 1.18.6 and is fixed in v1.18.7. Dependency-only change: no Relay source changes. (cherry picked from commit efb319f294c3098024e4f90f6d1d112f6c8e0066) --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 8cf054e4..98ebba00 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.7 github.com/launchdarkly/api-client-go/v13 v13.0.1-0.20230420175109-f5469391a13e golang.org/x/crypto v0.53.0 ) @@ -123,9 +123,9 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index 476a5716..d909c213 100644 --- a/go.sum +++ b/go.sum @@ -314,8 +314,8 @@ github.com/kardianos/minwinsvc v1.0.2/go.mod h1:LUZNYhNmxujx2tR7FbdxqYJ9XDDoCd3M github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -520,16 +520,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +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.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=