Skip to content

fix: mem leak due to unbounded metrics cardinality#1931

Merged
toddbaert merged 2 commits into
mainfrom
fix/metrics
Apr 7, 2026
Merged

fix: mem leak due to unbounded metrics cardinality#1931
toddbaert merged 2 commits into
mainfrom
fix/metrics

Conversation

@toddbaert

@toddbaert toddbaert commented Apr 7, 2026

Copy link
Copy Markdown
Member

Fixes a memory leak that can be seen with many remote clients due to default OTel metrics attributes included remote peer and ephemeral port.

See: https://github.com/connectrpc/otelconnect-go?tab=readme-ov-file#reducing-metrics-and-tracing-cardinality

I tested this with this integration test (I can see the memory leak and confirmed this fixes it).

Details
package service

import (
	"bytes"
	"context"
	"fmt"
	"net/http"
	"runtime"
	"testing"
	"time"

	mock "github.com/open-feature/flagd/core/pkg/evaluator/mock"
	"github.com/open-feature/flagd/core/pkg/logger"
	"github.com/open-feature/flagd/core/pkg/model"
	iservice "github.com/open-feature/flagd/core/pkg/service"
	"github.com/open-feature/flagd/core/pkg/telemetry"
	"github.com/stretchr/testify/require"
	"go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.uber.org/mock/gomock"
)

// TestMemoryGrowthFromPeerAttributes is a long-running test that demonstrates
// unbounded memory growth when otelconnect records net.peer.port in metrics.
//
// Run with: go test -v -run TestMemoryGrowthFromPeerAttributes -timeout=120s ./flagd/pkg/service/flag-evaluation/
func TestMemoryGrowthFromPeerAttributes(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping long-running memory test in short mode")
	}

	const (
		port       = 18490
		batchSize  = 500
		batches    = 20
		resolveURL = "http://localhost:%d/flagd.evaluation.v1.Service/ResolveBoolean"
	)

	ctrl := gomock.NewController(t)
	eval := mock.NewMockIEvaluator(ctrl)
	eval.EXPECT().ResolveBooleanValue(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(
		true, "on", model.DefaultReason, map[string]interface{}{}, nil,
	).AnyTimes()

	reader := metric.NewManualReader()
	rs := resource.NewWithAttributes("testSchema")
	metricRecorder := telemetry.NewOTelRecorder(reader, rs, "memory-test")

	options, err := telemetry.BuildConnectOptions(telemetry.Config{})
	require.NoError(t, err)

	svc := NewConnectService(logger.NewLogger(nil, false), eval, nil, metricRecorder)

	ctx, cancel := context.WithCancel(context.Background())
	t.Cleanup(cancel)

	go func() {
		_ = svc.Serve(ctx, iservice.Configuration{
			ReadinessProbe: func() bool { return true },
			Port:           port,
			Options:        options,
		})
	}()

	url := fmt.Sprintf(resolveURL, port)
	require.Eventually(t, func() bool {
		resp, err := http.Get(fmt.Sprintf("http://localhost:%d/flagd.evaluation.v1.Service/ResolveAll", port))
		if err == nil && resp != nil {
			resp.Body.Close()
		}
		return err == nil && resp != nil
	}, 3*time.Second, 100*time.Millisecond)

	body := []byte(`{"flagKey":"testFlag"}`)

	// baseline
	runtime.GC()
	var baseline runtime.MemStats
	runtime.ReadMemStats(&baseline)

	t.Logf("")
	t.Logf("%-10s  %-12s  %-12s  %-12s", "Requests", "HeapInuse", "HeapAlloc", "Delta")
	t.Logf("%-10s  %-12s  %-12s  %-12s", "--------", "---------", "---------", "-----")

	totalRequests := 0
	for batch := 0; batch < batches; batch++ {
		for i := 0; i < batchSize; i++ {
			client := &http.Client{
				Transport: &http.Transport{
					DisableKeepAlives: true,
				},
			}
			req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
			req.Header.Set("Content-Type", "application/json")
			resp, err := client.Do(req)
			if err == nil {
				resp.Body.Close()
			}
		}
		totalRequests += batchSize

		runtime.GC()
		var m runtime.MemStats
		runtime.ReadMemStats(&m)
		delta := int64(m.HeapInuse) - int64(baseline.HeapInuse)

		t.Logf("%-10d  %-12s  %-12s  %+.1f MB",
			totalRequests,
			formatMB(m.HeapInuse),
			formatMB(m.HeapAlloc),
			float64(delta)/1024/1024,
		)
	}

	// final summary
	runtime.GC()
	var final runtime.MemStats
	runtime.ReadMemStats(&final)
	totalDelta := float64(int64(final.HeapInuse)-int64(baseline.HeapInuse)) / 1024 / 1024

	t.Logf("")
	t.Logf("Total: %d requests, heap grew by %.1f MB", totalRequests, totalDelta)

	if totalDelta > 10 {
		t.Logf("WARNING: heap grew by %.1f MB; this indicates unbounded cardinality growth", totalDelta)
	} else {
		t.Logf("Heap growth is bounded (%.1f MB); cardinality is under control", totalDelta)
	}
}

