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();
+ }
+ }
+}