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' diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2a639987..1581b1a5 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { ".": "7.13.4", "ldotel": "1.3.0", - "ldai": "0.7.1" + "ldai": "0.7.1", + "ldmiddleware": "0.1.0" } diff --git a/Makefile b/Makefile index e75f81ac..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)) @@ -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) diff --git a/ldmiddleware/README.md b/ldmiddleware/README.md new file mode 100644 index 00000000..cd6c8580 --- /dev/null +++ b/ldmiddleware/README.md @@ -0,0 +1,57 @@ +LaunchDarkly HTTP Middleware +=============================== + +# ⛔️⛔️⛔️⛔️ +> [!CAUTION] +> 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) + +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 + } + + w.WriteHeader(200) +} +``` \ No newline at end of file diff --git a/ldmiddleware/go.mod b/ldmiddleware/go.mod new file mode 100644 index 00000000..15726666 --- /dev/null +++ b/ldmiddleware/go.mod @@ -0,0 +1,28 @@ +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.4 + 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 + 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 + 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 new file mode 100644 index 00000000..1b584ac5 --- /dev/null +++ b/ldmiddleware/go.sum @@ -0,0 +1,57 @@ +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/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= +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.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= +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/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= +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/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.go b/ldmiddleware/http_middleware.go new file mode 100644 index 00000000..e04bd253 --- /dev/null +++ b/ldmiddleware/http_middleware.go @@ -0,0 +1,110 @@ +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" +) + +// 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) + +// 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 AddScopedClientForRequest(client *ld.LDClient) func(next http.Handler) http.Handler { + return AddScopedClientForRequestWithKeyFn(client, nil) +} + +// 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 { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 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("ld_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) + requestCtx := b.Build() + + scoped := ld.NewScopedClient(client, requestCtx) + ctxWithScoped := ld.GoContextWithScopedClient(r.Context(), scoped) + + next.ServeHTTP(w, r.WithContext(ctxWithScoped)) + }) + } +} + +// TrackTiming sends a LD event "http.request.duration_ms" with the duration of the request in milliseconds. +// 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 +// 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 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 +// 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/http_middleware_test.go b/ldmiddleware/http_middleware_test.go new file mode 100644 index 00000000..8042b692 --- /dev/null +++ b/ldmiddleware/http_middleware_test.go @@ -0,0 +1,145 @@ +package ldmiddleware + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "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" +) + +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() + 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()) + 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) + 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) { + 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) + assert.Equal(t, 204, rr.Code) + 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) +} + +func TestTrackErrorResponses_AfterTrackReceivesErrorKeys(t *testing.T) { + rec := &recordingHook{} + 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, 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[1].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(200) }) + handler := AddScopedClientForRequest(client)(TrackErrorResponses(leaf)) + + req := httptest.NewRequest("GET", "http://test/path", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, 200, rr.Code) + assert.Equal(t, 1, len(rec.events)) // $ld:scoped:usage + }) +} 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" + ] } } }