func formatMB(b uint64) string {
	return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
}

@toddbaert toddbaert requested review from a team as code owners April 7, 2026 13:07
@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Apr 7, 2026
@netlify

netlify Bot commented Apr 7, 2026

Copy link
Copy Markdown

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit 04521d6
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/69d52259034db900084253ab

interceptor, err := otelconnect.NewInterceptor(otelconnect.WithTrustRemote())
interceptor, err := otelconnect.NewInterceptor(
otelconnect.WithTrustRemote(),
otelconnect.WithoutServerPeerAttributes(),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Primary fix.

Comment on lines +198 to +202
// limit metric attribute cardinality to prevent unbounded memory growth from
// high-cardinality attributes (OTel spec recommends 2000, Go SDK defaults to unlimited)
// 2000 is recommended by OTel spec and is a reasonable default for our use case,
// but can be overridden with the OTEL_GO_X_CARDINALITY_LIMIT environment variable
msdk.WithCardinalityLimit(2000),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional guard to prevent metrics leak for some other custom metrics, etc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please mention this in the docs too?

https://flagd.dev/reference/monitoring/#metrics

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates telemetry settings by disabling server peer attributes in the connect interceptor and attempting to implement a metric cardinality limit. A critical issue was identified where the msdk.WithCardinalityLimit function is not available in the stable OpenTelemetry Go SDK, which will cause a compilation failure. The feedback suggests removing this programmatic configuration and instead relying on the OTEL_GO_X_CARDINALITY_LIMIT environment variable for cardinality management.

Comment thread core/pkg/telemetry/metrics.go
@toddbaert toddbaert requested a review from beeme1mr April 7, 2026 13:15

@juanparadox juanparadox left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for jumping on this 😄

Comment thread core/pkg/telemetry/builder.go
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
@sonarqubecloud

sonarqubecloud Bot commented Apr 7, 2026

Copy link
Copy Markdown

@toddbaert toddbaert merged commit 176866e into main Apr 7, 2026
17 checks passed
@github-actions github-actions Bot mentioned this pull request Apr 7, 2026
toddbaert added a commit that referenced this pull request Apr 7, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>flagd: 0.15.1</summary>

##
[0.15.1](flagd/v0.15.0...flagd/v0.15.1)
(2026-04-07)


### 🐛 Bug Fixes

* object flags without `defaultVaraint` dont default in RPC
([#1925](#1925))
([17f833e](17f833e))
* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4
[security] ([#1929](#1929))
([cf22a11](cf22a11))
* zombie process on metrics server fail
([#1926](#1926))
([0271068](0271068))
* mem leak due to unbounded metrics cardinality
([#1931](#1931))
([176866e](176866e))
</details>

<details><summary>flagd-proxy: 0.9.3</summary>

##
[0.9.3](flagd-proxy/v0.9.2...flagd-proxy/v0.9.3)
(2026-04-07)


### 🐛 Bug Fixes

* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4
[security] ([#1929](#1929))
([cf22a11](cf22a11))
</details>

<details><summary>core: 0.15.1</summary>

##
[0.15.1](core/v0.15.0...core/v0.15.1)
(2026-04-07)


### 🐛 Bug Fixes

* mem leak due to unbounded metrics cardinality
([#1931](#1931))
([176866e](176866e))
* **security:** update module github.com/go-jose/go-jose/v4 to v4.1.4
[security] ([#1929](#1929))
([cf22a11](cf22a11))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants