Repository: open-telemetry/opentelemetry-go-contrib (go)

Issue:
Generate New Client Metrics in `otelhttp`
The [`semconv.HTTPClient`](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/13536ddd8c872685baba158a42a7b2ad37129b37/instrumentation/net/http/otelhttp/internal/semconv/env.go#L131-L138) already produces the existing `v1.20.0` client metrics (https://github.com/open-telemetry/opentelemetry-go-contrib/pull/6002). The following methods need to be updated to produce the new client HTTP metrics:

- [ ] [`MetricOptions`](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/13536ddd8c872685baba158a42a7b2ad37129b37/instrumentation/net/http/otelhttp/internal/semconv/env.go#L197C21-L205)
- [ ] [`RecordMetrics`](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/13536ddd8c872685baba158a42a7b2ad37129b37/instrumentation/net/http/otelhttp/internal/semconv/env.go#L207C21-L217)
- [ ] [`RecordResponseSize`](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/13536ddd8c872685baba158a42a7b2ad37129b37/instrumentation/net/http/otelhttp/internal/semconv/env.go#L219C21-L227)

These updates should ensure that when `OTEL_SEMCONV_STABILITY_OPT_IN="http/dup"` is set the produced telemetry is compliant with [these semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-metrics.md#http-client):

- [ ] `http.client.request.duration`
  - Do not include the experimental attributes.
- [ ] `http.client.request.body.size`

Do not include: 

- [ ] `http.client.response.body.size`
- [ ] `http.client.open_connections`
- [ ] `http.client.connection.duration`
- [ ] `http.client.active_requests`

These metrics are optional, and since the existing instrumentation does not provide an equivalent, do not add additional metrics (at this time).

Pull request:
Duplicate client metrics with semconv v1.26 in otelhttp
Closes #6261 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 817d890ae..4539d2bc8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 ### Added
 
 - Generate server metrics with semantic conventions v1.26 in `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` when `OTEL_SEMCONV_STABILITY_OPT_IN` is set to `http/dup`. (#6411)
+- Generate client metrics with semantic conventions v1.26 in `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` when `OTEL_SEMCONV_STABILITY_OPT_IN` is set to `http/dup`. (#6607)
 
 ### Fixed
 
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/env.go b/instrumentation/net/http/otelhttp/internal/semconv/env.go
index cab3cf812..d51b6a22f 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/env.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/env.go
@@ -172,14 +172,23 @@ type HTTPClient struct {
 	requestBytesCounter  metric.Int64Counter
 	responseBytesCounter metric.Int64Counter
 	latencyMeasure       metric.Float64Histogram
+
+	// new metrics
+	requestBodySize metric.Int64Histogram
+	requestDuration metric.Float64Histogram
 }
 
 func NewHTTPClient(meter metric.Meter) HTTPClient {
 	env := strings.ToLower(os.Getenv(OTelSemConvStabilityOptIn))
+	duplicate := env == "http/dup"
 	client := HTTPClient{
-		duplicate: env == "http/dup",
+		duplicate: duplicate,
 	}
 	client.requestBytesCounter, client.responseBytesCounter, client.latencyMeasure = OldHTTPClient{}.createMeasures(meter)
+	if duplicate {
+		client.requestBodySize, client.requestDuration = CurrentHTTPClient{}.createMeasures(meter)
+	}
+
 	return client
 }
 
@@ -231,34 +240,48 @@ func (o MetricOpts) AddOptions() metric.AddOption {
 	return o.addOptions
 }
 
-func (c HTTPClient) MetricOptions(ma MetricAttributes) MetricOpts {
+func (c HTTPClient) MetricOptions(ma MetricAttributes) map[string]MetricOpts {
+	opts := map[string]MetricOpts{}
+
 	attributes := OldHTTPClient{}.MetricAttributes(ma.Req, ma.StatusCode, ma.AdditionalAttributes)
-	// TODO: Duplicate Metrics
 	set := metric.WithAttributeSet(attribute.NewSet(attributes...))
-	return MetricOpts{
+	opts["old"] = MetricOpts{
 		measurement: set,
 		addOptions:  set,
 	}
+
+	if c.duplicate {
+		attributes := CurrentHTTPClient{}.MetricAttributes(ma.Req, ma.StatusCode, ma.AdditionalAttributes)
+		set := metric.WithAttributeSet(attribute.NewSet(attributes...))
+		opts["new"] = MetricOpts{
+			measurement: set,
+			addOptions:  set,
+		}
+	}
+
+	return opts
 }
 
-func (s HTTPClient) RecordMetrics(ctx context.Context, md MetricData, opts MetricOpts) {
+func (s HTTPClient) RecordMetrics(ctx context.Context, md MetricData, opts map[string]MetricOpts) {
 	if s.requestBytesCounter == nil || s.latencyMeasure == nil {
 		// This will happen if an HTTPClient{} is used instead of NewHTTPClient().
 		return
 	}
 
-	s.requestBytesCounter.Add(ctx, md.RequestSize, opts.AddOptions())
-	s.latencyMeasure.Record(ctx, md.ElapsedTime, opts.MeasurementOption())
+	s.requestBytesCounter.Add(ctx, md.RequestSize, opts["old"].AddOptions())
+	s.latencyMeasure.Record(ctx, md.ElapsedTime, opts["old"].MeasurementOption())
 
-	// TODO: Duplicate Metrics
+	if s.duplicate {
+		s.requestBodySize.Record(ctx, md.RequestSize, opts["new"].MeasurementOption())
+		s.requestDuration.Record(ctx, md.ElapsedTime, opts["new"].MeasurementOption())
+	}
 }
 
-func (s HTTPClient) RecordResponseSize(ctx context.Context, responseData int64, opts metric.AddOption) {
+func (s HTTPClient) RecordResponseSize(ctx context.Context, responseData int64, opts map[string]MetricOpts) {
 	if s.responseBytesCounter == nil {
 		// This will happen if an HTTPClient{} is used instead of NewHTTPClient().
 		return
 	}
 
-	s.responseBytesCounter.Add(ctx, responseData, opts)
-	// TODO: Duplicate Metrics
+	s.responseBytesCounter.Add(ctx, responseData, opts["old"].AddOptions())
 }
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/httpconv.go b/instrumentation/net/http/otelhttp/internal/semconv/httpconv.go
index 3c6c6a9b9..5daf7ed9a 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/httpconv.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/httpconv.go
@@ -426,6 +426,91 @@ func (n CurrentHTTPClient) method(method string) (attribute.KeyValue, attribute.
 	return semconvNew.HTTPRequestMethodGet, orig
 }
 
+func (n CurrentHTTPClient) createMeasures(meter metric.Meter) (metric.Int64Histogram, metric.Float64Histogram) {
+	if meter == nil {
+		return noop.Int64Histogram{}, noop.Float64Histogram{}
+	}
+
+	var err error
+	requestBodySize, err := meter.Int64Histogram(
+		semconvNew.HTTPClientRequestBodySizeName,
+		metric.WithUnit(semconvNew.HTTPClientRequestBodySizeUnit),
+		metric.WithDescription(semconvNew.HTTPClientRequestBodySizeDescription),
+	)
+	handleErr(err)
+
+	requestDuration, err := meter.Float64Histogram(
+		semconvNew.HTTPClientRequestDurationName,
+		metric.WithUnit(semconvNew.HTTPClientRequestDurationUnit),
+		metric.WithDescription(semconvNew.HTTPClientRequestDurationDescription),
+	)
+	handleErr(err)
+
+	return requestBodySize, requestDuration
+}
+
+func (n CurrentHTTPClient) MetricAttributes(req *http.Request, statusCode int, additionalAttributes []attribute.KeyValue) []attribute.KeyValue {
+	num := len(additionalAttributes) + 2
+	var h string
+	if req.URL != nil {
+		h = req.URL.Host
+	}
+	var requestHost string
+	var requestPort int
+	for _, hostport := range []string{h, req.Header.Get("Host")} {
+		requestHost, requestPort = SplitHostPort(hostport)
+		if requestHost != "" || requestPort > 0 {
+			break
+		}
+	}
+
+	port := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort)
+	if port > 0 {
+		num++
+	}
+
+	protoName, protoVersion := netProtocol(req.Proto)
+	if protoName != "" {
+		num++
+	}
+	if protoVersion != "" {
+		num++
+	}
+
+	if statusCode > 0 {
+		num++
+	}
+
+	attributes := slices.Grow(additionalAttributes, num)
+	attributes = append(attributes,
+		semconvNew.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)),
+		semconvNew.ServerAddress(requestHost),
+		n.scheme(req.TLS != nil),
+	)
+
+	if port > 0 {
+		attributes = append(attributes, semconvNew.ServerPort(port))
+	}
+	if protoName != "" {
+		attributes = append(attributes, semconvNew.NetworkProtocolName(protoName))
+	}
+	if protoVersion != "" {
+		attributes = append(attributes, semconvNew.NetworkProtocolVersion(protoVersion))
+	}
+
+	if statusCode > 0 {
+		attributes = append(attributes, semconvNew.HTTPResponseStatusCode(statusCode))
+	}
+	return attributes
+}
+
+func (n CurrentHTTPClient) scheme(https bool) attribute.KeyValue { // nolint:revive
+	if https {
+		return semconvNew.URLScheme("https")
+	}
+	return semconvNew.URLScheme("http")
+}
+
 func isErrorStatusCode(code int) bool {
 	return code >= 400 || code < 100
 }
diff --git a/instrumentation/net/http/otelhttp/transport.go b/instrumentation/net/http/otelhttp/transport.go
index 39681ad4b..44b86ad86 100644
--- a/instrumentation/net/http/otelhttp/transport.go
+++ b/instrumentation/net/http/otelhttp/transport.go
@@ -153,7 +153,7 @@ func (t *Transport) RoundTrip(r *http.Request) (*http.Response, error) {
 
 	// For handling response bytes we leverage a callback when the client reads the http response
 	readRecordFunc := func(n int64) {
-		t.semconv.RecordResponseSize(ctx, n, metricOpts.AddOptions())
+		t.semconv.RecordResponseSize(ctx, n, metricOpts)
 	}
 
 	// traces


Test patch:
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/env_test.go b/instrumentation/net/http/otelhttp/internal/semconv/env_test.go
index 55e461d91..a03ec7a27 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/env_test.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/env_test.go
@@ -81,7 +81,7 @@ func TestHTTPClientDoesNotPanic(t *testing.T) {
 					Req:        req,
 					StatusCode: 200,
 				})
-				tt.client.RecordResponseSize(context.Background(), 40, opts.AddOptions())
+				tt.client.RecordResponseSize(context.Background(), 40, opts)
 				tt.client.RecordMetrics(context.Background(), MetricData{
 					RequestSize: 20,
 					ElapsedTime: 1,
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/httpconv_test.go b/instrumentation/net/http/otelhttp/internal/semconv/httpconv_test.go
index 899661c4a..3fce5d686 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/httpconv_test.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/httpconv_test.go
@@ -196,3 +196,60 @@ func TestHTTPClientStatus(t *testing.T) {
 		})
 	}
 }
+
+func TestCurrentHttpClient_MetricAttributes(t *testing.T) {
+	defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", nil)
+	require.NoError(t, err)
+
+	tests := []struct {
+		name                 string
+		server               string
+		req                  *http.Request
+		statusCode           int
+		additionalAttributes []attribute.KeyValue
+		wantFunc             func(t *testing.T, attrs []attribute.KeyValue)
+	}{
+		{
+			name:                 "routine testing",
+			req:                  defaultRequest,
+			statusCode:           200,
+			additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")},
+			wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
+				require.Len(t, attrs, 7)
+				assert.ElementsMatch(t, []attribute.KeyValue{
+					attribute.String("http.request.method", "GET"),
+					attribute.String("server.address", "example.com"),
+					attribute.String("url.scheme", "http"),
+					attribute.String("network.protocol.name", "http"),
+					attribute.String("network.protocol.version", "1.1"),
+					attribute.Int64("http.response.status_code", 200),
+					attribute.String("test", "test"),
+				}, attrs)
+			},
+		},
+		{
+			name:                 "use server address",
+			req:                  defaultRequest,
+			statusCode:           200,
+			additionalAttributes: nil,
+			wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
+				require.Len(t, attrs, 6)
+				assert.ElementsMatch(t, []attribute.KeyValue{
+					attribute.String("http.request.method", "GET"),
+					attribute.String("server.address", "example.com"),
+					attribute.String("url.scheme", "http"),
+					attribute.String("network.protocol.name", "http"),
+					attribute.String("network.protocol.version", "1.1"),
+					attribute.Int64("http.response.status_code", 200),
+				}, attrs)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := CurrentHTTPClient{}.MetricAttributes(tt.req, tt.statusCode, tt.additionalAttributes)
+			tt.wantFunc(t, got)
+		})
+	}
+}
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/test/httpconv_test.go b/instrumentation/net/http/otelhttp/internal/semconv/test/httpconv_test.go
index 7b56aa1d1..508d75887 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/test/httpconv_test.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/test/httpconv_test.go
@@ -54,7 +54,7 @@ func TestNewTraceRequest(t *testing.T) {
 	testTraceRequest(t, serv, want)
 }
 
-func TestNewRecordMetrics(t *testing.T) {
+func TestNewServerRecordMetrics(t *testing.T) {
 	oldAttrs := attribute.NewSet(
 		attribute.String("http.scheme", "http"),
 		attribute.String("http.method", "POST"),
@@ -397,6 +397,283 @@ func TestRequestErrorType(t *testing.T) {
 	}
 }
 
+func TestNewClientRecordMetrics(t *testing.T) {
+	oldAttrs := attribute.NewSet(
+		attribute.String("http.method", "POST"),
+		attribute.Int64("http.status_code", 301),
+		attribute.String("net.peer.name", "example.com"),
+	)
+
+	currAttrs := attribute.NewSet(
+		attribute.String("http.request.method", "POST"),
+		attribute.Int64("http.response.status_code", 301),
+		attribute.String("network.protocol.name", "http"),
+		attribute.String("network.protocol.version", "1.1"),
+		attribute.String("server.address", "example.com"),
+		attribute.String("url.scheme", "http"),
+	)
+
+	// The OldHTTPClient version
+	expectedOldScopeMetric := metricdata.ScopeMetrics{
+		Scope: instrumentation.Scope{
+			Name: "test",
+		},
+		Metrics: []metricdata.Metrics{
+			{
+				Name:        "http.client.request.size",
+				Description: "Measures the size of HTTP request messages.",
+				Unit:        "By",
+				Data: metricdata.Sum[int64]{
+					Temporality: metricdata.CumulativeTemporality,
+					IsMonotonic: true,
+					DataPoints: []metricdata.DataPoint[int64]{
+						{
+							Attributes: oldAttrs,
+						},
+					},
+				},
+			},
+			{
+				Name:        "http.client.duration",
+				Description: "Measures the duration of outbound HTTP requests.",
+				Unit:        "ms",
+				Data: metricdata.Histogram[float64]{
+					Temporality: metricdata.CumulativeTemporality,
+					DataPoints: []metricdata.HistogramDataPoint[float64]{
+						{
+							Attributes: oldAttrs,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	// the CurrentHTTPClient version
+	expectedCurrentScopeMetric := expectedOldScopeMetric
+	expectedCurrentScopeMetric.Metrics = append(expectedCurrentScopeMetric.Metrics, []metricdata.Metrics{
+		{
+			Name:        "http.client.request.body.size",
+			Description: "Size of HTTP client request bodies.",
+			Unit:        "By",
+			Data: metricdata.Histogram[int64]{
+				Temporality: metricdata.CumulativeTemporality,
+				DataPoints: []metricdata.HistogramDataPoint[int64]{
+					{
+						Attributes: currAttrs,
+					},
+				},
+			},
+		},
+		{
+			Name:        "http.client.request.duration",
+			Description: "Duration of HTTP client requests.",
+			Unit:        "s",
+			Data: metricdata.Histogram[float64]{
+				Temporality: metricdata.CumulativeTemporality,
+				DataPoints: []metricdata.HistogramDataPoint[float64]{
+					{
+						Attributes: currAttrs,
+					},
+				},
+			},
+		},
+	}...)
+
+	tests := []struct {
+		name       string
+		setEnv     bool
+		clientFunc func(metric.MeterProvider) semconv.HTTPClient
+		wantFunc   func(t *testing.T, rm metricdata.ResourceMetrics)
+	}{
+		{
+			name:   "No environment variable set, and no Meter",
+			setEnv: false,
+			clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(nil)
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				assert.Empty(t, rm.ScopeMetrics)
+			},
+		},
+		{
+			name:   "No environment variable set, but with Meter",
+			setEnv: false,
+			clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(mp.Meter("test"))
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				require.Len(t, rm.ScopeMetrics, 1)
+
+				// because of OldHTTPClient
+				require.Len(t, rm.ScopeMetrics[0].Metrics, 2)
+				metricdatatest.AssertEqual(t, expectedOldScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
+			},
+		},
+		{
+			name:   "Set environment variable, but no Meter",
+			setEnv: true,
+			clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(nil)
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				assert.Empty(t, rm.ScopeMetrics)
+			},
+		},
+		{
+			name:   "Set environment variable and Meter",
+			setEnv: true,
+			clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(mp.Meter("test"))
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				require.Len(t, rm.ScopeMetrics, 1)
+				require.Len(t, rm.ScopeMetrics[0].Metrics, 4)
+				metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.setEnv {
+				t.Setenv(semconv.OTelSemConvStabilityOptIn, "http/dup")
+			}
+
+			reader := sdkmetric.NewManualReader()
+			mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
+
+			client := tt.clientFunc(mp)
+			req, err := http.NewRequest("POST", "http://example.com", nil)
+			assert.NoError(t, err)
+
+			client.RecordMetrics(context.Background(), semconv.MetricData{
+				RequestSize: 100,
+				ElapsedTime: 300,
+			}, client.MetricOptions(semconv.MetricAttributes{
+				Req:        req,
+				StatusCode: 301,
+			}))
+
+			rm := metricdata.ResourceMetrics{}
+			require.NoError(t, reader.Collect(context.Background(), &rm))
+			tt.wantFunc(t, rm)
+		})
+	}
+}
+
+func TestClientRecordResponseSize(t *testing.T) {
+	oldAttrs := attribute.NewSet(
+		attribute.String("http.method", "POST"),
+		attribute.Int64("http.status_code", 301),
+		attribute.String("net.peer.name", "example.com"),
+	)
+
+	// The OldHTTPClient version
+	expectedOldScopeMetric := metricdata.ScopeMetrics{
+		Scope: instrumentation.Scope{
+			Name: "test",
+		},
+		Metrics: []metricdata.Metrics{
+			{
+				Name:        "http.client.response.size",
+				Description: "Measures the size of HTTP response messages.",
+				Unit:        "By",
+				Data: metricdata.Sum[int64]{
+					Temporality: metricdata.CumulativeTemporality,
+					IsMonotonic: true,
+					DataPoints: []metricdata.DataPoint[int64]{
+						{
+							Attributes: oldAttrs,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	// the CurrentHTTPClient version
+	expectedCurrentScopeMetric := expectedOldScopeMetric
+
+	tests := []struct {
+		name       string
+		setEnv     bool
+		clientFunc func(metric.MeterProvider) semconv.HTTPClient
+		wantFunc   func(t *testing.T, rm metricdata.ResourceMetrics)
+	}{
+		{
+			name:   "No environment variable set, and no Meter",
+			setEnv: false,
+			clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(nil)
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				assert.Empty(t, rm.ScopeMetrics)
+			},
+		},
+		{
+			name:   "No environment variable set, but with Meter",
+			setEnv: false,
+			clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(mp.Meter("test"))
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				require.Len(t, rm.ScopeMetrics, 1)
+
+				// because of OldHTTPClient
+				require.Len(t, rm.ScopeMetrics[0].Metrics, 1)
+				metricdatatest.AssertEqual(t, expectedOldScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
+			},
+		},
+		{
+			name:   "Set environment variable, but no Meter",
+			setEnv: true,
+			clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(nil)
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				assert.Empty(t, rm.ScopeMetrics)
+			},
+		},
+		{
+			name:   "Set environment variable and Meter",
+			setEnv: true,
+			clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
+				return semconv.NewHTTPClient(mp.Meter("test"))
+			},
+			wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
+				require.Len(t, rm.ScopeMetrics, 1)
+				require.Len(t, rm.ScopeMetrics[0].Metrics, 1)
+				metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.setEnv {
+				t.Setenv(semconv.OTelSemConvStabilityOptIn, "http/dup")
+			}
+
+			reader := sdkmetric.NewManualReader()
+			mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
+
+			client := tt.clientFunc(mp)
+			req, err := http.NewRequest("POST", "http://example.com", nil)
+			assert.NoError(t, err)
+
+			client.RecordResponseSize(context.Background(), 100, client.MetricOptions(semconv.MetricAttributes{
+				Req:        req,
+				StatusCode: 301,
+			}))
+
+			rm := metricdata.ResourceMetrics{}
+			require.NoError(t, reader.Collect(context.Background(), &rm))
+			tt.wantFunc(t, rm)
+		})
+	}
+}
+
 type customError struct{}
 
 func (customError) Error() string {
diff --git a/instrumentation/net/http/otelhttp/internal/semconv/test/v1.20.0_test.go b/instrumentation/net/http/otelhttp/internal/semconv/test/v1.20.0_test.go
index e46d7b278..a0961c1af 100644
--- a/instrumentation/net/http/otelhttp/internal/semconv/test/v1.20.0_test.go
+++ b/instrumentation/net/http/otelhttp/internal/semconv/test/v1.20.0_test.go
@@ -236,7 +236,7 @@ func TestV120ClientMetrics(t *testing.T) {
 
 	ctx := context.Background()
 
-	client.RecordResponseSize(ctx, 200, opts.AddOptions())
+	client.RecordResponseSize(ctx, 200, opts)
 
 	client.RecordMetrics(ctx, semconv.MetricData{
 		RequestSize: 100,