diff --git a/pom.xml b/pom.xml index d3d56506..34d5178e 100644 --- a/pom.xml +++ b/pom.xml @@ -383,12 +383,44 @@ 11.0.26 3.2.0 0.7.5 + 1.57.0 + 1.25.0-alpha 2.0.17 ${project.groupId}.shaded 3.5.2 + + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-exporter-prometheus + 1.45.0-alpha + + + io.opentelemetry.semconv + opentelemetry-semconv + ${opentelemetry-semconv.version} + com.amazonaws aws-java-sdk-s3 diff --git a/src/main/java/org/gaul/s3proxy/MetricsHandler.java b/src/main/java/org/gaul/s3proxy/MetricsHandler.java new file mode 100644 index 00000000..34a16c5b --- /dev/null +++ b/src/main/java/org/gaul/s3proxy/MetricsHandler.java @@ -0,0 +1,48 @@ +/* + * Copyright 2014-2025 Andrew Gaul + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.gaul.s3proxy; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; + +/** Jetty handler that serves Prometheus metrics at /metrics endpoint. */ +public final class MetricsHandler extends AbstractHandler { + private final S3ProxyMetrics metrics; + + public MetricsHandler(S3ProxyMetrics metrics) { + this.metrics = metrics; + } + + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest request, HttpServletResponse response) + throws IOException { + if (!"/metrics".equals(target)) { + return; + } + + response.setContentType("text/plain; version=0.0.4; charset=utf-8"); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(metrics.scrape()); + baseRequest.setHandled(true); + } +} diff --git a/src/main/java/org/gaul/s3proxy/S3Operation.java b/src/main/java/org/gaul/s3proxy/S3Operation.java new file mode 100644 index 00000000..8cea6950 --- /dev/null +++ b/src/main/java/org/gaul/s3proxy/S3Operation.java @@ -0,0 +1,57 @@ +/* + * Copyright 2014-2025 Andrew Gaul + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.gaul.s3proxy; + +/** Enumeration of S3 operations for metrics tracking. */ +public enum S3Operation { + LIST_BUCKETS("ListBuckets"), + LIST_OBJECTS_V2("ListObjectsV2"), + GET_OBJECT("GetObject"), + PUT_OBJECT("PutObject"), + DELETE_OBJECT("DeleteObject"), + DELETE_OBJECTS("DeleteObjects"), + CREATE_BUCKET("CreateBucket"), + DELETE_BUCKET("DeleteBucket"), + HEAD_BUCKET("HeadBucket"), + HEAD_OBJECT("HeadObject"), + COPY_OBJECT("CopyObject"), + CREATE_MULTIPART_UPLOAD("CreateMultipartUpload"), + UPLOAD_PART("UploadPart"), + UPLOAD_PART_COPY("UploadPartCopy"), + COMPLETE_MULTIPART_UPLOAD("CompleteMultipartUpload"), + ABORT_MULTIPART_UPLOAD("AbortMultipartUpload"), + LIST_MULTIPART_UPLOADS("ListMultipartUploads"), + LIST_PARTS("ListParts"), + GET_OBJECT_ACL("GetObjectAcl"), + PUT_OBJECT_ACL("PutObjectAcl"), + GET_BUCKET_ACL("GetBucketAcl"), + PUT_BUCKET_ACL("PutBucketAcl"), + GET_BUCKET_LOCATION("GetBucketLocation"), + GET_BUCKET_POLICY("GetBucketPolicy"), + OPTIONS_OBJECT("OptionsObject"), + UNKNOWN("Unknown"); + + private final String value; + + S3Operation(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/org/gaul/s3proxy/S3Proxy.java b/src/main/java/org/gaul/s3proxy/S3Proxy.java index 0eea7270..e3e47e6a 100644 --- a/src/main/java/org/gaul/s3proxy/S3Proxy.java +++ b/src/main/java/org/gaul/s3proxy/S3Proxy.java @@ -40,6 +40,7 @@ import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.server.handler.HandlerList; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.jclouds.blobstore.BlobStore; @@ -53,6 +54,7 @@ public final class S3Proxy { private final Server server; private final S3ProxyHandlerJetty handler; + private final S3ProxyMetrics metrics; private final boolean listenHTTP; private final boolean listenHTTPS; @@ -128,14 +130,28 @@ public final class S3Proxy { } else { listenHTTPS = false; } + if (builder.metricsEnabled) { + this.metrics = new S3ProxyMetrics( + builder.metricsHost, builder.metricsPort); + } else { + this.metrics = null; + } + handler = new S3ProxyHandlerJetty(builder.blobStore, builder.authenticationType, builder.identity, builder.credential, builder.virtualHost, builder.maxSinglePartObjectSize, builder.v4MaxNonChunkedRequestSize, builder.ignoreUnknownHeaders, builder.corsRules, - builder.servicePath, builder.maximumTimeSkew); - server.setHandler(handler); + builder.servicePath, builder.maximumTimeSkew, metrics); + + if (metrics != null) { + var metricsHandler = new MetricsHandler(metrics); + var handlerList = new HandlerList(metricsHandler, handler); + server.setHandler(handlerList); + } else { + server.setHandler(handler); + } } public static final class Builder { @@ -157,6 +173,9 @@ public static final class Builder { private CrossOriginResourceSharing corsRules; private int jettyMaxThreads = 200; // sourced from QueuedThreadPool() private int maximumTimeSkew = 15 * 60; + private boolean metricsEnabled; + private int metricsPort = S3ProxyMetrics.DEFAULT_METRICS_PORT; + private String metricsHost = S3ProxyMetrics.DEFAULT_METRICS_HOST; Builder() { } @@ -321,6 +340,24 @@ public static Builder fromProperties(Properties properties) builder.maximumTimeSkew(Integer.parseInt(maximumTimeSkew)); } + String metricsEnabled = properties.getProperty( + S3ProxyConstants.PROPERTY_METRICS_ENABLED); + if (!Strings.isNullOrEmpty(metricsEnabled)) { + builder.metricsEnabled(Boolean.parseBoolean(metricsEnabled)); + } + + String metricsPort = properties.getProperty( + S3ProxyConstants.PROPERTY_METRICS_PORT); + if (!Strings.isNullOrEmpty(metricsPort)) { + builder.metricsPort(Integer.parseInt(metricsPort)); + } + + String metricsHost = properties.getProperty( + S3ProxyConstants.PROPERTY_METRICS_HOST); + if (!Strings.isNullOrEmpty(metricsHost)) { + builder.metricsHost(metricsHost); + } + return builder; } @@ -409,6 +446,21 @@ public Builder maximumTimeSkew(int maximumTimeSkew) { return this; } + public Builder metricsEnabled(boolean metricsEnabled) { + this.metricsEnabled = metricsEnabled; + return this; + } + + public Builder metricsPort(int metricsPort) { + this.metricsPort = metricsPort; + return this; + } + + public Builder metricsHost(String metricsHost) { + this.metricsHost = requireNonNull(metricsHost); + return this; + } + public Builder servicePath(String s3ProxyServicePath) { String path = Strings.nullToEmpty(s3ProxyServicePath); @@ -487,6 +539,9 @@ public void start() throws Exception { public void stop() throws Exception { server.stop(); + if (metrics != null) { + metrics.close(); + } } public int getPort() { diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java b/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java index 8af6bffd..f53c80f3 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyConstants.java @@ -144,6 +144,15 @@ public final class S3ProxyConstants { public static final String PROPERTY_NO_CACHE_BLOBSTORE = "s3proxy.no-cache-blobstore"; + /** Enable Prometheus metrics endpoint at /metrics. */ + public static final String PROPERTY_METRICS_ENABLED = + "s3proxy.metrics.enabled"; + + public static final String PROPERTY_METRICS_PORT = + "s3proxy.metrics.port"; + public static final String PROPERTY_METRICS_HOST = + "s3proxy.metrics.host"; + static final String PROPERTY_ALT_JCLOUDS_PREFIX = "alt."; private S3ProxyConstants() { diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java b/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java index 72620887..e74911bb 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyHandler.java @@ -117,6 +117,27 @@ public class S3ProxyHandler { private static final Logger logger = LoggerFactory.getLogger( S3ProxyHandler.class); + + public static final class RequestContext { + private S3Operation operation; + private String bucket; + + public S3Operation getOperation() { + return operation; + } + + public void setOperation(S3Operation operation) { + this.operation = operation; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + } private static final String AWS_XMLNS = "http://s3.amazonaws.com/doc/2006-03-01/"; // TODO: support configurable metadata prefix @@ -294,7 +315,8 @@ private static boolean isValidContainer(String containerName) { public final void doHandle(HttpServletRequest baseRequest, HttpServletRequest request, HttpServletResponse response, - InputStream is) throws IOException, S3Exception { + InputStream is, @Nullable RequestContext ctx) + throws IOException, S3Exception { String method = request.getMethod(); String uri = request.getRequestURI(); String originalUri = request.getRequestURI(); @@ -357,7 +379,8 @@ public final void doHandle(HttpServletRequest baseRequest, request.getParameter("X-Amz-Algorithm") == null && // v4 query request.getParameter("AWSAccessKeyId") == null && // v2 query defaultBlobStore != null) { - doHandleAnonymous(request, response, is, uri, defaultBlobStore); + doHandleAnonymous(request, response, is, uri, defaultBlobStore, + ctx); return; } @@ -668,76 +691,98 @@ public final void doHandle(HttpServletRequest baseRequest, } String uploadId = request.getParameter("uploadId"); + + if (ctx != null && path.length > 1 && !path[1].isEmpty()) { + ctx.setBucket(path[1]); + } + switch (method) { case "DELETE": if (path.length <= 2 || path[2].isEmpty()) { + setOperation(ctx, S3Operation.DELETE_BUCKET); handleContainerDelete(request, response, blobStore, path[1]); return; } else if (uploadId != null) { + setOperation(ctx, S3Operation.ABORT_MULTIPART_UPLOAD); handleAbortMultipartUpload(request, response, blobStore, path[1], path[2], uploadId); return; } else { + setOperation(ctx, S3Operation.DELETE_OBJECT); handleBlobRemove(request, response, blobStore, path[1], path[2]); return; } case "GET": if (uri.equals("/")) { + setOperation(ctx, S3Operation.LIST_BUCKETS); handleContainerList(request, response, blobStore); return; } else if (path.length <= 2 || path[2].isEmpty()) { if (request.getParameter("acl") != null) { + setOperation(ctx, S3Operation.GET_BUCKET_ACL); handleGetContainerAcl(request, response, blobStore, path[1]); return; } else if (request.getParameter("location") != null) { + setOperation(ctx, S3Operation.GET_BUCKET_LOCATION); handleContainerLocation(request, response); return; } else if (request.getParameter("policy") != null) { + setOperation(ctx, S3Operation.GET_BUCKET_POLICY); handleBucketPolicy(blobStore, path[1]); return; } else if (request.getParameter("uploads") != null) { + setOperation(ctx, S3Operation.LIST_MULTIPART_UPLOADS); handleListMultipartUploads(request, response, blobStore, path[1]); return; } + setOperation(ctx, S3Operation.LIST_OBJECTS_V2); handleBlobList(request, response, blobStore, path[1]); return; } else { if (request.getParameter("acl") != null) { + setOperation(ctx, S3Operation.GET_OBJECT_ACL); handleGetBlobAcl(request, response, blobStore, path[1], path[2]); return; } else if (uploadId != null) { + setOperation(ctx, S3Operation.LIST_PARTS); handleListParts(request, response, blobStore, path[1], path[2], uploadId); return; } + setOperation(ctx, S3Operation.GET_OBJECT); handleGetBlob(request, response, blobStore, path[1], path[2]); return; } case "HEAD": if (path.length <= 2 || path[2].isEmpty()) { + setOperation(ctx, S3Operation.HEAD_BUCKET); handleContainerExists(request, response, blobStore, path[1]); return; } else { + setOperation(ctx, S3Operation.HEAD_OBJECT); handleBlobMetadata(request, response, blobStore, path[1], path[2]); return; } case "POST": if (request.getParameter("delete") != null) { + setOperation(ctx, S3Operation.DELETE_OBJECTS); handleMultiBlobRemove(request, response, is, blobStore, path[1]); return; } else if (request.getParameter("uploads") != null) { + setOperation(ctx, S3Operation.CREATE_MULTIPART_UPLOAD); handleInitiateMultipartUpload(request, response, blobStore, path[1], path[2]); return; } else if (uploadId != null && request.getParameter("partNumber") == null) { + setOperation(ctx, S3Operation.COMPLETE_MULTIPART_UPLOAD); handleCompleteMultipartUpload(request, response, is, blobStore, path[1], path[2], uploadId); return; @@ -746,47 +791,63 @@ public final void doHandle(HttpServletRequest baseRequest, case "PUT": if (path.length <= 2 || path[2].isEmpty()) { if (request.getParameter("acl") != null) { + setOperation(ctx, S3Operation.PUT_BUCKET_ACL); handleSetContainerAcl(request, response, is, blobStore, path[1]); return; } + setOperation(ctx, S3Operation.CREATE_BUCKET); handleContainerCreate(request, response, is, blobStore, path[1]); return; } else if (uploadId != null) { if (request.getHeader(AwsHttpHeaders.COPY_SOURCE) != null) { + setOperation(ctx, S3Operation.UPLOAD_PART_COPY); handleCopyPart(request, response, blobStore, path[1], path[2], uploadId); } else { + setOperation(ctx, S3Operation.UPLOAD_PART); handleUploadPart(request, response, is, blobStore, path[1], path[2], uploadId); } return; } else if (request.getHeader(AwsHttpHeaders.COPY_SOURCE) != null) { + setOperation(ctx, S3Operation.COPY_OBJECT); handleCopyBlob(request, response, is, blobStore, path[1], path[2]); return; } else { if (request.getParameter("acl") != null) { + setOperation(ctx, S3Operation.PUT_OBJECT_ACL); handleSetBlobAcl(request, response, is, blobStore, path[1], path[2]); return; } + setOperation(ctx, S3Operation.PUT_OBJECT); handlePutBlob(request, response, is, blobStore, path[1], path[2]); return; } case "OPTIONS": + setOperation(ctx, S3Operation.OPTIONS_OBJECT); handleOptionsBlob(request, response, blobStore, path[1]); return; default: break; } + setOperation(ctx, S3Operation.UNKNOWN); logger.error("Unknown method {} with URI {}", method, request.getRequestURI()); throw new S3Exception(S3ErrorCode.NOT_IMPLEMENTED); } + private static void setOperation(@Nullable RequestContext ctx, + S3Operation operation) { + if (ctx != null) { + ctx.setOperation(operation); + } + } + private static boolean checkPublicAccess(BlobStore blobStore, String containerName, String blobName) { String blobStoreType = getBlobStoreType(blobStore); @@ -803,29 +864,39 @@ private static boolean checkPublicAccess(BlobStore blobStore, private void doHandleAnonymous(HttpServletRequest request, HttpServletResponse response, InputStream is, String uri, - BlobStore blobStore) + BlobStore blobStore, @Nullable RequestContext ctx) throws IOException, S3Exception { String method = request.getMethod(); String[] path = uri.split("/", 3); + + if (ctx != null && path.length > 1 && !path[1].isEmpty()) { + ctx.setBucket(path[1]); + } + switch (method) { case "GET": if (uri.equals("/")) { + setOperation(ctx, S3Operation.LIST_BUCKETS); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } else if (path.length <= 2 || path[2].isEmpty()) { String containerName = path[1]; ContainerAccess access = blobStore.getContainerAccess( containerName); if (access == ContainerAccess.PRIVATE) { + setOperation(ctx, S3Operation.LIST_OBJECTS_V2); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } + setOperation(ctx, S3Operation.LIST_OBJECTS_V2); handleBlobList(request, response, blobStore, containerName); return; } else { String containerName = path[1]; String blobName = path[2]; if (!checkPublicAccess(blobStore, containerName, blobName)) { + setOperation(ctx, S3Operation.GET_OBJECT); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } + setOperation(ctx, S3Operation.GET_OBJECT); handleGetBlob(request, response, blobStore, containerName, blobName); return; @@ -836,8 +907,10 @@ private void doHandleAnonymous(HttpServletRequest request, ContainerAccess access = blobStore.getContainerAccess( containerName); if (access == ContainerAccess.PRIVATE) { + setOperation(ctx, S3Operation.HEAD_BUCKET); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } + setOperation(ctx, S3Operation.HEAD_BUCKET); if (!blobStore.containerExists(containerName)) { throw new S3Exception(S3ErrorCode.NO_SUCH_BUCKET); } @@ -845,29 +918,35 @@ private void doHandleAnonymous(HttpServletRequest request, String containerName = path[1]; String blobName = path[2]; if (!checkPublicAccess(blobStore, containerName, blobName)) { + setOperation(ctx, S3Operation.HEAD_OBJECT); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } + setOperation(ctx, S3Operation.HEAD_OBJECT); handleBlobMetadata(request, response, blobStore, containerName, blobName); } return; case "POST": if (path.length <= 2 || path[2].isEmpty()) { + setOperation(ctx, S3Operation.PUT_OBJECT); handlePostBlob(request, response, is, blobStore, path[1]); return; } break; case "OPTIONS": if (uri.equals("/")) { + setOperation(ctx, S3Operation.OPTIONS_OBJECT); throw new S3Exception(S3ErrorCode.ACCESS_DENIED); } else { String containerName = path[1]; + setOperation(ctx, S3Operation.OPTIONS_OBJECT); handleOptionsBlob(request, response, blobStore, containerName); return; } default: break; } + setOperation(ctx, S3Operation.UNKNOWN); logger.error("Unknown method {} with URI {}", method, request.getRequestURI()); throw new S3Exception(S3ErrorCode.NOT_IMPLEMENTED); diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java b/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java index 521d1940..a7b7b17c 100644 --- a/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java +++ b/src/main/java/org/gaul/s3proxy/S3ProxyHandlerJetty.java @@ -47,17 +47,20 @@ final class S3ProxyHandlerJetty extends AbstractHandler { S3ProxyHandlerJetty.class); private final S3ProxyHandler handler; + private final S3ProxyMetrics metrics; S3ProxyHandlerJetty(final BlobStore blobStore, AuthenticationType authenticationType, final String identity, final String credential, @Nullable String virtualHost, long maxSinglePartObjectSize, long v4MaxNonChunkedRequestSize, boolean ignoreUnknownHeaders, CrossOriginResourceSharing corsRules, - String servicePath, int maximumTimeSkew) { + String servicePath, int maximumTimeSkew, + @Nullable S3ProxyMetrics metrics) { handler = new S3ProxyHandler(blobStore, authenticationType, identity, credential, virtualHost, maxSinglePartObjectSize, v4MaxNonChunkedRequestSize, ignoreUnknownHeaders, corsRules, servicePath, maximumTimeSkew); + this.metrics = metrics; } private void sendS3Exception(HttpServletRequest request, @@ -71,13 +74,16 @@ private void sendS3Exception(HttpServletRequest request, public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException { + long startNanos = System.nanoTime(); + var ctx = new S3ProxyHandler.RequestContext(); + try (InputStream is = request.getInputStream()) { // Set query encoding baseRequest.setAttribute(S3ProxyConstants.ATTRIBUTE_QUERY_ENCODING, baseRequest.getQueryEncoding()); - handler.doHandle(baseRequest, request, response, is); + handler.doHandle(baseRequest, request, response, is, ctx); baseRequest.setHandled(true); } catch (ContainerNotFoundException cnfe) { S3ErrorCode code = S3ErrorCode.NO_SUCH_BUCKET; @@ -142,7 +148,8 @@ public void handle(String target, Request baseRequest, baseRequest.setHandled(true); return; } catch (IOException ioe) { - var cause = Throwables2.getFirstThrowableOfType(ioe, S3Exception.class); + var cause = Throwables2.getFirstThrowableOfType(ioe, + S3Exception.class); if (cause != null) { sendS3Exception(request, response, cause); baseRequest.setHandled(true); @@ -184,7 +191,25 @@ public void handle(String target, Request baseRequest, logger.debug("Unknown exception:", throwable); throw throwable; } + } finally { + recordMetrics(request, response, ctx, startNanos); + } + } + + private void recordMetrics(HttpServletRequest request, + HttpServletResponse response, S3ProxyHandler.RequestContext ctx, + long startNanos) { + if (metrics == null || ctx.getOperation() == null) { + return; } + long durationNanos = System.nanoTime() - startNanos; + metrics.recordRequest( + request.getMethod(), + request.getScheme(), + response.getStatus(), + ctx.getOperation(), + ctx.getBucket(), + durationNanos); } public S3ProxyHandler getHandler() { diff --git a/src/main/java/org/gaul/s3proxy/S3ProxyMetrics.java b/src/main/java/org/gaul/s3proxy/S3ProxyMetrics.java new file mode 100644 index 00000000..c4bc9f8c --- /dev/null +++ b/src/main/java/org/gaul/s3proxy/S3ProxyMetrics.java @@ -0,0 +1,114 @@ +/* + * Copyright 2014-2025 Andrew Gaul + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 org.gaul.s3proxy; + +import java.util.List; + +import javax.annotation.Nullable; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.exporter.prometheus.PrometheusHttpServer; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.UrlAttributes; + +public final class S3ProxyMetrics { + /** Default metrics port (0 = ephemeral). */ + public static final int DEFAULT_METRICS_PORT = 0; + public static final String DEFAULT_METRICS_HOST = "0.0.0.0"; + + private static final AttributeKey S3_OPERATION = + AttributeKey.stringKey("s3.operation"); + private static final AttributeKey S3_BUCKET = + AttributeKey.stringKey("s3.bucket"); + // OTel semantic conventions specify these bucket boundaries for + // http.server.request.duration histogram. + // See: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/ + private static final List DURATION_BUCKETS = List.of( + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, + 0.75, 1.0, 2.5, 5.0, 7.5, 10.0); + + private final SdkMeterProvider meterProvider; + private final DoubleHistogram requestDuration; + private final PrometheusHttpServer prometheusServer; + + public S3ProxyMetrics() { + this(DEFAULT_METRICS_HOST, DEFAULT_METRICS_PORT); + } + + public S3ProxyMetrics(String host, int port) { + prometheusServer = PrometheusHttpServer.builder() + .setHost(host) + .setPort(port) + .build(); + + meterProvider = SdkMeterProvider.builder() + .registerMetricReader(prometheusServer) + .build(); + + Meter meter = meterProvider.get("org.gaul.s3proxy"); + + requestDuration = meter.histogramBuilder("http.server.request.duration") + .setDescription("Duration of HTTP server requests") + .setUnit("s") + .setExplicitBucketBoundariesAdvice(DURATION_BUCKETS) + .build(); + } + + public void recordRequest( + String method, + String scheme, + int statusCode, + @Nullable S3Operation operation, + @Nullable String bucket, + long durationNanos) { + if (operation == null) { + return; + } + + double durationSeconds = durationNanos / 1_000_000_000.0; + + AttributesBuilder builder = Attributes.builder() + .put(HttpAttributes.HTTP_REQUEST_METHOD, method) + .put(UrlAttributes.URL_SCHEME, scheme) + .put(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, statusCode) + .put(S3_OPERATION, operation.getValue()); + + if (bucket != null && !bucket.isEmpty()) { + builder.put(S3_BUCKET, bucket); + } + + requestDuration.record(durationSeconds, builder.build()); + } + + public String scrape() { + return prometheusServer.toString(); + } + + public void close() { + if (prometheusServer != null) { + prometheusServer.close(); + } + if (meterProvider != null) { + meterProvider.close(); + } + } +}