diff --git a/.changes/next-release/feature-AWSSDKforJavav2-aa60cd3.json b/.changes/next-release/feature-AWSSDKforJavav2-aa60cd3.json new file mode 100644 index 000000000000..48c75e006610 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-aa60cd3.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "Amazon S3", + "contributor": "", + "description": "Implemented business metrics tracking for S3_Express_Bucket (featureID \"J\") through User-Agent header." +} diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverInterceptorSpec.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverInterceptorSpec.java index 7bac753e88e5..83c81b97ad7d 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverInterceptorSpec.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverInterceptorSpec.java @@ -921,6 +921,12 @@ private MethodSpec setMetricValuesMethod() { + "metrics -> endpoint.attribute($T.METRIC_VALUES).forEach(v -> metrics.addMetric(v)))", SdkInternalExecutionAttribute.class, AwsEndpointAttribute.class); b.endControlFlow(); + + if (endpointRulesSpecUtils.isS3()) { + b.addStatement("$T.addS3ExpressBusinessMetricIfApplicable(executionAttributes)", + ClassName.get("software.amazon.awssdk.services.s3.internal.s3express", "S3ExpressUtils")); + } + return b.build(); } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java index 7f1483d56895..005eb0f83d32 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java @@ -36,6 +36,7 @@ public enum BusinessMetricFeatureId { S3_TRANSFER("G"), GZIP_REQUEST_COMPRESSION("L"), //TODO(metrics): Not working, compression happens after header ENDPOINT_OVERRIDE("N"), + S3_EXPRESS_BUCKET("J"), ACCOUNT_ID_MODE_PREFERRED("P"), ACCOUNT_ID_MODE_DISABLED("Q"), ACCOUNT_ID_MODE_REQUIRED("R"), diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/s3express/S3ExpressUtils.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/s3express/S3ExpressUtils.java index b55200c5e536..441e575687e4 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/s3express/S3ExpressUtils.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/s3express/S3ExpressUtils.java @@ -21,6 +21,7 @@ import software.amazon.awssdk.core.SelectedAuthScheme; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.endpoints.Endpoint; import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption; import software.amazon.awssdk.services.s3.endpoints.internal.KnownS3ExpressEndpointProperty; @@ -57,4 +58,15 @@ public static boolean useS3ExpressAuthScheme(ExecutionAttributes executionAttrib } return false; } + + /** + * Adds S3 Express business metric if applicable for the current operation. + */ + public static void addS3ExpressBusinessMetricIfApplicable(ExecutionAttributes executionAttributes) { + if (executionAttributes != null && useS3Express(executionAttributes) && useS3ExpressAuthScheme(executionAttributes)) { + executionAttributes.getOptionalAttribute(SdkInternalExecutionAttribute.BUSINESS_METRICS) + .ifPresent(businessMetrics -> + businessMetrics.addMetric(BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value())); + } + } } diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentTest.java new file mode 100644 index 000000000000..4776ea0249d7 --- /dev/null +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/s3express/S3ExpressUserAgentTest.java @@ -0,0 +1,174 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.services.s3.s3express; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.core.sync.ResponseTransformer; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; +import software.amazon.awssdk.utils.StringInputStream; + +/** + * Unit test to verify that S3 Express operations include the correct business metric feature ID + * in the User-Agent header. + */ +public class S3ExpressUserAgentTest { + private static final String KEY = "test-feature-id.txt"; + private static final String CONTENTS = "test content for feature id validation"; + private static final String S3_EXPRESS_BUCKET = "my-test-bucket--use1-az4--x-s3"; + private static final String REGULAR_BUCKET = "my-test-bucket-regular"; + + public static final UnaryOperator METRIC_SEARCH_PATTERN = + metric -> ".*m/[a-zA-Z0-9+-,]*" + metric + ".*"; + + private MockSyncHttpClient mockHttpClient; + private S3Client s3Client; + + @BeforeEach + void setup() { + // Mock HTTP client + mockHttpClient = new MockSyncHttpClient(); + + // Mock CreateSession response for S3 Express authentication + String createSessionResponse = "\n" + + "\n" + + " \n" + + " mock-session-token\n" + + " mock-secret-key\n" + + " mock-access-key\n" + + " 2025-12-31T23:59:59Z\n" + + " \n" + + ""; + + HttpExecuteResponse createSessionHttpResponse = HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create(new StringInputStream(createSessionResponse))) + .build(); + + HttpExecuteResponse putResponse = HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create(new StringInputStream(""))) + .build(); + + HttpExecuteResponse getResponse = HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create(new StringInputStream(CONTENTS))) + .build(); + + mockHttpClient.stubResponses( + createSessionHttpResponse, // First CreateSession call for S3 Express bucket + putResponse, // PUT operation + createSessionHttpResponse, // Second CreateSession call for S3 Express bucket + getResponse, // GET operation + putResponse, // PUT operation for regular bucket + getResponse // GET operation for regular bucket + ); + + // S3 client with mocked HTTP client + s3Client = S3Client.builder() + .region(Region.US_EAST_1) + .credentialsProvider(AnonymousCredentialsProvider.create()) + .httpClient(mockHttpClient) + .build(); + } + + @Test + void putObject_whenS3ExpressBucket_shouldIncludeS3ExpressFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(S3_EXPRESS_BUCKET) + .key(KEY) + .build(); + + s3Client.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + SdkHttpRequest lastRequest = mockHttpClient.getLastRequest(); + assertThat(lastRequest).isNotNull(); + + List userAgentHeaders = lastRequest.headers().get("User-Agent"); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + + assertThat(userAgentHeaders.get(0)).matches(METRIC_SEARCH_PATTERN.apply("J")); + } + + @Test + void getObject_whenS3ExpressBucket_shouldIncludeS3ExpressFeatureIdInUserAgent() { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(S3_EXPRESS_BUCKET) + .key(KEY) + .build(); + + s3Client.getObject(getRequest, ResponseTransformer.toBytes()); + + SdkHttpRequest lastRequest = mockHttpClient.getLastRequest(); + assertThat(lastRequest).isNotNull(); + + List userAgentHeaders = lastRequest.headers().get("User-Agent"); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + + assertThat(userAgentHeaders.get(0)).matches(METRIC_SEARCH_PATTERN.apply("J")); + } + + @Test + void putObject_whenRegularS3Bucket_shouldNotIncludeS3ExpressFeatureIdInUserAgent() { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(REGULAR_BUCKET) + .key(KEY) + .build(); + + s3Client.putObject(putRequest, RequestBody.fromString(CONTENTS)); + + SdkHttpRequest lastRequest = mockHttpClient.getLastRequest(); + assertThat(lastRequest).isNotNull(); + + List userAgentHeaders = lastRequest.headers().get("User-Agent"); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + + assertThat(userAgentHeaders.get(0)).doesNotMatch(METRIC_SEARCH_PATTERN.apply("J")); + } + + @Test + void getObject_whenRegularS3Bucket_shouldNotIncludeS3ExpressFeatureIdInUserAgent() { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(REGULAR_BUCKET) + .key(KEY) + .build(); + + s3Client.getObject(getRequest, ResponseTransformer.toBytes()); + + SdkHttpRequest lastRequest = mockHttpClient.getLastRequest(); + assertThat(lastRequest).isNotNull(); + + List userAgentHeaders = lastRequest.headers().get("User-Agent"); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + + assertThat(userAgentHeaders.get(0)).doesNotMatch(METRIC_SEARCH_PATTERN.apply("J")); + } + +}