From 6d61262ed3dc4324b5b17fbcb19d92db174c38ba Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 12 Aug 2025 16:05:25 -0700 Subject: [PATCH 01/22] Add http middleware file --- ldmiddleware/http_middleware.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ldmiddleware/http_middleware.go diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go new file mode 100644 index 00000000..d48a5193 --- /dev/null +++ b/ldmiddleware/http_middleware.go @@ -0,0 +1,27 @@ +package ldmiddleware + +import ( + "net/http" + + "github.com/google/uuid" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" +) + +// AddRequestScopedClient returns a net/http middleware that, for each incoming request, +// creates an LDScopedClient seeded with a `request`-kind LDContext and stores it in the +// request's Go context. Downstream handlers can retrieve it via ld.GetScopedClient. +func AddRequestScopedClient(client *ld.LDClient) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Use a UUID to identify the request context key. + requestKey := uuid.New().String() + requestCtx := ldcontext.NewWithKind("request", requestKey) + + scoped := ld.NewScopedClient(client, requestCtx) + ctxWithScoped := ld.GoContextWithScopedClient(r.Context(), scoped) + + next.ServeHTTP(w, r.WithContext(ctxWithScoped)) + }) + } +} From 4984b0f24d46e3a48c1376377e1b7d0ce13ed247 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 13 Aug 2025 14:51:04 -0700 Subject: [PATCH 02/22] Add request params to request context --- ldmiddleware/http_middleware.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index d48a5193..d5e68601 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -9,14 +9,29 @@ import ( ) // AddRequestScopedClient returns a net/http middleware that, for each incoming request, -// creates an LDScopedClient seeded with a `request`-kind LDContext and stores it in the +// creates an LDScopedClient seeded with a `request`-kind LDContext populated with useful +// HTTP request attributes (e.g., method, path, host, userAgent), and stores it in the // request's Go context. Downstream handlers can retrieve it via ld.GetScopedClient. func AddRequestScopedClient(client *ld.LDClient) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Use a UUID to identify the request context key. requestKey := uuid.New().String() - requestCtx := ldcontext.NewWithKind("request", requestKey) + b := ldcontext.NewBuilder(requestKey).Kind("request").Anonymous(true) + b.SetString("method", r.Method) + b.SetString("host", r.Host) + b.SetString("userAgent", r.UserAgent()) + if r.URL != nil { + b.SetString("path", r.URL.Path) + b.SetString("scheme", r.URL.Scheme) + b.SetString("query", r.URL.RawQuery) + } + b.SetString("proto", r.Proto) + b.SetString("remoteAddr", r.RemoteAddr) + if rid := r.Header.Get("X-Request-Id"); rid != "" { + b.SetString("requestId", rid) + } + requestCtx := b.Build() scoped := ld.NewScopedClient(client, requestCtx) ctxWithScoped := ld.GoContextWithScopedClient(r.Context(), scoped) From 250be0f69996b757dfe1a86126943f416ec98f8f Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 13 Aug 2025 15:19:05 -0700 Subject: [PATCH 03/22] Separate AddRequestScopedClientWithKeyFn --- ldmiddleware/http_middleware.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index d5e68601..6568eb94 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -8,15 +8,35 @@ import ( ld "github.com/launchdarkly/go-server-sdk/v7" ) +// RequestKeyFunc allows callers to override the request context key for the LDContext. +// Return (key, true) to use the provided key; return ("", false) to fall back to the default UUID key. +type RequestKeyFunc func(r *http.Request) (string, bool) + // AddRequestScopedClient returns a net/http middleware that, for each incoming request, // creates an LDScopedClient seeded with a `request`-kind LDContext populated with useful // HTTP request attributes (e.g., method, path, host, userAgent), and stores it in the // request's Go context. Downstream handlers can retrieve it via ld.GetScopedClient. func AddRequestScopedClient(client *ld.LDClient) func(next http.Handler) http.Handler { + return AddRequestScopedClientWithKeyFn(client, nil) +} + +// AddRequestScopedClientWithKeyFn is like AddRequestScopedClient, but allows providing a function to override +// the context key used for the `request`-kind LDContext. If the function returns ok=false or an empty key, +// a random UUID will be used. +func AddRequestScopedClientWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Use a UUID to identify the request context key. - requestKey := uuid.New().String() + // Determine request context key + requestKey := "" + if keyFn != nil { + if k, ok := keyFn(r); ok && k != "" { + requestKey = k + } + } + if requestKey == "" { + requestKey = uuid.New().String() + } + b := ldcontext.NewBuilder(requestKey).Kind("request").Anonymous(true) b.SetString("method", r.Method) b.SetString("host", r.Host) @@ -28,9 +48,6 @@ func AddRequestScopedClient(client *ld.LDClient) func(next http.Handler) http.Ha } b.SetString("proto", r.Proto) b.SetString("remoteAddr", r.RemoteAddr) - if rid := r.Header.Get("X-Request-Id"); rid != "" { - b.SetString("requestId", rid) - } requestCtx := b.Build() scoped := ld.NewScopedClient(client, requestCtx) From 08c3d3a11a69fa4a7745635bece166374c3b4c5d Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 13 Aug 2025 16:21:47 -0700 Subject: [PATCH 04/22] Add TrackTiming, TrackErrorResponses, httpsnoop dependency --- .release-please-manifest.json | 3 +- ldmiddleware/go.mod | 24 ++++++++++++++++ ldmiddleware/go.sum | 49 +++++++++++++++++++++++++++++++++ ldmiddleware/http_middleware.go | 48 ++++++++++++++++++++++++++++++++ ldmiddleware/package_info.go | 5 ++++ release-please-config.json | 12 +++++++- 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 ldmiddleware/go.mod create mode 100644 ldmiddleware/go.sum create mode 100644 ldmiddleware/package_info.go diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d2783379..a6ee7e0d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { ".": "7.13.2", "ldotel": "1.3.0", - "ldai": "0.7.1" + "ldai": "0.7.1", + "ldmiddleware": "0.1.0" } diff --git a/ldmiddleware/go.mod b/ldmiddleware/go.mod new file mode 100644 index 00000000..e78a9034 --- /dev/null +++ b/ldmiddleware/go.mod @@ -0,0 +1,24 @@ +module github.com/launchdarkly/go-server-sdk/ldmiddleware + +go 1.23.0 + +require ( + github.com/felixge/httpsnoop v1.0.4 + github.com/google/uuid v1.1.1 + github.com/launchdarkly/go-sdk-common/v3 v3.4.0 + github.com/launchdarkly/go-server-sdk/v7 v7.13.0 +) + +require ( + github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/launchdarkly/ccache v1.1.0 // indirect + github.com/launchdarkly/eventsource v1.10.0 // indirect + github.com/launchdarkly/go-jsonstream/v3 v3.1.0 // indirect + github.com/launchdarkly/go-sdk-events/v3 v3.5.0 // indirect + github.com/launchdarkly/go-semver v1.0.3 // indirect + github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + golang.org/x/sync v0.8.0 // indirect +) diff --git a/ldmiddleware/go.sum b/ldmiddleware/go.sum new file mode 100644 index 00000000..dd708bca --- /dev/null +++ b/ldmiddleware/go.sum @@ -0,0 +1,49 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f h1:kOkUP6rcVVqC+KlKKENKtgfFfJyDySYhqL9srXooghY= +github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k= +github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= +github.com/launchdarkly/eventsource v1.10.0 h1:H9Tp6AfGu/G2qzBJC26iperrvwhzdbiA/gx7qE2nDFI= +github.com/launchdarkly/eventsource v1.10.0/go.mod h1:J3oa50bPvJesZqNAJtb5btSIo5N6roDWhiAS3IpsKck= +github.com/launchdarkly/go-jsonstream/v3 v3.1.0 h1:U/7/LplZO72XefBQ+FzHf6o4FwLHVqBE+4V58Ornu/E= +github.com/launchdarkly/go-jsonstream/v3 v3.1.0/go.mod h1:2Pt4BR5AwWgsuVTCcIpB6Os04JFIKWfoA+7faKkZB5E= +github.com/launchdarkly/go-sdk-common/v3 v3.4.0 h1:GTRulE0G43xdWY1QdjAXJ7QnZ8PMFU8pOWZICCydEtM= +github.com/launchdarkly/go-sdk-common/v3 v3.4.0/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg= +github.com/launchdarkly/go-sdk-events/v3 v3.5.0 h1:Yav8Thm70dZbO8U1foYwZPf3w60n/lNBRaYeeNM/qg4= +github.com/launchdarkly/go-sdk-events/v3 v3.5.0/go.mod h1:oepYWQ2RvvjfL2WxkE1uJJIuRsIMOP4WIVgUpXRPcNI= +github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3CdCcGQM= +github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= +github.com/launchdarkly/go-server-sdk/v7 v7.13.0 h1:ajiZOPBwmWVFFgP+EMdy3oS1Xl9wNDlEd/7Zn/0I2JU= +github.com/launchdarkly/go-server-sdk/v7 v7.13.0/go.mod h1:6krbDWp417H7lIg+3ehh/A/AW5xwHtiUFg06fvNYHAk= +github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= +github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index 6568eb94..b1a6b62a 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -2,9 +2,12 @@ package ldmiddleware import ( "net/http" + "time" + "github.com/felixge/httpsnoop" "github.com/google/uuid" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ld "github.com/launchdarkly/go-server-sdk/v7" ) @@ -57,3 +60,48 @@ func AddRequestScopedClientWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) }) } } + +// TrackTiming sends a LD event "http.request.duration_ms" with the duration of the request in milliseconds. +// This middleware must be after AddRequestScopedClient in the middleware chain, as it uses the scoped client +// from the request context. +// +// The timing event will include all LaunchDarkly contexts added to the scoped client. You may add more +// contexts to the scoped client _during_ the request, and they will be included in the timing event sent +// when the request completes. +func TrackTiming(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + next.ServeHTTP(w, r) + duration := time.Since(startTime) + scoped, ok := ld.GetScopedClient(r.Context()) + if !ok { + return + } + scoped.TrackMetric("http.request.duration_ms", float64(duration.Milliseconds()), ldvalue.Null()) + }) +} + +// TrackErrorResponses sends a LD event "http.response.4xx" or "http.response.5xx" if the response code is 4xx or 5xx. +// This middleware must be after AddRequestScopedClient in the middleware chain, as it uses the scoped client +// from the request context. +// +// The error event will include all LaunchDarkly contexts added to the scoped client. You may add more +// contexts to the scoped client _during_ the request, and they will be included in the error event sent +// when the request completes. +func TrackErrorResponses(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + metrics := httpsnoop.CaptureMetrics(next, w, r) + if metrics.Code < 400 { + return + } + scoped, ok := ld.GetScopedClient(r.Context()) + if !ok { + return + } + if metrics.Code < 500 { + _ = scoped.TrackEvent("http.response.4xx") + return + } + _ = scoped.TrackEvent("http.response.5xx") + }) +} diff --git a/ldmiddleware/package_info.go b/ldmiddleware/package_info.go new file mode 100644 index 00000000..28ba8424 --- /dev/null +++ b/ldmiddleware/package_info.go @@ -0,0 +1,5 @@ +// Package ldmiddleware contains HTTP middleware helpers for LaunchDarkly. +package ldmiddleware + +// Version is the current version string of the ldmiddleware package. This is updated by our release scripts. +const Version = "0.1.0" // {{ x-release-please-version }} diff --git a/release-please-config.json b/release-please-config.json index 8ea8c577..c4f8d356 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -15,7 +15,8 @@ ".github", ".vscode", "ldotel", - "ldai" + "ldai", + "ldmiddleware" ] }, "ldotel" : { @@ -36,6 +37,15 @@ "extra-files" : [ "package_info.go" ] + }, + "ldmiddleware" : { + "package-name": "ldmiddleware", + "release-type" : "go", + "tag-separator": "/", + "versioning" : "default", + "extra-files" : [ + "package_info.go" + ] } } } From 58d96bb06a6f2efb2f9aed7a880aecaa5ba94571 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 15:36:22 -0700 Subject: [PATCH 05/22] bump v7 dependency --- ldmiddleware/go.mod | 2 +- ldmiddleware/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ldmiddleware/go.mod b/ldmiddleware/go.mod index e78a9034..bf0ce67d 100644 --- a/ldmiddleware/go.mod +++ b/ldmiddleware/go.mod @@ -6,7 +6,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 github.com/google/uuid v1.1.1 github.com/launchdarkly/go-sdk-common/v3 v3.4.0 - github.com/launchdarkly/go-server-sdk/v7 v7.13.0 + github.com/launchdarkly/go-server-sdk/v7 v7.13.2 ) require ( diff --git a/ldmiddleware/go.sum b/ldmiddleware/go.sum index dd708bca..456b3773 100644 --- a/ldmiddleware/go.sum +++ b/ldmiddleware/go.sum @@ -25,8 +25,8 @@ github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3 github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= -github.com/launchdarkly/go-server-sdk/v7 v7.13.0 h1:ajiZOPBwmWVFFgP+EMdy3oS1Xl9wNDlEd/7Zn/0I2JU= -github.com/launchdarkly/go-server-sdk/v7 v7.13.0/go.mod h1:6krbDWp417H7lIg+3ehh/A/AW5xwHtiUFg06fvNYHAk= +github.com/launchdarkly/go-server-sdk/v7 v7.13.2 h1:gzc9/sHtWjN/y4vHzA2/d7EqSpFrHT6IiBzLfro+Oaw= +github.com/launchdarkly/go-server-sdk/v7 v7.13.2/go.mod h1:EEUSX/bc1mVq+3pwrRzTfu8LFRWRI1UL4XMgzsKWmbE= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= From 0e077291774f75fbd37049297c4c78248147dc33 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 15:46:10 -0700 Subject: [PATCH 06/22] rename fns --- ldmiddleware/http_middleware.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index b1a6b62a..16d2834d 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -15,18 +15,18 @@ import ( // Return (key, true) to use the provided key; return ("", false) to fall back to the default UUID key. type RequestKeyFunc func(r *http.Request) (string, bool) -// AddRequestScopedClient returns a net/http middleware that, for each incoming request, +// AddScopedClientForRequest returns a net/http middleware that, for each incoming request, // creates an LDScopedClient seeded with a `request`-kind LDContext populated with useful // HTTP request attributes (e.g., method, path, host, userAgent), and stores it in the // request's Go context. Downstream handlers can retrieve it via ld.GetScopedClient. -func AddRequestScopedClient(client *ld.LDClient) func(next http.Handler) http.Handler { - return AddRequestScopedClientWithKeyFn(client, nil) +func AddScopedClientForRequest(client *ld.LDClient) func(next http.Handler) http.Handler { + return AddScopedClientForRequestWithKeyFn(client, nil) } -// AddRequestScopedClientWithKeyFn is like AddRequestScopedClient, but allows providing a function to override +// AddScopedClientForRequestWithKeyFn is like AddScopedClientForRequest, but allows providing a function to override // the context key used for the `request`-kind LDContext. If the function returns ok=false or an empty key, // a random UUID will be used. -func AddRequestScopedClientWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) func(next http.Handler) http.Handler { +func AddScopedClientForRequestWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Determine request context key @@ -62,8 +62,8 @@ func AddRequestScopedClientWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) } // TrackTiming sends a LD event "http.request.duration_ms" with the duration of the request in milliseconds. -// This middleware must be after AddRequestScopedClient in the middleware chain, as it uses the scoped client -// from the request context. +// This middleware must be after AddScopedClientForRequest in the middleware chain, as it uses the scoped client +// from the Go context. // // The timing event will include all LaunchDarkly contexts added to the scoped client. You may add more // contexts to the scoped client _during_ the request, and they will be included in the timing event sent @@ -82,8 +82,8 @@ func TrackTiming(next http.Handler) http.Handler { } // TrackErrorResponses sends a LD event "http.response.4xx" or "http.response.5xx" if the response code is 4xx or 5xx. -// This middleware must be after AddRequestScopedClient in the middleware chain, as it uses the scoped client -// from the request context. +// This middleware must be after AddScopedClientForRequest in the middleware chain, as it uses the scoped client +// from the Go context. // // The error event will include all LaunchDarkly contexts added to the scoped client. You may add more // contexts to the scoped client _during_ the request, and they will be included in the error event sent From b203ea2043be314aaf8fdcd159de238ef8bb6de0 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:10:26 -0700 Subject: [PATCH 07/22] Add test --- ldmiddleware/http_middleware_test.go | 126 +++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 ldmiddleware/http_middleware_test.go diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go new file mode 100644 index 00000000..ab0d348c --- /dev/null +++ b/ldmiddleware/http_middleware_test.go @@ -0,0 +1,126 @@ +package ldmiddleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + ld "github.com/launchdarkly/go-server-sdk/v7" + "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" + "github.com/launchdarkly/go-server-sdk/v7/ldhooks" +) + +type recordingHook struct { + ldhooks.Unimplemented + events []ldhooks.TrackSeriesContext +} + +func (h *recordingHook) Metadata() ldhooks.Metadata { return ldhooks.NewMetadata("rec") } +func (h *recordingHook) AfterTrack(_ context.Context, sc ldhooks.TrackSeriesContext) error { + h.events = append(h.events, sc) + return nil +} + +func makeClientWithHook(t *testing.T, hook ldhooks.Hook) *ld.LDClient { + t.Helper() + config := ld.Config{ + DataSource: ldcomponents.ExternalUpdatesOnly(), + Events: ldcomponents.SendEvents().FlushInterval(time.Hour), + DiagnosticOptOut: true, + Hooks: []ldhooks.Hook{hook}, + } + client, err := ld.MakeCustomClient("sdk-key", config, time.Second) + if err != nil { + t.Fatalf("failed to make client: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestAddScopedClientForRequest_SetsScopedClientAndContext(t *testing.T) { + client := makeClientWithHook(t, &recordingHook{}) + handler := AddScopedClientForRequest(client)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sc, ok := ld.GetScopedClient(r.Context()) + if !ok { + t.Fatalf("scoped client not found in request context") + } + ctx := sc.CurrentContext() + if string(ctx.Kind()) != "request" { + t.Fatalf("unexpected kind: %s", ctx.Kind()) + } + if ctx.Key() == "" { + t.Fatalf("expected non-empty context key") + } + if ctx.GetValue("method").StringValue() != r.Method { + t.Fatalf("method attribute mismatch") + } + if ctx.GetValue("path").StringValue() != r.URL.Path { + t.Fatalf("path attribute mismatch") + } + w.WriteHeader(204) + })) + req := httptest.NewRequest("GET", "http://test/path?q=1", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != 204 { + t.Fatalf("unexpected status: %d", rr.Code) + } +} + +func TestTrackTiming_AfterTrackReceivesDurationMetric(t *testing.T) { + rec := &recordingHook{} + client := makeClientWithHook(t, rec) + + leaf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Millisecond) + w.WriteHeader(204) + }) + handler := AddScopedClientForRequest(client)(TrackTiming(leaf)) + + req := httptest.NewRequest("GET", "http://test/path", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != 204 { + t.Fatalf("unexpected status: %d", rr.Code) + } + if len(rec.events) != 1 { + t.Fatalf("expected 1 track event, got %d", len(rec.events)) + } + e := rec.events[0] + if e.Key() != "http.request.duration_ms" { + t.Fatalf("unexpected key: %s", e.Key()) + } + if e.MetricValue() == nil || *e.MetricValue() <= 0 { + t.Fatalf("expected positive metric value") + } +} + +func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { + rec := &recordingHook{} + client := makeClientWithHook(t, rec) + + for _, status := range []int{404, 503} { + rec.events = nil + leaf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }) + handler := AddScopedClientForRequest(client)(TrackErrorResponses(leaf)) + + req := httptest.NewRequest("GET", "http://test/path", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != status { + t.Fatalf("unexpected status: %d", rr.Code) + } + if len(rec.events) != 1 { + t.Fatalf("expected 1 track event, got %d", len(rec.events)) + } + want := "http.response.5xx" + if status < 500 { + want = "http.response.4xx" + } + if rec.events[0].Key() != want { + t.Fatalf("unexpected key: %s", rec.events[0].Key()) + } + } +} From 7fe5bdb90a2ab2e83a908e5738c807b97c18b4e5 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:15:03 -0700 Subject: [PATCH 08/22] Use testify assert --- ldmiddleware/go.mod | 4 ++++ ldmiddleware/go.sum | 8 ++++++++ ldmiddleware/http_middleware_test.go | 23 +++++++++++------------ 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/ldmiddleware/go.mod b/ldmiddleware/go.mod index bf0ce67d..fb6b8982 100644 --- a/ldmiddleware/go.mod +++ b/ldmiddleware/go.mod @@ -7,9 +7,11 @@ require ( github.com/google/uuid v1.1.1 github.com/launchdarkly/go-sdk-common/v3 v3.4.0 github.com/launchdarkly/go-server-sdk/v7 v7.13.2 + github.com/stretchr/testify v1.9.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f // indirect github.com/josharian/intern v1.0.0 // indirect github.com/launchdarkly/ccache v1.1.0 // indirect @@ -20,5 +22,7 @@ require ( github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sync v0.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/ldmiddleware/go.sum b/ldmiddleware/go.sum index 456b3773..a212f64e 100644 --- a/ldmiddleware/go.sum +++ b/ldmiddleware/go.sum @@ -11,6 +11,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k= github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= github.com/launchdarkly/eventsource v1.10.0 h1:H9Tp6AfGu/G2qzBJC26iperrvwhzdbiA/gx7qE2nDFI= @@ -35,6 +39,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= @@ -44,6 +50,8 @@ github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdms golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/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= diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index ab0d348c..2b578f34 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -10,6 +10,7 @@ import ( ld "github.com/launchdarkly/go-server-sdk/v7" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" "github.com/launchdarkly/go-server-sdk/v7/ldhooks" + "github.com/stretchr/testify/assert" ) type recordingHook struct { @@ -47,18 +48,16 @@ func TestAddScopedClientForRequest_SetsScopedClientAndContext(t *testing.T) { t.Fatalf("scoped client not found in request context") } ctx := sc.CurrentContext() - if string(ctx.Kind()) != "request" { - t.Fatalf("unexpected kind: %s", ctx.Kind()) - } - if ctx.Key() == "" { - t.Fatalf("expected non-empty context key") - } - if ctx.GetValue("method").StringValue() != r.Method { - t.Fatalf("method attribute mismatch") - } - if ctx.GetValue("path").StringValue() != r.URL.Path { - t.Fatalf("path attribute mismatch") - } + assert.Equal(t, "request", string(ctx.Kind())) + assert.NotEmpty(t, ctx.Key()) + assert.Equal(t, r.Method, ctx.GetValue("method").StringValue()) + assert.Equal(t, r.URL.Path, ctx.GetValue("path").StringValue()) + assert.Equal(t, r.UserAgent(), ctx.GetValue("userAgent").StringValue()) + assert.Equal(t, r.URL.Scheme, ctx.GetValue("scheme").StringValue()) + assert.Equal(t, r.URL.RawQuery, ctx.GetValue("query").StringValue()) + assert.Equal(t, r.Proto, ctx.GetValue("proto").StringValue()) + assert.Equal(t, r.Host, ctx.GetValue("host").StringValue()) + assert.Equal(t, r.RemoteAddr, ctx.GetValue("remoteAddr").StringValue()) w.WriteHeader(204) })) req := httptest.NewRequest("GET", "http://test/path?q=1", nil) From 91f7acd23dd6e3495ce614cdccc7c58d17a7a216 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:19:31 -0700 Subject: [PATCH 09/22] Test params better --- ldmiddleware/http_middleware_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index 2b578f34..f07978aa 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -7,10 +7,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + ld "github.com/launchdarkly/go-server-sdk/v7" "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" "github.com/launchdarkly/go-server-sdk/v7/ldhooks" - "github.com/stretchr/testify/assert" ) type recordingHook struct { @@ -50,17 +51,20 @@ func TestAddScopedClientForRequest_SetsScopedClientAndContext(t *testing.T) { ctx := sc.CurrentContext() assert.Equal(t, "request", string(ctx.Kind())) assert.NotEmpty(t, ctx.Key()) - assert.Equal(t, r.Method, ctx.GetValue("method").StringValue()) - assert.Equal(t, r.URL.Path, ctx.GetValue("path").StringValue()) - assert.Equal(t, r.UserAgent(), ctx.GetValue("userAgent").StringValue()) - assert.Equal(t, r.URL.Scheme, ctx.GetValue("scheme").StringValue()) - assert.Equal(t, r.URL.RawQuery, ctx.GetValue("query").StringValue()) - assert.Equal(t, r.Proto, ctx.GetValue("proto").StringValue()) - assert.Equal(t, r.Host, ctx.GetValue("host").StringValue()) + assert.Equal(t, "GET", ctx.GetValue("method").StringValue()) + assert.Equal(t, "test", ctx.GetValue("host").StringValue()) + assert.NotEmpty(t, ctx.GetValue("userAgent").StringValue()) + assert.Equal(t, "user-agent/1.0", ctx.GetValue("userAgent").StringValue()) + assert.Equal(t, "/path", ctx.GetValue("path").StringValue()) + assert.Equal(t, "http", ctx.GetValue("scheme").StringValue()) + assert.Equal(t, "q=1", ctx.GetValue("query").StringValue()) + assert.Equal(t, "HTTP/1.1", ctx.GetValue("proto").StringValue()) + assert.NotEmpty(t, ctx.GetValue("remoteAddr").StringValue()) assert.Equal(t, r.RemoteAddr, ctx.GetValue("remoteAddr").StringValue()) w.WriteHeader(204) })) req := httptest.NewRequest("GET", "http://test/path?q=1", nil) + req.Header.Set("User-Agent", "user-agent/1.0") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != 204 { From 26fb7bde5de1a63cf2702a236dfd6607a7e8cdcd Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:22:37 -0700 Subject: [PATCH 10/22] rewrite tests --- ldmiddleware/http_middleware_test.go | 56 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index f07978aa..1331928f 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -2,6 +2,7 @@ package ldmiddleware import ( "context" + "fmt" "net/http" "net/http/httptest" "testing" @@ -85,19 +86,12 @@ func TestTrackTiming_AfterTrackReceivesDurationMetric(t *testing.T) { req := httptest.NewRequest("GET", "http://test/path", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - if rr.Code != 204 { - t.Fatalf("unexpected status: %d", rr.Code) - } - if len(rec.events) != 1 { - t.Fatalf("expected 1 track event, got %d", len(rec.events)) - } + assert.Equal(t, 204, rr.Code) + assert.Equal(t, 1, len(rec.events)) e := rec.events[0] - if e.Key() != "http.request.duration_ms" { - t.Fatalf("unexpected key: %s", e.Key()) - } - if e.MetricValue() == nil || *e.MetricValue() <= 0 { - t.Fatalf("expected positive metric value") - } + assert.Equal(t, "http.request.duration_ms", e.Key()) + assert.NotNil(t, e.MetricValue()) + assert.Greater(t, *e.MetricValue(), 0.0) } func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { @@ -105,25 +99,33 @@ func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { client := makeClientWithHook(t, rec) for _, status := range []int{404, 503} { + t.Run(fmt.Sprintf("status %d", status), func(t *testing.T) { + rec.events = nil + leaf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }) + handler := AddScopedClientForRequest(client)(TrackErrorResponses(leaf)) + + req := httptest.NewRequest("GET", "http://test/path", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, status, rr.Code) + assert.Equal(t, 1, len(rec.events)) + want := "http.response.5xx" + if status < 500 { + want = "http.response.4xx" + } + assert.Equal(t, want, rec.events[0].Key()) + }) + } + + t.Run("no events when no error", func(t *testing.T) { rec.events = nil - leaf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) }) + leaf := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) handler := AddScopedClientForRequest(client)(TrackErrorResponses(leaf)) req := httptest.NewRequest("GET", "http://test/path", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - if rr.Code != status { - t.Fatalf("unexpected status: %d", rr.Code) - } - if len(rec.events) != 1 { - t.Fatalf("expected 1 track event, got %d", len(rec.events)) - } - want := "http.response.5xx" - if status < 500 { - want = "http.response.4xx" - } - if rec.events[0].Key() != want { - t.Fatalf("unexpected key: %s", rec.events[0].Key()) - } - } + assert.Equal(t, 200, rr.Code) + assert.Equal(t, 0, len(rec.events)) + }) } From b2fb6d6a0a71f1ce4c1a1c9033c2ad749332ccaa Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:23:28 -0700 Subject: [PATCH 11/22] Add TestAddScopedClientForRequest_WithKeyFn --- ldmiddleware/http_middleware_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index 1331928f..a65330ed 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -68,9 +68,23 @@ func TestAddScopedClientForRequest_SetsScopedClientAndContext(t *testing.T) { req.Header.Set("User-Agent", "user-agent/1.0") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) - if rr.Code != 204 { - t.Fatalf("unexpected status: %d", rr.Code) - } + assert.Equal(t, 204, rr.Code) +} + +func TestAddScopedClientForRequest_WithKeyFn(t *testing.T) { + client := makeClientWithHook(t, &recordingHook{}) + handler := AddScopedClientForRequestWithKeyFn(client, func(r *http.Request) (string, bool) { + return "test-key", true + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sc, ok := ld.GetScopedClient(r.Context()) + assert.True(t, ok) + assert.Equal(t, "test-key", sc.CurrentContext().Key()) + w.WriteHeader(204) + })) + req := httptest.NewRequest("GET", "http://test/path", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, 204, rr.Code) } func TestTrackTiming_AfterTrackReceivesDurationMetric(t *testing.T) { From 4bdcdb4160843f671d6fced976d1fcebdaeb0ab1 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:38:03 -0700 Subject: [PATCH 12/22] Add README.md --- ldmiddleware/README.md | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 ldmiddleware/README.md diff --git a/ldmiddleware/README.md b/ldmiddleware/README.md new file mode 100644 index 00000000..716ca017 --- /dev/null +++ b/ldmiddleware/README.md @@ -0,0 +1,55 @@ +LaunchDarkly HTTP Middleware +=============================== + +# ⛔️⛔️⛔️⛔️ +> [!CAUTION] +> This library is a alpha version and should not be considered ready for production use while this message is visible. +# ☝️☝️☝️☝️☝️☝️ + +[![Actions Status](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ldmiddleware-ci.yml/badge.svg?branch=v7)](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ldmiddleware-ci.yml) + +This package provides a set of HTTP middleware functions that speed up the process of instrumenting your application with LaunchDarkly. + +## Usage + +```go +import ( + ld "github.com/launchdarkly/go-server-sdk/v7" + ldmiddleware "github.com/launchdarkly/go-server-sdk/v7/ldmiddleware" + "net/http" + "time" + "github.com/gorilla/mux" +) + +func main() { + client, err := ld.MakeClient("your-sdk-key", 5*time.Second) + if err != nil { + log.Fatal(err) + } + + // Add the LaunchDarkly middleware functions to your middleware chain. + // The order of the middleware functions is important. + // The AddScopedClientForRequest function must be called before the TrackTiming and TrackErrorResponses functions. + + r := mux.NewRouter() + r.Use(ldmiddleware.AddScopedClientForRequest(client)) + r.Use(ldmiddleware.TrackTiming) + r.Use(ldmiddleware.TrackErrorResponses) + + r.Handle("/", http.HandlerFunc(myHandler)) + http.ListenAndServe(":8080", r) +} + +func myHandler(w http.ResponseWriter, r *http.Request) { + // Thanks to `AddScopedClientForRequest`, a scoped client is available in the request context. + // We can use it to evaluate feature flags, track analytics, etc. without having to provide the LaunchDarkly context. + var enableBetaFeatures bool + if client, ok := ld.GetScopedClient(r.Context()); ok { + enableBetaFeatures = client.BoolVariation("your-feature-key", false) + } + + if enableBetaFeatures { + // Do something + } +} +``` \ No newline at end of file From e8441f161b9eca37355f25cb26a090379c134fe2 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:40:11 -0700 Subject: [PATCH 13/22] ld_request context kind --- ldmiddleware/http_middleware.go | 2 +- ldmiddleware/http_middleware_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index 16d2834d..ea64a6c5 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -40,7 +40,7 @@ func AddScopedClientForRequestWithKeyFn(client *ld.LDClient, keyFn RequestKeyFun requestKey = uuid.New().String() } - b := ldcontext.NewBuilder(requestKey).Kind("request").Anonymous(true) + b := ldcontext.NewBuilder(requestKey).Kind("ld_request").Anonymous(true) b.SetString("method", r.Method) b.SetString("host", r.Host) b.SetString("userAgent", r.UserAgent()) diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index a65330ed..b2380d54 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -50,7 +50,7 @@ func TestAddScopedClientForRequest_SetsScopedClientAndContext(t *testing.T) { t.Fatalf("scoped client not found in request context") } ctx := sc.CurrentContext() - assert.Equal(t, "request", string(ctx.Kind())) + assert.Equal(t, "ld_request", string(ctx.Kind())) assert.NotEmpty(t, ctx.Key()) assert.Equal(t, "GET", ctx.GetValue("method").StringValue()) assert.Equal(t, "test", ctx.GetValue("host").StringValue()) From 5d49f2f7e3d2f625cd19c3de71c4bb33579624d7 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:58:21 -0700 Subject: [PATCH 14/22] add WriteHeader --- ldmiddleware/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ldmiddleware/README.md b/ldmiddleware/README.md index 716ca017..737d575b 100644 --- a/ldmiddleware/README.md +++ b/ldmiddleware/README.md @@ -51,5 +51,7 @@ func myHandler(w http.ResponseWriter, r *http.Request) { if enableBetaFeatures { // Do something } + + w.WriteHeader(200) } ``` \ No newline at end of file From f1b3576f94115afe5f659714fef8f3ce244dee0b Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 16:59:54 -0700 Subject: [PATCH 15/22] Add ldmiddleware ci yml --- .github/workflows/ldmiddleware-ci.yml | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/ldmiddleware-ci.yml diff --git a/.github/workflows/ldmiddleware-ci.yml b/.github/workflows/ldmiddleware-ci.yml new file mode 100644 index 00000000..cb8a1118 --- /dev/null +++ b/.github/workflows/ldmiddleware-ci.yml @@ -0,0 +1,40 @@ +name: Build and Test ldmiddleware +permissions: + contents: read +on: + push: + branches: [ 'v7', 'feat/**' ] + paths-ignore: + - '**.md' # Don't run CI on markdown changes. + pull_request: + branches: [ 'v7', 'feat/**' ] + paths-ignore: + - '**.md' + +jobs: + go-versions: + uses: ./.github/workflows/go-versions.yml + + # Runs the common tasks (unit tests, lint, contract tests) for each Go version. + test-linux: + name: ${{ format('ldmiddleware Linux, Go {0}', matrix.go-version) }} + needs: go-versions + strategy: + # Let jobs fail independently, in case it's a single version that's broken. + fail-fast: false + matrix: + go-version: ${{ fromJSON(needs.go-versions.outputs.matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go ${{ inputs.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - uses: ./.github/actions/unit-tests + with: + lint: 'true' + test-target: ldmiddleware-test + - uses: ./.github/actions/coverage + with: + enforce: 'false' From ed4d55a4c8296894c8025f508124dc5957a48959 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Aug 2025 17:06:32 -0700 Subject: [PATCH 16/22] Add to makefile --- Makefile | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Makefile b/Makefile index e75f81ac..a32b3553 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,32 @@ ldai-lint: $(LINTER_VERSION_FILE) cd ldai && ../$(LINTER) run .; \ fi +ldmiddleware: + @if [ -f go.work ]; then \ + echo "Building ldmiddleware with workspace"; \ + go build ./ldmiddleware; \ + else \ + echo "Building ldmiddleware without workspace"; \ + cd ldmiddleware && go build .; \ + fi + +ldmiddleware-test: + @if [ -f go.work ]; then \ + echo "Testing ldmiddleware with workspace"; \ + go test -v -race ./ldmiddleware; \ + else \ + echo "Testing ldmiddleware without workspace"; \ + cd ldmiddleware && go test -v -race .; \ + fi + +ldmiddleware-lint: $(LINTER_VERSION_FILE) + @if [ -f go.work ]; then \ + echo "Linting ldmiddleware with workspace"; \ + $(LINTER) run ./ldmiddleware; \ + else \ + echo "Linting ldmiddleware without workspace"; \ + cd ldmiddleware && ../$(LINTER) run .; \ + fi test-coverage: $(COVERAGE_PROFILE_RAW) go run github.com/launchdarkly-labs/go-coverage-enforcer@latest $(COVERAGE_ENFORCER_FLAGS) -outprofile $(COVERAGE_PROFILE_FILTERED) $(COVERAGE_PROFILE_RAW) From 96859882fbb7b6862cb663a1dd61f6b6b5819dad Mon Sep 17 00:00:00 2001 From: Alex Engelberg <4122172+aengelberg@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:57:59 -0700 Subject: [PATCH 17/22] Update ldmiddleware/README.md Co-authored-by: Matthew M. Keeler --- ldmiddleware/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldmiddleware/README.md b/ldmiddleware/README.md index 737d575b..cd6c8580 100644 --- a/ldmiddleware/README.md +++ b/ldmiddleware/README.md @@ -3,7 +3,7 @@ LaunchDarkly HTTP Middleware # ⛔️⛔️⛔️⛔️ > [!CAUTION] -> This library is a alpha version and should not be considered ready for production use while this message is visible. +> This library is an alpha version and should not be considered ready for production use while this message is visible. # ☝️☝️☝️☝️☝️☝️ [![Actions Status](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ldmiddleware-ci.yml/badge.svg?branch=v7)](https://github.com/launchdarkly/go-server-sdk/actions/workflows/ldmiddleware-ci.yml) From 888764c7a597e3347bb62edd42f7d881ff16c835 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 20 Aug 2025 11:13:50 -0700 Subject: [PATCH 18/22] Add ldmiddleware to Makefile ALL_BUILD_TARGETS --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a32b3553..dbd57fde 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ COVERAGE_ENFORCER_FLAGS=-package github.com/launchdarkly/go-server-sdk/v7 \ -skipcode "// COVERAGE" \ -packagestats -filestats -showcode -ALL_BUILD_TARGETS=sdk ldotel ldai +ALL_BUILD_TARGETS=sdk ldotel ldai ldmiddleware ALL_TEST_TARGETS = $(addsuffix -test, $(ALL_BUILD_TARGETS)) ALL_LINT_TARGETS = $(addsuffix -lint, $(ALL_BUILD_TARGETS)) From 60bbb59424292e4d681b247535a5e09679572ed8 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 20 Aug 2025 11:19:25 -0700 Subject: [PATCH 19/22] lint --- ldmiddleware/http_middleware.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index ea64a6c5..470ef3d4 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -6,6 +6,7 @@ import ( "github.com/felixge/httpsnoop" "github.com/google/uuid" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ld "github.com/launchdarkly/go-server-sdk/v7" @@ -77,7 +78,7 @@ func TrackTiming(next http.Handler) http.Handler { if !ok { return } - scoped.TrackMetric("http.request.duration_ms", float64(duration.Milliseconds()), ldvalue.Null()) + _ = scoped.TrackMetric("http.request.duration_ms", float64(duration.Milliseconds()), ldvalue.Null()) }) } From 0c344676fed95bf3c9d3a3408c09593a2a6478fa Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 20 Aug 2025 11:45:03 -0700 Subject: [PATCH 20/22] lint --- ldmiddleware/http_middleware.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ldmiddleware/http_middleware.go b/ldmiddleware/http_middleware.go index 470ef3d4..e04bd253 100644 --- a/ldmiddleware/http_middleware.go +++ b/ldmiddleware/http_middleware.go @@ -27,7 +27,9 @@ func AddScopedClientForRequest(client *ld.LDClient) func(next http.Handler) http // AddScopedClientForRequestWithKeyFn is like AddScopedClientForRequest, but allows providing a function to override // the context key used for the `request`-kind LDContext. If the function returns ok=false or an empty key, // a random UUID will be used. -func AddScopedClientForRequestWithKeyFn(client *ld.LDClient, keyFn RequestKeyFunc) func(next http.Handler) http.Handler { +func AddScopedClientForRequestWithKeyFn( + client *ld.LDClient, keyFn RequestKeyFunc, +) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Determine request context key From 340e664d679419c90aef072602330472119a134b Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Thu, 21 Aug 2025 15:45:23 -0700 Subject: [PATCH 21/22] Bump ldmiddleware's go-server-sdk dependency to v7.13.4 --- ldmiddleware/go.mod | 2 +- ldmiddleware/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ldmiddleware/go.mod b/ldmiddleware/go.mod index fb6b8982..15726666 100644 --- a/ldmiddleware/go.mod +++ b/ldmiddleware/go.mod @@ -6,7 +6,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 github.com/google/uuid v1.1.1 github.com/launchdarkly/go-sdk-common/v3 v3.4.0 - github.com/launchdarkly/go-server-sdk/v7 v7.13.2 + github.com/launchdarkly/go-server-sdk/v7 v7.13.4 github.com/stretchr/testify v1.9.0 ) diff --git a/ldmiddleware/go.sum b/ldmiddleware/go.sum index a212f64e..1b584ac5 100644 --- a/ldmiddleware/go.sum +++ b/ldmiddleware/go.sum @@ -29,8 +29,8 @@ github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3 github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= -github.com/launchdarkly/go-server-sdk/v7 v7.13.2 h1:gzc9/sHtWjN/y4vHzA2/d7EqSpFrHT6IiBzLfro+Oaw= -github.com/launchdarkly/go-server-sdk/v7 v7.13.2/go.mod h1:EEUSX/bc1mVq+3pwrRzTfu8LFRWRI1UL4XMgzsKWmbE= +github.com/launchdarkly/go-server-sdk/v7 v7.13.4 h1:Jn4HQDkmV0DhbUKLz7gFbNrhVrE3xSx8D6FTKEDheis= +github.com/launchdarkly/go-server-sdk/v7 v7.13.4/go.mod h1:EEUSX/bc1mVq+3pwrRzTfu8LFRWRI1UL4XMgzsKWmbE= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= From 6c5b5326ebe53e9c13da75b37601819dc5efd8f2 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Thu, 21 Aug 2025 15:50:02 -0700 Subject: [PATCH 22/22] Fix unit tests to handle scoped usage --- ldmiddleware/http_middleware_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ldmiddleware/http_middleware_test.go b/ldmiddleware/http_middleware_test.go index b2380d54..8042b692 100644 --- a/ldmiddleware/http_middleware_test.go +++ b/ldmiddleware/http_middleware_test.go @@ -101,8 +101,8 @@ func TestTrackTiming_AfterTrackReceivesDurationMetric(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, 204, rr.Code) - assert.Equal(t, 1, len(rec.events)) - e := rec.events[0] + assert.Equal(t, 2, len(rec.events)) // $ld:scoped:usage and http.request.duration_ms + e := rec.events[1] assert.Equal(t, "http.request.duration_ms", e.Key()) assert.NotNil(t, e.MetricValue()) assert.Greater(t, *e.MetricValue(), 0.0) @@ -122,12 +122,12 @@ func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, status, rr.Code) - assert.Equal(t, 1, len(rec.events)) + assert.Equal(t, 2, len(rec.events)) // $ld:scoped:usage and http.response.* want := "http.response.5xx" if status < 500 { want = "http.response.4xx" } - assert.Equal(t, want, rec.events[0].Key()) + assert.Equal(t, want, rec.events[1].Key()) }) } @@ -140,6 +140,6 @@ func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, 200, rr.Code) - assert.Equal(t, 0, len(rec.events)) + assert.Equal(t, 1, len(rec.events)) // $ld:scoped:usage }) }