This is an automated email from the ASF dual-hosted git repository.
gaborgsomogyi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new df122f59096 [FLINK-39546][s3] Improve observability in
flink-s3-fs-native by exposing operation-level S3 metrics
df122f59096 is described below
commit df122f590962d55917b0f9033f587467f8596490
Author: Samrat <[email protected]>
AuthorDate: Wed Aug 19 18:14:56 2026 +0530
[FLINK-39546][s3] Improve observability in flink-s3-fs-native by exposing
operation-level S3 metrics
---
docs/content.zh/docs/deployment/filesystems/s3.md | 6 +
docs/content/docs/deployment/filesystems/s3.md | 6 +
.../flink/core/fs/ConnectionLimitingFactory.java | 14 +-
.../java/org/apache/flink/core/fs/FileSystem.java | 41 ++++
.../flink/core/fs/PluginFileSystemFactory.java | 18 +-
.../org/apache/flink/core/plugin/MetricsAware.java | 47 +++++
.../flink/core/fs/FileSystemAttachMetricsTest.java | 231 +++++++++++++++++++++
flink-filesystems/flink-s3-fs-native/README.md | 20 ++
.../fs/s3native/NativeS3FileSystemFactory.java | 83 +++++++-
.../apache/flink/fs/s3native/S3ClientProvider.java | 20 +-
.../fs/s3native/metrics/AwsSdkMetricBridge.java | 165 +++++++++++++++
.../fs/s3native/metrics/S3MetricRecorder.java | 177 ++++++++++++++++
.../fs/s3native/NativeS3FileSystemFactoryTest.java | 44 ++++
.../s3native/metrics/AwsSdkMetricBridgeTest.java | 187 +++++++++++++++++
.../NativeS3FileSystemFactoryMetricsTest.java | 95 +++++++++
.../metrics/NativeS3MetricsEmissionITCase.java | 218 +++++++++++++++++++
.../fs/s3native/metrics/S3MetricRecorderTest.java | 120 +++++++++++
.../flink/metrics/SlidingWindowHistogram.java | 151 ++++++++++++++
.../flink/metrics/SlidingWindowHistogramTest.java | 73 +++++++
.../runtime/entrypoint/ClusterEntrypoint.java | 2 +
.../runtime/taskexecutor/TaskManagerRunner.java | 3 +
21 files changed, 1715 insertions(+), 6 deletions(-)
diff --git a/docs/content.zh/docs/deployment/filesystems/s3.md
b/docs/content.zh/docs/deployment/filesystems/s3.md
index 44e6dde5c58..f8f7bad7a8e 100644
--- a/docs/content.zh/docs/deployment/filesystems/s3.md
+++ b/docs/content.zh/docs/deployment/filesystems/s3.md
@@ -172,6 +172,12 @@ In addition to the [common
configuration](#common-configuration) options (`s3.ac
View the detailed configuration at
[native-s3-fs](https://github.com/apache/flink/tree/master/flink-filesystems/flink-s3-fs-native#configuration-options)
+#### Metrics
+
+The Native S3 FileSystem can publish AWS SDK operation metrics into Flink's
process-level metric group on the JobManager and TaskManager. Metrics are
enabled by default with `s3.metrics.enabled: true` and are scoped under
`filesystem.filesystem_type.s3` or `filesystem.filesystem_type.s3a`.
+
+The default metric set includes `api_call_count`, `api_call_duration_ms`,
`throttle_count`, `retry_count`, and reporter-derived `iops`. Use
`s3.metrics.allowlist` to restrict the registered metrics, or set
`s3.metrics.enabled: false` to disable S3 operation metrics. Empty allowlists
are rejected.
+
---
### Presto S3 FileSystem
diff --git a/docs/content/docs/deployment/filesystems/s3.md
b/docs/content/docs/deployment/filesystems/s3.md
index a49ac14e678..36c2b55bad3 100644
--- a/docs/content/docs/deployment/filesystems/s3.md
+++ b/docs/content/docs/deployment/filesystems/s3.md
@@ -180,6 +180,12 @@ s3.region: us-east-1
s3.checksum-validation.enabled: false
```
+#### Metrics
+
+The Native S3 FileSystem can publish AWS SDK operation metrics into Flink's
process-level metric group on the JobManager and TaskManager. Metrics are
enabled by default with `s3.metrics.enabled: true` and are scoped under
`filesystem.filesystem_type.s3` or `filesystem.filesystem_type.s3a`.
+
+The default metric set includes `api_call_count`, `api_call_duration_ms`,
`throttle_count`, `retry_count`, and reporter-derived `iops`. Use
`s3.metrics.allowlist` to restrict the registered metrics, or set
`s3.metrics.enabled: false` to disable S3 operation metrics. Empty allowlists
are rejected.
+
---
### Presto S3 FileSystem
diff --git
a/flink-core/src/main/java/org/apache/flink/core/fs/ConnectionLimitingFactory.java
b/flink-core/src/main/java/org/apache/flink/core/fs/ConnectionLimitingFactory.java
index 7fdb2dac4f2..1d57aeea387 100644
---
a/flink-core/src/main/java/org/apache/flink/core/fs/ConnectionLimitingFactory.java
+++
b/flink-core/src/main/java/org/apache/flink/core/fs/ConnectionLimitingFactory.java
@@ -21,6 +21,8 @@ package org.apache.flink.core.fs;
import org.apache.flink.annotation.Internal;
import org.apache.flink.configuration.Configuration;
import
org.apache.flink.core.fs.LimitedConnectionsFileSystem.ConnectionLimitingSettings;
+import org.apache.flink.core.plugin.MetricsAware;
+import org.apache.flink.metrics.MetricGroup;
import java.io.IOException;
import java.net.URI;
@@ -29,7 +31,7 @@ import static
org.apache.flink.util.Preconditions.checkNotNull;
/** A wrapping factory that adds a {@link LimitedConnectionsFileSystem} to a
file system. */
@Internal
-public class ConnectionLimitingFactory implements FileSystemFactory {
+public class ConnectionLimitingFactory implements FileSystemFactory,
MetricsAware {
private final FileSystemFactory factory;
@@ -59,6 +61,16 @@ public class ConnectionLimitingFactory implements
FileSystemFactory {
factory.configure(config);
}
+ /**
+ * Forwards metric registration to the wrapped factory when it supports
{@link MetricsAware}.
+ */
+ @Override
+ public void setMetricGroup(MetricGroup metricGroup) {
+ if (factory instanceof MetricsAware) {
+ ((MetricsAware) factory).setMetricGroup(metricGroup);
+ }
+ }
+
@Override
public FileSystem create(URI fsUri) throws IOException {
FileSystem original = factory.create(fsUri);
diff --git a/flink-core/src/main/java/org/apache/flink/core/fs/FileSystem.java
b/flink-core/src/main/java/org/apache/flink/core/fs/FileSystem.java
index 3699b2a2962..ba6a0a9d953 100644
--- a/flink-core/src/main/java/org/apache/flink/core/fs/FileSystem.java
+++ b/flink-core/src/main/java/org/apache/flink/core/fs/FileSystem.java
@@ -32,7 +32,9 @@ import org.apache.flink.configuration.CoreOptions;
import org.apache.flink.configuration.IllegalConfigurationException;
import org.apache.flink.core.fs.local.LocalFileSystem;
import org.apache.flink.core.fs.local.LocalFileSystemFactory;
+import org.apache.flink.core.plugin.MetricsAware;
import org.apache.flink.core.plugin.PluginManager;
+import org.apache.flink.metrics.MetricGroup;
import org.apache.flink.util.ExceptionUtils;
import org.apache.flink.util.TemporaryClassLoaderContext;
import org.apache.flink.util.WrappingProxy;
@@ -316,6 +318,45 @@ public abstract class FileSystem implements IFileSystem {
}
}
+ /**
+ * Hands a runtime-owned, process-level {@link MetricGroup} to every
registered {@link
+ * FileSystemFactory} that opts into metrics via {@link MetricsAware}.
+ *
+ * <p>This is the second phase of file system initialization. {@link
#initialize(Configuration,
+ * PluginManager)} runs at process startup, before the {@code
MetricRegistry} exists; this
+ * method is therefore invoked separately, once the registry and a
process-level {@link
+ * MetricGroup} are available. It is called from the TaskManager and
JobManager entrypoints
+ * only. Contexts without a process-level {@link MetricGroup} (CLI,
HistoryServer, YARN client)
+ * simply never call it, and their file system plugins continue to operate
without emitting
+ * metrics.
+ *
+ * <p>The call is idempotent: factories receive a child group {@code
<process>.filesystem}, and
+ * {@link MetricGroup#addGroup} returns the same child on repeated calls
with the same parent,
+ * so re-invocation does not register duplicate metrics. Factories that do
not implement {@link
+ * MetricsAware} are skipped.
+ *
+ * @param processMetricGroup the process-level metric group to register
file system metrics
+ * under.
+ */
+ @Internal
+ public static void attachMetrics(MetricGroup processMetricGroup) {
+ checkNotNull(processMetricGroup, "processMetricGroup");
+ LOCK.lock();
+ try {
+ final MetricGroup fsGroup =
processMetricGroup.addGroup("filesystem");
+ for (FileSystemFactory factory : FS_FACTORIES.values()) {
+ // Plugin-loaded factories are wrapped in a
PluginFileSystemFactory, which is itself
+ // MetricsAware and forwards setMetricGroup to the inner
factory under the plugin
+ // classloader, so this plain instanceof reaches both wrapped
and direct factories.
+ if (factory instanceof MetricsAware) {
+ ((MetricsAware) factory).setMetricGroup(fsGroup);
+ }
+ }
+ } finally {
+ LOCK.unlock();
+ }
+ }
+
/**
* Initializes the shared file system settings.
*
diff --git
a/flink-core/src/main/java/org/apache/flink/core/fs/PluginFileSystemFactory.java
b/flink-core/src/main/java/org/apache/flink/core/fs/PluginFileSystemFactory.java
index e7eb5892363..c2ff0d5e893 100644
---
a/flink-core/src/main/java/org/apache/flink/core/fs/PluginFileSystemFactory.java
+++
b/flink-core/src/main/java/org/apache/flink/core/fs/PluginFileSystemFactory.java
@@ -18,6 +18,8 @@
package org.apache.flink.core.fs;
import org.apache.flink.configuration.Configuration;
+import org.apache.flink.core.plugin.MetricsAware;
+import org.apache.flink.metrics.MetricGroup;
import org.apache.flink.util.TemporaryClassLoaderContext;
import org.apache.flink.util.WrappingProxy;
@@ -30,7 +32,7 @@ import java.util.List;
* {@link FileSystem} operations.
*/
public class PluginFileSystemFactory
- implements FileSystemFactory, WrappingProxy<FileSystemFactory> {
+ implements FileSystemFactory, WrappingProxy<FileSystemFactory>,
MetricsAware {
private final FileSystemFactory inner;
private final ClassLoader loader;
@@ -58,6 +60,20 @@ public class PluginFileSystemFactory
inner.configure(config);
}
+ /**
+ * Forwards the metric group to the wrapped factory if it opts into
metrics, using the plugin
+ * classloader so the factory observes the same isolation as every other
call routed through
+ * this wrapper.
+ */
+ @Override
+ public void setMetricGroup(final MetricGroup metricGroup) {
+ if (inner instanceof MetricsAware) {
+ try (TemporaryClassLoaderContext ignored =
TemporaryClassLoaderContext.of(loader)) {
+ ((MetricsAware) inner).setMetricGroup(metricGroup);
+ }
+ }
+ }
+
@Override
public FileSystem create(final URI fsUri) throws IOException {
try (TemporaryClassLoaderContext ignored =
TemporaryClassLoaderContext.of(loader)) {
diff --git
a/flink-core/src/main/java/org/apache/flink/core/plugin/MetricsAware.java
b/flink-core/src/main/java/org/apache/flink/core/plugin/MetricsAware.java
new file mode 100644
index 00000000000..5a2c21bcfe0
--- /dev/null
+++ b/flink-core/src/main/java/org/apache/flink/core/plugin/MetricsAware.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.core.plugin;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.metrics.MetricGroup;
+
+/**
+ * Opt-in contract for {@link Plugin}s that need a runtime-owned {@link
MetricGroup}.
+ *
+ * <p>The runtime calls {@link #setMetricGroup(MetricGroup)} after {@link
+ * Plugin#configure(org.apache.flink.configuration.Configuration)} and before
the plugin emits
+ * metrics.
+ *
+ * <p>{@code setMetricGroup} may be called more than once, for example when an
embedded runtime is
+ * restarted in the same JVM while retaining plugin instances. Re-applying the
same {@link
+ * MetricGroup} must be a no-op, and a different group must scope metrics
created afterwards. The
+ * runtime owns the group; implementations may call {@link
MetricGroup#addGroup} to derive nested
+ * scopes from it.
+ */
+@PublicEvolving
+public interface MetricsAware {
+
+ /**
+ * Hands the plugin a runtime-owned {@link MetricGroup} to register its
metrics against. See the
+ * class-level two-phase init contract.
+ *
+ * @param metricGroup the group to register metrics under; never {@code
null}.
+ */
+ void setMetricGroup(MetricGroup metricGroup);
+}
diff --git
a/flink-core/src/test/java/org/apache/flink/core/fs/FileSystemAttachMetricsTest.java
b/flink-core/src/test/java/org/apache/flink/core/fs/FileSystemAttachMetricsTest.java
new file mode 100644
index 00000000000..fbe91609b8f
--- /dev/null
+++
b/flink-core/src/test/java/org/apache/flink/core/fs/FileSystemAttachMetricsTest.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.core.fs;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.CoreOptions;
+import org.apache.flink.core.plugin.MetricsAware;
+import org.apache.flink.core.plugin.TestingPluginManager;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link FileSystem#attachMetrics(MetricGroup)}. */
+class FileSystemAttachMetricsTest {
+
+ @AfterEach
+ void resetFileSystems() {
+ // Restore the default, plugin-less factory registry so other tests
are unaffected.
+ FileSystem.initialize(new Configuration(), null);
+ }
+
+ @Test
+ void attachMetricsReachesPluginLoadedMetricsAwareFactory() {
+ // Headline case: plugin file systems are registered behind wrappers
such as
+ // PluginFileSystemFactory. attachMetrics must still reach the real
factory, otherwise the
+ // metric group is silently never delivered and no metrics are ever
emitted.
+ TestingFileSystemFactory factory = new
TestingFileSystemFactory("metrics-test-fs");
+ initializeWithPlugins(factory);
+
+ RecordingMetricGroup processGroup = new RecordingMetricGroup();
+ FileSystem.attachMetrics(processGroup);
+
+ // Unwrapped through PluginFileSystemFactory and invoked exactly once.
+ assertThat(factory.setMetricGroupCalls).hasValue(1);
+ // The group handed to the factory is the "filesystem" child of the
process group, not the
+ // process group itself.
+ assertThat(processGroup.childGroupNames).containsExactly("filesystem");
+
assertThat(factory.receivedGroup.get()).isNotNull().isNotSameAs(processGroup);
+ }
+
+ @Test
+ void attachMetricsReachesMetricsAwareFactoryBehindConnectionLimiter() {
+ TestingFileSystemFactory factory = new
TestingFileSystemFactory("limited-test-fs");
+ Configuration config = new Configuration();
+ config.set(CoreOptions.fileSystemConnectionLimit(factory.getScheme()),
1);
+ initializeWithPlugins(config, factory);
+
+ FileSystem.attachMetrics(new UnregisteredMetricsGroup());
+
+ assertThat(factory.setMetricGroupCalls).hasValue(1);
+ }
+
+ @Test
+ void attachMetricsSkipsFactoriesThatAreNotMetricsAware() {
+ // A factory that does not implement MetricsAware is filtered out by
the instanceof check in
+ // attachMetrics, so it simply receives no group. One representative
factory is enough.
+ initializeWithPlugins(new PlainFileSystemFactory("plain-test-fs"));
+
+ assertThatCode(() -> FileSystem.attachMetrics(new
UnregisteredMetricsGroup()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void attachMetricsPropagatesFactoryFailure() {
+ initializeWithPlugins(
+ new TestingFileSystemFactory(
+ "throwing-test-fs",
+ group -> {
+ throw new RuntimeException(
+ "intentional failure from a misbehaving
plugin");
+ }));
+
+ assertThatThrownBy(() -> FileSystem.attachMetrics(new
UnregisteredMetricsGroup()))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("intentional failure from a misbehaving plugin");
+ }
+
+ @Test
+ void attachMetricsForwardsToFactoryOnEveryInvocation() {
+ TestingFileSystemFactory factory = new
TestingFileSystemFactory("idem-test-fs");
+ initializeWithPlugins(factory);
+
+ MetricGroup group = new UnregisteredMetricsGroup();
+ FileSystem.attachMetrics(group);
+ FileSystem.attachMetrics(group);
+
+ // The hook forwards on every call; collapsing duplicate registrations
is the factory's
+ // responsibility, verified end-to-end in
NativeS3FileSystemFactoryMetricsTest
+ // #repeatedAttachmentWithSameGroupDoesNotCreateNewFilesystemTypeGroup.
+ assertThat(factory.setMetricGroupCalls).hasValue(2);
+ }
+
+ @Test
+ void setMetricGroupIsNotInvokedWhenAttachMetricsIsNeverCalled() {
+ TestingFileSystemFactory factory = new
TestingFileSystemFactory("never-test-fs");
+ initializeWithPlugins(factory);
+
+ assertThat(factory.setMetricGroupCalls).hasValue(0);
+ }
+
+ @Test
+ void pluginFileSystemFactoryForwardsMetricGroupToInner() {
+ TestingFileSystemFactory inner = new
TestingFileSystemFactory("wrapped-fs");
+ FileSystemFactory wrapper = PluginFileSystemFactory.of(inner);
+
+ // The wrapper must itself be MetricsAware so attachMetrics reaches it
without unwrapping.
+ assertThat(wrapper).isInstanceOf(MetricsAware.class);
+
+ MetricGroup group = new UnregisteredMetricsGroup();
+ ((MetricsAware) wrapper).setMetricGroup(group);
+
+ assertThat(inner.setMetricGroupCalls).hasValue(1);
+ assertThat(inner.receivedGroup.get()).isSameAs(group);
+ }
+
+ private static void initializeWithPlugins(FileSystemFactory... factories) {
+ initializeWithPlugins(new Configuration(), factories);
+ }
+
+ private static void initializeWithPlugins(
+ Configuration config, FileSystemFactory... factories) {
+ Map<Class<?>, Iterator<?>> plugins = new HashMap<>();
+ plugins.put(FileSystemFactory.class,
Arrays.asList(factories).iterator());
+ FileSystem.initialize(config, new TestingPluginManager(plugins));
+ }
+
+ // ------------------------------------------------------------------------
+ // test factories
+ // ------------------------------------------------------------------------
+
+ private static class PlainFileSystemFactory implements FileSystemFactory {
+ private final String scheme;
+
+ PlainFileSystemFactory(String scheme) {
+ this.scheme = scheme;
+ }
+
+ @Override
+ public void configure(Configuration config) {}
+
+ @Override
+ public String getScheme() {
+ return scheme;
+ }
+
+ @Override
+ public FileSystem create(URI fsUri) throws IOException {
+ throw new UnsupportedOperationException(
+ "This test factory does not create file systems.");
+ }
+ }
+
+ private static class TestingFileSystemFactory extends
PlainFileSystemFactory
+ implements MetricsAware {
+ final AtomicInteger setMetricGroupCalls = new AtomicInteger();
+ final AtomicReference<MetricGroup> receivedGroup = new
AtomicReference<>();
+ private final Consumer<MetricGroup> onSetMetricGroup;
+
+ TestingFileSystemFactory(String scheme) {
+ this(scheme, metricGroup -> {});
+ }
+
+ TestingFileSystemFactory(String scheme, Consumer<MetricGroup>
onSetMetricGroup) {
+ super(scheme);
+ this.onSetMetricGroup = onSetMetricGroup;
+ }
+
+ @Override
+ public void setMetricGroup(MetricGroup metricGroup) {
+ setMetricGroupCalls.incrementAndGet();
+ receivedGroup.set(metricGroup);
+ onSetMetricGroup.accept(metricGroup);
+ }
+ }
+
+ /**
+ * Records the names of child groups created directly under it, sharing
the list with children.
+ */
+ private static class RecordingMetricGroup extends UnregisteredMetricsGroup
{
+ final List<String> childGroupNames;
+
+ RecordingMetricGroup() {
+ this(Collections.synchronizedList(new ArrayList<>()));
+ }
+
+ private RecordingMetricGroup(List<String> childGroupNames) {
+ this.childGroupNames = childGroupNames;
+ }
+
+ @Override
+ public MetricGroup addGroup(String name) {
+ childGroupNames.add(name);
+ return new RecordingMetricGroup(childGroupNames);
+ }
+ }
+}
diff --git a/flink-filesystems/flink-s3-fs-native/README.md
b/flink-filesystems/flink-s3-fs-native/README.md
index d8be409626b..b65583f989d 100644
--- a/flink-filesystems/flink-s3-fs-native/README.md
+++ b/flink-filesystems/flink-s3-fs-native/README.md
@@ -78,6 +78,26 @@ input.sinkTo(FileSink.forRowFormat(new
Path("s3://my-bucket/output"),
| s3.async.enabled | true | Enable async read/write with TransferManager |
| s3.read.buffer.size | 262144 (256KB) | Read buffer size per stream (64KB -
4MB) |
+### Metrics
+
+When the native S3 plugin is loaded in a JobManager or TaskManager, it can
publish AWS SDK operation metrics into Flink's process-level metric group.
Metrics are scoped under `filesystem.filesystem_type.<scheme>`, where
`<scheme>` is `s3` or `s3a`.
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| s3.metrics.enabled | true | Enable S3 operation metrics. Set to `false` to
avoid attaching the AWS SDK metric publisher |
+| s3.metrics.allowlist | `api_call_count`, `api_call_duration_ms`,
`throttle_count`, `retry_count`, `iops` | Metrics to register. Use `*` to
register every metric emitted by the plugin. Empty lists are rejected; set
`s3.metrics.enabled: false` to disable metrics. `iops` is derived by reporters
from `api_call_count`, so allowing `iops` also registers `api_call_count` |
+| s3.metrics.histogram.window-size | 1024 | Number of recent samples retained
per `api_call_duration_ms` histogram |
+
+The plugin emits the following metric names:
+
+| Metric | Type | Labels | Description |
+|--------|------|--------|-------------|
+| api_call_count | Counter | `op`, `status_class` | Number of completed S3 API
calls, grouped by operation and result class |
+| api_call_duration_ms | Histogram | `op` | Completed S3 API call latency in
milliseconds |
+| throttle_count | Counter | `op` | Number of throttled S3 responses (`429` or
`503`) |
+| retry_count | Counter | `op`, `reason` | Number of AWS SDK retries, grouped
by retry reason |
+| iops | Derived rate | `op`, `status_class` | Reporter-side rate derived from
`api_call_count` |
+
### Credentials Provider
| Key | Default | Description |
diff --git
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
index 23d6c22314c..ff52b2dfb0a 100644
---
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
+++
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
@@ -27,6 +27,11 @@ import
org.apache.flink.configuration.IllegalConfigurationException;
import org.apache.flink.configuration.MemorySize;
import org.apache.flink.core.fs.FileSystem;
import org.apache.flink.core.fs.FileSystemFactory;
+import org.apache.flink.core.plugin.MetricsAware;
+import org.apache.flink.fs.s3native.metrics.AwsSdkMetricBridge;
+import org.apache.flink.fs.s3native.metrics.S3MetricRecorder;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.SlidingWindowHistogram;
import org.apache.flink.util.Preconditions;
import org.apache.flink.util.StringUtils;
@@ -34,11 +39,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
/**
@@ -55,10 +62,11 @@ import java.util.Map;
* @see org.apache.flink.core.fs.FileSystemFactory
*/
@Experimental
-public class NativeS3FileSystemFactory implements FileSystemFactory {
+public class NativeS3FileSystemFactory implements FileSystemFactory,
MetricsAware {
private static final Logger LOG =
LoggerFactory.getLogger(NativeS3FileSystemFactory.class);
+ private static final String CONFIGURATION_PREFIX = "s3";
private static final String INVALID_ENTROPY_KEY_CHARS =
"^.*[~#@*+%{}<>\\[\\]|\"\\\\].*$";
public static final long S3_MULTIPART_MIN_PART_SIZE = 5L << 20;
@@ -410,9 +418,47 @@ public class NativeS3FileSystemFactory implements
FileSystemFactory {
+ "size here causes 'failed to acquire a
connection' timeouts "
+ "under parallel checkpoint
upload/restore. Defaults to 256.");
+ public static final ConfigOption<Boolean> METRICS_ENABLED =
+ ConfigOptions.key(CONFIGURATION_PREFIX + ".metrics.enabled")
+ .booleanType()
+ .defaultValue(true)
+ .withDescription(
+ "Master switch for publishing S3 operation metrics
to Flink's metric "
+ + "system.");
+
+ public static final ConfigOption<List<String>> METRICS_ALLOWLIST =
+ ConfigOptions.key(CONFIGURATION_PREFIX + ".metrics.allowlist")
+ .stringType()
+ .asList()
+
.defaultValues(S3MetricRecorder.DEFAULT_ALLOWLIST.toArray(new String[0]))
+ .withDescription(
+ "Names of S3 metrics to register. Replaces the
default list; use \"*\" "
+ + "to register every metric emitted by the
plugin. An empty list "
+ + "is invalid. The iops metric is derived
from api_call_count.");
+
+ public static final ConfigOption<Integer> METRICS_HISTOGRAM_WINDOW_SIZE =
+ ConfigOptions.key(CONFIGURATION_PREFIX +
".metrics.histogram.window-size")
+ .intType()
+ .defaultValue(SlidingWindowHistogram.DEFAULT_WINDOW_SIZE)
+ .withDescription(
+ "Number of recent values retained by S3 latency
histograms. Must be "
+ + "positive.");
+
@Nullable private Configuration flinkConfig;
@Nullable private BucketConfigProvider bucketConfigProvider;
+ @GuardedBy("this")
+ @Nullable
+ private MetricGroup pluginMetrics;
+
+ @GuardedBy("this")
+ @Nullable
+ private MetricGroup attachedMetricGroup;
+
+ @GuardedBy("this")
+ @Nullable
+ private AwsSdkMetricBridge metricBridge;
+
@Override
public String getScheme() {
return "s3";
@@ -430,6 +476,40 @@ public class NativeS3FileSystemFactory implements
FileSystemFactory {
this.bucketConfigProvider = new BucketConfigProvider(config);
}
+ @Override
+ public synchronized void setMetricGroup(MetricGroup metricGroup) {
+ if (metricGroup == attachedMetricGroup) {
+ return;
+ }
+ this.attachedMetricGroup = metricGroup;
+ this.pluginMetrics = metricGroup.addGroup("filesystem_type",
getScheme());
+ if (metricBridge != null) {
+ metricBridge.setMetricGroup(pluginMetrics);
+ }
+ }
+
+ /**
+ * Returns the stable SDK metric publisher shared by all clients, or
{@code null} when metrics
+ * are disabled. The publisher must be installed even before a metric
group is available because
+ * runtime startup may cache a file system before attaching metrics.
+ */
+ @Nullable
+ private synchronized AwsSdkMetricBridge resolveMetricBridge(Configuration
config) {
+ if (!config.get(METRICS_ENABLED)) {
+ return null;
+ }
+ if (metricBridge == null) {
+ metricBridge =
+ new AwsSdkMetricBridge(
+ config.get(METRICS_ALLOWLIST),
+ config.get(METRICS_HISTOGRAM_WINDOW_SIZE));
+ if (pluginMetrics != null) {
+ metricBridge.setMetricGroup(pluginMetrics);
+ }
+ }
+ return metricBridge;
+ }
+
@Override
public FileSystem create(URI fsUri) throws IOException {
Configuration config = this.flinkConfig;
@@ -632,6 +712,7 @@ public class NativeS3FileSystemFactory implements
FileSystemFactory {
.retryCircuitBreakerEnabled(config.get(RETRY_CIRCUIT_BREAKER_ENABLED))
.credentialsProviderClasses(credentialsProviderClasses)
.encryptionConfig(encryptionConfig)
+ .metricPublisher(resolveMetricBridge(config))
.useCrt(crtEnabled);
if (crtEnabled) {
diff --git
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
index aa31db64696..634409da4d3 100644
---
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
+++
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3ClientProvider.java
@@ -36,6 +36,7 @@ import
software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.http.crt.AwsCrtHttpClient;
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
+import software.amazon.awssdk.metrics.MetricPublisher;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain;
import software.amazon.awssdk.retries.StandardRetryStrategy;
@@ -464,6 +465,10 @@ class S3ClientProvider implements AutoCloseableAsync {
private long crtMinPartSizeInBytes =
NativeS3FileSystemFactory.PART_UPLOAD_MIN_SIZE.defaultValue();
+ // Optional AWS SDK metric publisher (e.g. the Flink metric bridge).
Attached to both the
+ // sync and async clients via the shared ClientOverrideConfiguration.
Null = no metrics.
+ @Nullable private MetricPublisher metricPublisher;
+
public Builder accessKey(@Nullable String accessKey) {
this.accessKey = accessKey;
return this;
@@ -633,6 +638,11 @@ class S3ClientProvider implements AutoCloseableAsync {
return this;
}
+ public Builder metricPublisher(@Nullable MetricPublisher
metricPublisher) {
+ this.metricPublisher = metricPublisher;
+ return this;
+ }
+
S3ClientProvider build() {
if (endpoint == null) {
endpoint = System.getProperty("s3.endpoint");
@@ -674,7 +684,7 @@ class S3ClientProvider implements AutoCloseableAsync {
retryMaxBackoff,
retryThrottleBaseDelay);
- ClientOverrideConfiguration overrideConfig =
+ ClientOverrideConfiguration.Builder overrideConfigBuilder =
ClientOverrideConfiguration.builder()
.retryStrategy(
StandardRetryStrategy.builder()
@@ -687,8 +697,7 @@ class S3ClientProvider implements AutoCloseableAsync {
retryThrottleBaseDelay,
retryMaxBackoff))
.circuitBreakerEnabled(retryCircuitBreakerEnabled)
- .build())
- .build();
+ .build());
if (useCrt) {
LOG.info(
@@ -698,6 +707,11 @@ class S3ClientProvider implements AutoCloseableAsync {
: "(CRT runtime default)");
}
+ if (metricPublisher != null) {
+ overrideConfigBuilder.addMetricPublisher(metricPublisher);
+ }
+ ClientOverrideConfiguration overrideConfig =
overrideConfigBuilder.build();
+
S3Client s3Client =
buildSyncClient(
credentialsProvider, awsRegion, s3Config,
overrideConfig, endpointUri);
diff --git
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridge.java
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridge.java
new file mode 100644
index 00000000000..0f885944b17
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridge.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.SlidingWindowHistogram;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.core.metrics.CoreMetric;
+import software.amazon.awssdk.http.HttpMetric;
+import software.amazon.awssdk.metrics.MetricCollection;
+import software.amazon.awssdk.metrics.MetricPublisher;
+import software.amazon.awssdk.metrics.SdkMetric;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Maps AWS SDK metric records to native S3 filesystem metrics. */
+@Internal
+public final class AwsSdkMetricBridge implements MetricPublisher {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(AwsSdkMetricBridge.class);
+ private static final String UNKNOWN_OPERATION = "Unknown";
+
+ @Nullable private final Collection<String> configuredAllowlist;
+ private final int histogramWindowSize;
+ private final AtomicReference<S3MetricRecorder> metrics = new
AtomicReference<>();
+
+ public AwsSdkMetricBridge(MetricGroup metricGroup) {
+ this(
+ metricGroup,
+ S3MetricRecorder.DEFAULT_ALLOWLIST,
+ SlidingWindowHistogram.DEFAULT_WINDOW_SIZE);
+ }
+
+ public AwsSdkMetricBridge(
+ MetricGroup metricGroup,
+ @Nullable Collection<String> allowlist,
+ int histogramWindowSize) {
+ this(allowlist, histogramWindowSize);
+ setMetricGroup(metricGroup);
+ }
+
+ public AwsSdkMetricBridge(@Nullable Collection<String> allowlist, int
histogramWindowSize) {
+ this.configuredAllowlist = allowlist == null ? null : new
ArrayList<>(allowlist);
+ this.histogramWindowSize = histogramWindowSize;
+ }
+
+ /** Starts publishing subsequent SDK metrics to the given Flink metric
group. */
+ public void setMetricGroup(MetricGroup metricGroup) {
+ metrics.set(new S3MetricRecorder(metricGroup, configuredAllowlist,
histogramWindowSize));
+ }
+
+ @Override
+ public void publish(MetricCollection apiCall) {
+ final S3MetricRecorder currentMetrics = metrics.get();
+ if (currentMetrics == null) {
+ return;
+ }
+ try {
+ translate(apiCall, currentMetrics);
+ } catch (Exception e) {
+ LOG.warn("Failed to publish S3 SDK metrics", e);
+ }
+ }
+
+ private void translate(MetricCollection apiCall, S3MetricRecorder
currentMetrics) {
+ final String operation = first(apiCall, CoreMetric.OPERATION_NAME,
UNKNOWN_OPERATION);
+ final Duration duration = first(apiCall, CoreMetric.API_CALL_DURATION,
null);
+ if (duration != null) {
+ currentMetrics.recordDuration(operation, duration.toMillis());
+ }
+
+ int throttleResponses = 0;
+ boolean sawServerError = false;
+ Integer lastStatus = null;
+ for (MetricCollection attempt : apiCall.children()) {
+ for (Integer status :
attempt.metricValues(HttpMetric.HTTP_STATUS_CODE)) {
+ if (status != null) {
+ lastStatus = status;
+ if (isThrottle(status)) {
+ throttleResponses++;
+ } else if (status >= 500) {
+ sawServerError = true;
+ }
+ }
+ }
+ }
+
+ currentMetrics.recordApiCall(
+ operation,
+ statusClass(lastStatus, first(apiCall,
CoreMetric.API_CALL_SUCCESSFUL, null)));
+ currentMetrics.recordThrottles(operation, throttleResponses);
+
+ final Integer retries = first(apiCall, CoreMetric.RETRY_COUNT, 0);
+ if (retries != null) {
+ currentMetrics.recordRetries(
+ operation, retries, retryReason(throttleResponses > 0,
sawServerError));
+ }
+ }
+
+ private static boolean isThrottle(int status) {
+ return status == 429 || status == 503;
+ }
+
+ private static String statusClass(Integer status, Boolean successful) {
+ if (status == null) {
+ if (Boolean.TRUE.equals(successful)) {
+ return "2xx";
+ }
+ return Boolean.FALSE.equals(successful) ? "error" : "unknown";
+ }
+ if (isThrottle(status)) {
+ return "throttled";
+ }
+ if (status >= 200 && status < 300) {
+ return "2xx";
+ }
+ if (status >= 400 && status < 500) {
+ return "4xx";
+ }
+ if (status >= 500) {
+ return "5xx";
+ }
+ return "other";
+ }
+
+ private static String retryReason(boolean sawThrottle, boolean
sawServerError) {
+ if (sawThrottle) {
+ return "throttled";
+ }
+ return sawServerError ? "5xx" : "other";
+ }
+
+ private static <T> T first(MetricCollection collection, SdkMetric<T>
metric, T defaultValue) {
+ final List<T> values = collection.metricValues(metric);
+ return values.isEmpty() ? defaultValue : values.get(0);
+ }
+
+ @Override
+ public void close() {}
+}
diff --git
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorder.java
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorder.java
new file mode 100644
index 00000000000..852e66ab115
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorder.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.Histogram;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.SlidingWindowHistogram;
+import org.apache.flink.metrics.ThreadSafeSimpleCounter;
+import org.apache.flink.util.Preconditions;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Registers native S3 operation metrics supplied by the AWS SDK adapter. */
+@Internal
+public final class S3MetricRecorder {
+
+ static final String API_CALL_COUNT = "api_call_count";
+ static final String API_CALL_DURATION_MS = "api_call_duration_ms";
+ static final String THROTTLE_COUNT = "throttle_count";
+ static final String RETRY_COUNT = "retry_count";
+ static final String IOPS = "iops";
+
+ public static final List<String> DEFAULT_ALLOWLIST =
+ Collections.unmodifiableList(
+ Arrays.asList(
+ API_CALL_COUNT,
+ API_CALL_DURATION_MS,
+ THROTTLE_COUNT,
+ RETRY_COUNT,
+ IOPS));
+
+ private static final String WILDCARD = "*";
+ private static final String LABEL_OPERATION = "op";
+ private static final String LABEL_STATUS_CLASS = "status_class";
+ private static final String LABEL_REASON = "reason";
+
+ private final MetricGroup metricGroup;
+ private final int histogramWindowSize;
+ private final boolean allowAll;
+ private final Set<String> allowlist;
+ private final Map<String, Counter> counters = new ConcurrentHashMap<>();
+ private final Map<String, Histogram> histograms = new
ConcurrentHashMap<>();
+
+ S3MetricRecorder(
+ MetricGroup metricGroup,
+ @Nullable Collection<String> configuredAllowlist,
+ int histogramWindowSize) {
+ this.metricGroup = Preconditions.checkNotNull(metricGroup);
+ Preconditions.checkArgument(
+ histogramWindowSize > 0, "histogramWindowSize must be
positive");
+ this.histogramWindowSize = histogramWindowSize;
+
+ final Set<String> normalizedAllowlist =
normalizeAllowlist(configuredAllowlist);
+ Preconditions.checkArgument(
+ !normalizedAllowlist.isEmpty(),
+ "S3 metrics allowlist must not be empty. Disable metrics with "
+ + "s3.metrics.enabled=false instead.");
+ if (normalizedAllowlist.contains(WILDCARD)) {
+ this.allowAll = true;
+ this.allowlist = Collections.emptySet();
+ } else {
+ if (normalizedAllowlist.contains(IOPS)) {
+ normalizedAllowlist.add(API_CALL_COUNT);
+ }
+ this.allowAll = false;
+ this.allowlist = normalizedAllowlist;
+ }
+ }
+
+ void recordApiCall(String operation, String statusClass) {
+ if (!isMetricEnabled(API_CALL_COUNT)) {
+ return;
+ }
+ final String normalizedOperation =
Preconditions.checkNotNull(operation);
+ final String normalizedStatusClass =
Preconditions.checkNotNull(statusClass);
+ counters.computeIfAbsent(
+ "api-call|" + normalizedOperation + '|' +
normalizedStatusClass,
+ ignored ->
+ metricGroup
+ .addGroup(LABEL_OPERATION,
normalizedOperation)
+ .addGroup(LABEL_STATUS_CLASS,
normalizedStatusClass)
+ .counter(API_CALL_COUNT, new
ThreadSafeSimpleCounter()))
+ .inc();
+ }
+
+ void recordDuration(String operation, long durationMillis) {
+ if (!isMetricEnabled(API_CALL_DURATION_MS)) {
+ return;
+ }
+ final String normalizedOperation =
Preconditions.checkNotNull(operation);
+ histograms
+ .computeIfAbsent(
+ normalizedOperation,
+ ignored ->
+ metricGroup
+ .addGroup(LABEL_OPERATION,
normalizedOperation)
+ .histogram(
+ API_CALL_DURATION_MS,
+ new
SlidingWindowHistogram(histogramWindowSize)))
+ .update(durationMillis);
+ }
+
+ void recordThrottles(String operation, long count) {
+ if (count <= 0 || !isMetricEnabled(THROTTLE_COUNT)) {
+ return;
+ }
+ final String normalizedOperation =
Preconditions.checkNotNull(operation);
+ counters.computeIfAbsent(
+ "throttle|" + normalizedOperation,
+ ignored ->
+ metricGroup
+ .addGroup(LABEL_OPERATION,
normalizedOperation)
+ .counter(THROTTLE_COUNT, new
ThreadSafeSimpleCounter()))
+ .inc(count);
+ }
+
+ void recordRetries(String operation, long count, String reason) {
+ if (count <= 0 || !isMetricEnabled(RETRY_COUNT)) {
+ return;
+ }
+ final String normalizedOperation =
Preconditions.checkNotNull(operation);
+ final String normalizedReason = Preconditions.checkNotNull(reason);
+ counters.computeIfAbsent(
+ "retry|" + normalizedOperation + '|' +
normalizedReason,
+ ignored ->
+ metricGroup
+ .addGroup(LABEL_OPERATION,
normalizedOperation)
+ .addGroup(LABEL_REASON,
normalizedReason)
+ .counter(RETRY_COUNT, new
ThreadSafeSimpleCounter()))
+ .inc(count);
+ }
+
+ boolean isMetricEnabled(String metricName) {
+ return allowAll || allowlist.contains(metricName);
+ }
+
+ private static Set<String> normalizeAllowlist(
+ @Nullable Collection<String> configuredAllowlist) {
+ final Set<String> normalized = new HashSet<>();
+ if (configuredAllowlist == null) {
+ return normalized;
+ }
+ for (String metric : configuredAllowlist) {
+ if (metric != null && !metric.trim().isEmpty()) {
+ normalized.add(metric.trim());
+ }
+ }
+ return normalized;
+ }
+}
diff --git
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
index 902bc9ae9b6..5ed2edd1614 100644
---
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
+++
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
@@ -21,10 +21,12 @@ package org.apache.flink.fs.s3native;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.IllegalConfigurationException;
import org.apache.flink.configuration.MemorySize;
+import org.apache.flink.fs.s3native.metrics.AwsSdkMetricBridge;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.metrics.MetricPublisher;
import java.io.IOException;
import java.net.URI;
@@ -96,6 +98,48 @@ class NativeS3FileSystemFactoryTest {
assertThat(fs.getUri()).isEqualTo(URI.create("s3://test-bucket/"));
}
+ @Test
+ void testMetricsPublisherIsInstalledBeforeMetricGroupAttachment() throws
Exception {
+ NativeS3FileSystem fs = createFs(baseConfig());
+ List<MetricPublisher> syncPublishers =
+ fs.getClientProvider()
+ .getS3Client()
+ .serviceClientConfiguration()
+ .overrideConfiguration()
+ .metricPublishers();
+ List<MetricPublisher> asyncPublishers =
+ fs.getClientProvider()
+ .getAsyncClient()
+ .serviceClientConfiguration()
+ .overrideConfiguration()
+ .metricPublishers();
+
+
assertThat(syncPublishers).singleElement().isInstanceOf(AwsSdkMetricBridge.class);
+ assertThat(asyncPublishers).containsExactly(syncPublishers.get(0));
+ }
+
+ @Test
+ void testMetricsPublisherIsNotInstalledWhenMetricsAreDisabled() throws
Exception {
+ Configuration config = baseConfig();
+ config.set(NativeS3FileSystemFactory.METRICS_ENABLED, false);
+ NativeS3FileSystem fs = createFs(config);
+
+ assertThat(
+ fs.getClientProvider()
+ .getS3Client()
+ .serviceClientConfiguration()
+ .overrideConfiguration()
+ .metricPublishers())
+ .isEmpty();
+ assertThat(
+ fs.getClientProvider()
+ .getAsyncClient()
+ .serviceClientConfiguration()
+ .overrideConfiguration()
+ .metricPublishers())
+ .isEmpty();
+ }
+
@Test
void testCreateFileSystemWithCustomEndpoint() throws Exception {
// Global: endpoint A; bucket: endpoint B → bucket endpoint is used
diff --git
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridgeTest.java
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridgeTest.java
new file mode 100644
index 00000000000..b001eb4e95d
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/AwsSdkMetricBridgeTest.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.Histogram;
+import org.apache.flink.metrics.SlidingWindowHistogram;
+import org.apache.flink.metrics.testutils.MetricListener;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import software.amazon.awssdk.core.metrics.CoreMetric;
+import software.amazon.awssdk.http.HttpMetric;
+import software.amazon.awssdk.metrics.MetricCollection;
+import software.amazon.awssdk.metrics.MetricCollector;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AwsSdkMetricBridgeTest {
+
+ @Test
+ void mapsSuccessfulCallAndDuration() {
+ final MetricListener listener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(listener.getMetricGroup());
+
+ bridge.publish(apiCall("PutObject", Duration.ofMillis(120), true, 0,
200));
+
+ assertThat(count(listener, "op", "PutObject", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(1L);
+ final Histogram histogram = histogram(listener, "op", "PutObject",
"api_call_duration_ms");
+ assertThat(histogram.getCount()).isEqualTo(1L);
+ assertThat(histogram.getStatistics().getMax()).isEqualTo(120L);
+ }
+
+ @Test
+ void mapsAwsThrottlingAndRetries() {
+ final MetricListener listener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(listener.getMetricGroup());
+
+ bridge.publish(apiCall("UploadPart", Duration.ofMillis(900), true, 2,
503, 503, 200));
+
+ assertThat(count(listener, "op", "UploadPart",
"throttle_count")).isEqualTo(2L);
+ assertThat(count(listener, "op", "UploadPart", "reason", "throttled",
"retry_count"))
+ .isEqualTo(2L);
+ assertThat(count(listener, "op", "UploadPart", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ @ParameterizedTest(name = "HTTP {0} (successful={1}) -> status_class {2}")
+ @CsvSource({
+ "200, true, 2xx",
+ "404, false, 4xx",
+ "500, false, 5xx",
+ "503, false, throttled",
+ "429, false, throttled",
+ "302, false, other"
+ })
+ void mapsAwsHttpStatus(int status, boolean successful, String
expectedClass) {
+ final MetricListener listener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(listener.getMetricGroup());
+
+ bridge.publish(apiCall("GetObject", Duration.ofMillis(15), successful,
0, status));
+
+ assertThat(
+ count(
+ listener,
+ "op",
+ "GetObject",
+ "status_class",
+ expectedClass,
+ "api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ void mapsAwsServerErrorRetry() {
+ final MetricListener listener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(listener.getMetricGroup());
+
+ bridge.publish(apiCall("GetObject", Duration.ofMillis(50), true, 1,
500, 200));
+
+ assertThat(count(listener, "op", "GetObject", "reason", "5xx",
"retry_count"))
+ .isEqualTo(1L);
+ assertThat(listener.getCounter("op", "GetObject",
"throttle_count")).isEmpty();
+ }
+
+ @Test
+ void mapsMissingSdkFieldsToUnknown() {
+ final MetricListener listener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(listener.getMetricGroup());
+
+ bridge.publish(MetricCollector.create("ApiCall").collect());
+
+ assertThat(count(listener, "op", "Unknown", "status_class", "unknown",
"api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ void startsPublishingAfterMetricGroupIsAttached() {
+ final AwsSdkMetricBridge bridge =
+ new AwsSdkMetricBridge(
+ S3MetricRecorder.DEFAULT_ALLOWLIST,
+ SlidingWindowHistogram.DEFAULT_WINDOW_SIZE);
+ final MetricCollection call = apiCall("PutObject",
Duration.ofMillis(120), true, 0, 200);
+
+ bridge.publish(call);
+
+ final MetricListener listener = new MetricListener();
+ bridge.setMetricGroup(listener.getMetricGroup());
+ bridge.publish(call);
+
+ assertThat(count(listener, "op", "PutObject", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ void reattachingMetricGroupRedirectsSubsequentMetrics() {
+ final MetricListener firstListener = new MetricListener();
+ final MetricListener secondListener = new MetricListener();
+ final AwsSdkMetricBridge bridge = new
AwsSdkMetricBridge(firstListener.getMetricGroup());
+ final MetricCollection call = apiCall("PutObject",
Duration.ofMillis(120), true, 0, 200);
+
+ bridge.publish(call);
+ bridge.setMetricGroup(secondListener.getMetricGroup());
+ bridge.publish(call);
+
+ assertThat(count(firstListener, "op", "PutObject", "status_class",
"2xx", "api_call_count"))
+ .isEqualTo(1L);
+ assertThat(
+ count(
+ secondListener,
+ "op",
+ "PutObject",
+ "status_class",
+ "2xx",
+ "api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ private static MetricCollection apiCall(
+ String operation,
+ Duration duration,
+ boolean successful,
+ int retries,
+ int... attemptStatuses) {
+ final MetricCollector apiCall = MetricCollector.create("ApiCall");
+ apiCall.reportMetric(CoreMetric.OPERATION_NAME, operation);
+ apiCall.reportMetric(CoreMetric.API_CALL_DURATION, duration);
+ apiCall.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, successful);
+ apiCall.reportMetric(CoreMetric.RETRY_COUNT, retries);
+ for (int status : attemptStatuses) {
+ final MetricCollector attempt =
apiCall.createChild("ApiCallAttempt");
+ attempt.reportMetric(HttpMetric.HTTP_STATUS_CODE, status);
+ }
+ return apiCall.collect();
+ }
+
+ private static long count(MetricListener listener, String... identifier) {
+ return listener.getCounter(identifier)
+ .map(Counter::getCount)
+ .orElseThrow(() -> new AssertionError("Missing counter"));
+ }
+
+ private static Histogram histogram(MetricListener listener, String...
identifier) {
+ return listener.getHistogram(identifier)
+ .orElseThrow(() -> new AssertionError("Missing histogram"));
+ }
+}
diff --git
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3FileSystemFactoryMetricsTest.java
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3FileSystemFactoryMetricsTest.java
new file mode 100644
index 00000000000..483307bc8ee
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3FileSystemFactoryMetricsTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.fs.s3native.NativeS3AFileSystemFactory;
+import org.apache.flink.fs.s3native.NativeS3FileSystemFactory;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.SlidingWindowHistogram;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests that the native S3 factories register metrics under a {@code
filesystem_type} label whose
+ * value is the factory scheme, so {@code s3://} and {@code s3a://} traffic
remain distinguishable.
+ */
+class NativeS3FileSystemFactoryMetricsTest {
+
+ @Test
+ void definesS3SpecificMetricOptions() {
+
assertThat(NativeS3FileSystemFactory.METRICS_ENABLED.key()).isEqualTo("s3.metrics.enabled");
+
assertThat(NativeS3FileSystemFactory.METRICS_ENABLED.defaultValue()).isTrue();
+ assertThat(NativeS3FileSystemFactory.METRICS_ALLOWLIST.key())
+ .isEqualTo("s3.metrics.allowlist");
+ assertThat(NativeS3FileSystemFactory.METRICS_ALLOWLIST.defaultValue())
+ .containsExactlyElementsOf(S3MetricRecorder.DEFAULT_ALLOWLIST);
+
assertThat(NativeS3FileSystemFactory.METRICS_HISTOGRAM_WINDOW_SIZE.key())
+ .isEqualTo("s3.metrics.histogram.window-size");
+
assertThat(NativeS3FileSystemFactory.METRICS_HISTOGRAM_WINDOW_SIZE.defaultValue())
+ .isEqualTo(SlidingWindowHistogram.DEFAULT_WINDOW_SIZE);
+ }
+
+ @ParameterizedTest(name = "filesystem_type={1}")
+ @MethodSource("factories")
+ void factoryUsesSchemeAsFilesystemTypeLabel(
+ NativeS3FileSystemFactory factory, String expectedScheme) {
+ RecordingGroup group = new RecordingGroup();
+ factory.setMetricGroup(group);
+ assertThat(group.keyedGroups).containsEntry("filesystem_type",
expectedScheme);
+ }
+
+ private static Stream<Arguments> factories() {
+ return Stream.of(
+ Arguments.of(new NativeS3FileSystemFactory(), "s3"),
+ Arguments.of(new NativeS3AFileSystemFactory(), "s3a"));
+ }
+
+ @Test
+ void repeatedAttachmentWithSameGroupDoesNotCreateNewFilesystemTypeGroup() {
+ RecordingGroup group = new RecordingGroup();
+ NativeS3FileSystemFactory factory = new NativeS3FileSystemFactory();
+
+ factory.setMetricGroup(group);
+ factory.setMetricGroup(group);
+
+ assertThat(group.addGroupCalls).isEqualTo(1);
+ }
+
+ private static final class RecordingGroup extends UnregisteredMetricsGroup
{
+ final Map<String, String> keyedGroups = new HashMap<>();
+ int addGroupCalls;
+
+ @Override
+ public MetricGroup addGroup(String key, String value) {
+ addGroupCalls++;
+ keyedGroups.put(key, value);
+ return this;
+ }
+ }
+}
diff --git
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3MetricsEmissionITCase.java
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3MetricsEmissionITCase.java
new file mode 100644
index 00000000000..9a97ad293b2
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/NativeS3MetricsEmissionITCase.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.core.fs.FileStatus;
+import org.apache.flink.core.fs.FileSystem;
+import org.apache.flink.core.fs.Path;
+import org.apache.flink.core.testutils.CommonTestUtils;
+import org.apache.flink.fs.s3native.NativeS3AFileSystemFactory;
+import org.apache.flink.fs.s3native.NativeS3FileSystemFactory;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.Histogram;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.testutils.MetricListener;
+import org.apache.flink.util.AutoCloseableAsync;
+import org.apache.flink.util.DockerImageVersions;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
+
+import java.io.FileNotFoundException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end test proving that real S3 operations performed through {@code
NativeS3FileSystem} are
+ * translated into Flink metrics by {@link AwsSdkMetricBridge} and become
visible in a real Flink
+ * metric registry.
+ *
+ * <p>Unlike {@link AwsSdkMetricBridgeTest} (which drives the bridge with
synthesized SDK records
+ * and a fake {@code MetricGroup}), this test exercises the full chain: the
AWS SDK actually invokes
+ * the registered {@link software.amazon.awssdk.metrics.MetricPublisher} after
each completed API
+ * call, the bridge registers and updates {@link Counter}/{@link Histogram}
handles, and the
+ * assertions read those handles back through {@link MetricListener}'s real
{@code MetricRegistry}.
+ *
+ * <p>Assertions use only GET/HEAD/LIST round trips, which carry no request
body and are therefore
+ * unaffected by the request-checksum behaviour newer AWS SDK versions apply
to {@code PutObject}.
+ *
+ * <p>Requires Docker; auto-skipped when Docker is unavailable.
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class NativeS3MetricsEmissionITCase {
+
+ private static final int SEAWEEDFS_PORT = 8333;
+ private static final String ACCESS_KEY = "metricsAccessKey";
+ private static final String SECRET_KEY = "metricsSecretKey";
+ private static final String BUCKET = "flip576-metrics";
+
+ @Container
+ private static final GenericContainer<?> SEAWEEDFS =
+ new GenericContainer<>(DockerImageVersions.SEAWEEDFS)
+ .withEnv("AWS_ACCESS_KEY_ID", ACCESS_KEY)
+ .withEnv("AWS_SECRET_ACCESS_KEY", SECRET_KEY)
+ .withCommand("server", "-s3", "-s3.port=" +
SEAWEEDFS_PORT, "-dir=/data")
+ .withExposedPorts(SEAWEEDFS_PORT)
+ .waitingFor(
+ Wait.forHttp("/healthz")
+ .forPort(SEAWEEDFS_PORT)
+ .withStartupTimeout(Duration.ofMinutes(2)))
+ .withStartupAttempts(3);
+
+ private static String endpoint() {
+ return String.format(
+ "http://%s:%d", SEAWEEDFS.getHost(),
SEAWEEDFS.getMappedPort(SEAWEEDFS_PORT));
+ }
+
+ @BeforeAll
+ static void createBucket() {
+ try (S3Client client =
+ S3Client.builder()
+ .endpointOverride(URI.create(endpoint()))
+ .region(Region.US_EAST_1)
+ .credentialsProvider(
+ StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(ACCESS_KEY,
SECRET_KEY)))
+ .serviceConfiguration(
+
S3Configuration.builder().pathStyleAccessEnabled(true).build())
+ .build()) {
+
client.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
+ }
+ }
+
+ @ParameterizedTest(name = "filesystem_type={1}")
+ @MethodSource("factories")
+ void realS3OperationsEmitFlinkMetrics(NativeS3FileSystemFactory factory,
String expectedScheme)
+ throws Exception {
+ Configuration config = new Configuration();
+ config.set(NativeS3FileSystemFactory.ENDPOINT, endpoint());
+ config.set(NativeS3FileSystemFactory.ACCESS_KEY, ACCESS_KEY);
+ config.set(NativeS3FileSystemFactory.SECRET_KEY, SECRET_KEY);
+ config.set(NativeS3FileSystemFactory.REGION, "us-east-1");
+ config.set(NativeS3FileSystemFactory.PATH_STYLE_ACCESS, true);
+ config.set(NativeS3FileSystemFactory.CHUNKED_ENCODING_ENABLED, false);
+ config.set(NativeS3FileSystemFactory.CHECKSUM_VALIDATION_ENABLED,
false);
+ config.set(NativeS3FileSystemFactory.METRICS_ENABLED, true);
+
+ factory.configure(config);
+
+ final Path bucketPath = new Path(expectedScheme + "://" + BUCKET +
"/");
+ FileSystem fs = factory.create(bucketPath.toUri());
+ try {
+ MetricListener metricListener = new MetricListener();
+ // Mirror what FileSystem#attachMetrics hands to the factory: the
"filesystem" child of
+ // the process-level group.
+ MetricGroup fsGroup =
metricListener.getMetricGroup().addGroup("filesystem");
+ factory.setMetricGroup(fsGroup);
+
+ // (1) A successful listing -> ListObjectsV2 (2xx). No request
body, no checksum.
+ FileStatus[] listing = fs.listStatus(bucketPath);
+ assertThat(listing).isNotNull();
+
+ // (2) A lookup of a key that does not exist -> HeadObject
classified as an error
+ // (4xx).
+ try {
+ fs.getFileStatus(new Path(bucketPath, "does-not-exist-" +
System.nanoTime()));
+ } catch (FileNotFoundException expected) {
+ // expected: the object is absent
+ }
+
+ // The SDK publishes metrics after each completed call; the sync
client typically
+ // publishes inline, but poll to remain robust against any
asynchronous delivery.
+ CommonTestUtils.waitUtil(
+ () -> listObjectsSuccessCount(metricListener,
expectedScheme) > 0L,
+ Duration.ofSeconds(30),
+ "Expected a ListObjectsV2 api_call_count metric to be
emitted by real S3 traffic");
+
+ // --- api_call_count (Counter) for the successful listing ---
+ long listCalls = listObjectsSuccessCount(metricListener,
expectedScheme);
+ assertThat(listCalls).as("ListObjectsV2 (2xx)
api_call_count").isGreaterThan(0L);
+
+ // --- api_call_duration_ms (Histogram) for the listing ---
+ Optional<Histogram> listDuration =
+ metricListener.getHistogram(
+ "filesystem",
+ "filesystem_type",
+ expectedScheme,
+ "op",
+ "ListObjectsV2",
+ "api_call_duration_ms");
+ assertThat(listDuration).as("ListObjectsV2 duration
histogram").isPresent();
+ assertThat(listDuration.get().getCount()).isGreaterThan(0L);
+
+ // --- the failed lookup is recorded and classified as a client
error (4xx) ---
+ long headErrorCalls =
+ counter(
+ metricListener,
+ "filesystem",
+ "filesystem_type",
+ expectedScheme,
+ "op",
+ "HeadObject",
+ "status_class",
+ "4xx",
+ "api_call_count");
+ assertThat(headErrorCalls)
+ .as("HeadObject (4xx) api_call_count for the missing key")
+ .isGreaterThan(0L);
+ } finally {
+ ((AutoCloseableAsync) fs).closeAsync().get();
+ }
+ }
+
+ private static Stream<Arguments> factories() {
+ return Stream.of(
+ Arguments.of(new NativeS3FileSystemFactory(), "s3"),
+ Arguments.of(new NativeS3AFileSystemFactory(), "s3a"));
+ }
+
+ private static long listObjectsSuccessCount(MetricListener listener,
String expectedScheme) {
+ return counter(
+ listener,
+ "filesystem",
+ "filesystem_type",
+ expectedScheme,
+ "op",
+ "ListObjectsV2",
+ "status_class",
+ "2xx",
+ "api_call_count");
+ }
+
+ private static long counter(MetricListener listener, String... identifier)
{
+ return
listener.getCounter(identifier).map(Counter::getCount).orElse(0L);
+ }
+}
diff --git
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorderTest.java
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorderTest.java
new file mode 100644
index 00000000000..062a9b2b41e
--- /dev/null
+++
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/metrics/S3MetricRecorderTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * http://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.apache.flink.fs.s3native.metrics;
+
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.metrics.SlidingWindowHistogram;
+import org.apache.flink.metrics.testutils.MetricListener;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class S3MetricRecorderTest {
+
+ @Test
+ void restrictedAllowlistRegistersOnlySelectedMetrics() {
+ final MetricListener listener = new MetricListener();
+ final S3MetricRecorder recorder =
+ recorder(listener,
Collections.singletonList(S3MetricRecorder.API_CALL_COUNT));
+
+ recordAllMetrics(recorder);
+
+ assertThat(count(listener, "op", "Write", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(1L);
+ assertThat(listener.getHistogram("op", "Write",
"api_call_duration_ms")).isEmpty();
+ assertThat(listener.getCounter("op", "Write",
"throttle_count")).isEmpty();
+ assertThat(listener.getCounter("op", "Write", "reason", "throttled",
"retry_count"))
+ .isEmpty();
+ }
+
+ @Test
+ void iopsEnablesSourceCounter() {
+ final MetricListener listener = new MetricListener();
+ final S3MetricRecorder recorder =
+ recorder(listener,
Collections.singletonList(S3MetricRecorder.IOPS));
+
+ recorder.recordApiCall("Read", "2xx");
+
+ assertThat(count(listener, "op", "Read", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(1L);
+ }
+
+ @Test
+ void accumulatesMetricsAcrossCalls() {
+ final MetricListener listener = new MetricListener();
+ final S3MetricRecorder recorder = recorder(listener,
S3MetricRecorder.DEFAULT_ALLOWLIST);
+
+ recorder.recordApiCall("Read", "2xx");
+ recorder.recordApiCall("Read", "2xx");
+ recorder.recordDuration("Read", 10L);
+ recorder.recordDuration("Read", 30L);
+
+ assertThat(count(listener, "op", "Read", "status_class", "2xx",
"api_call_count"))
+ .isEqualTo(2L);
+ assertThat(
+ listener.getHistogram("op", "Read",
"api_call_duration_ms")
+ .orElseThrow(AssertionError::new)
+ .getStatistics()
+ .getValues())
+ .containsExactly(10L, 30L);
+ }
+
+ @Test
+ void wildcardIncludesMetricsOutsideDefaultAllowlist() {
+ final S3MetricRecorder wildcardRecorder =
+ recorder(new MetricListener(), Collections.singletonList("*"));
+ final S3MetricRecorder defaultRecorder =
+ recorder(new MetricListener(),
S3MetricRecorder.DEFAULT_ALLOWLIST);
+
+ assertThat(wildcardRecorder.isMetricEnabled("future_metric")).isTrue();
+ assertThat(defaultRecorder.isMetricEnabled("future_metric")).isFalse();
+ }
+
+ @Test
+ void rejectsEmptyAllowlist() {
+ final MetricListener listener = new MetricListener();
+
+ assertThatThrownBy(() -> recorder(listener, Collections.emptyList()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("allowlist must not be empty");
+ }
+
+ private static S3MetricRecorder recorder(
+ MetricListener listener, java.util.Collection<String> allowlist) {
+ return new S3MetricRecorder(
+ listener.getMetricGroup(), allowlist,
SlidingWindowHistogram.DEFAULT_WINDOW_SIZE);
+ }
+
+ private static void recordAllMetrics(S3MetricRecorder recorder) {
+ recorder.recordApiCall("Write", "2xx");
+ recorder.recordDuration("Write", 10L);
+ recorder.recordThrottles("Write", 1L);
+ recorder.recordRetries("Write", 1L, "throttled");
+ }
+
+ private static long count(MetricListener listener, String... identifier) {
+ return listener.getCounter(identifier)
+ .map(Counter::getCount)
+ .orElseThrow(() -> new AssertionError("Missing counter"));
+ }
+}
diff --git
a/flink-metrics/flink-metrics-core/src/main/java/org/apache/flink/metrics/SlidingWindowHistogram.java
b/flink-metrics/flink-metrics-core/src/main/java/org/apache/flink/metrics/SlidingWindowHistogram.java
new file mode 100644
index 00000000000..acfbbf2df87
--- /dev/null
+++
b/flink-metrics/flink-metrics-core/src/main/java/org/apache/flink/metrics/SlidingWindowHistogram.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.flink.metrics;
+
+import org.apache.flink.annotation.Internal;
+
+import java.io.Serializable;
+import java.util.Arrays;
+
+/** A thread-safe histogram retaining a fixed-size window of the most recent
values. */
+@Internal
+public class SlidingWindowHistogram implements Histogram, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final int DEFAULT_WINDOW_SIZE = 1024;
+
+ private final long[] window;
+ private int size;
+ private int next;
+ private long count;
+
+ public SlidingWindowHistogram(int windowSize) {
+ if (windowSize <= 0) {
+ throw new IllegalArgumentException("windowSize must be positive");
+ }
+ this.window = new long[windowSize];
+ }
+
+ @Override
+ public synchronized void update(long value) {
+ window[next] = value;
+ next = (next + 1) % window.length;
+ size = Math.min(size + 1, window.length);
+ count++;
+ }
+
+ @Override
+ public synchronized long getCount() {
+ return count;
+ }
+
+ @Override
+ public synchronized HistogramStatistics getStatistics() {
+ return new SlidingWindowStatistics(getValuesSnapshot());
+ }
+
+ protected synchronized long[] getValuesSnapshot() {
+ return Arrays.copyOf(window, size);
+ }
+
+ private static final class SlidingWindowStatistics extends
HistogramStatistics {
+
+ private final long[] sortedValues;
+ private final double mean;
+ private final double standardDeviation;
+
+ private SlidingWindowStatistics(long[] values) {
+ Arrays.sort(values);
+ this.sortedValues = values;
+ this.mean = calculateMean(values);
+ this.standardDeviation = calculateStandardDeviation(values, mean);
+ }
+
+ @Override
+ public double getQuantile(double quantile) {
+ if (sortedValues.length == 0) {
+ return 0.0;
+ }
+ final double position = quantile * (sortedValues.length + 1);
+ if (position < 1) {
+ return sortedValues[0];
+ }
+ if (position >= sortedValues.length) {
+ return sortedValues[sortedValues.length - 1];
+ }
+ final int lower = (int) position;
+ final double fraction = position - lower;
+ return sortedValues[lower - 1]
+ + fraction * (sortedValues[lower] - sortedValues[lower -
1]);
+ }
+
+ @Override
+ public long[] getValues() {
+ return Arrays.copyOf(sortedValues, sortedValues.length);
+ }
+
+ @Override
+ public int size() {
+ return sortedValues.length;
+ }
+
+ @Override
+ public double getMean() {
+ return mean;
+ }
+
+ @Override
+ public double getStdDev() {
+ return standardDeviation;
+ }
+
+ @Override
+ public long getMax() {
+ return sortedValues.length == 0 ? 0L :
sortedValues[sortedValues.length - 1];
+ }
+
+ @Override
+ public long getMin() {
+ return sortedValues.length == 0 ? 0L : sortedValues[0];
+ }
+
+ private static double calculateMean(long[] values) {
+ if (values.length == 0) {
+ return 0.0;
+ }
+ double sum = 0.0;
+ for (long value : values) {
+ sum += value;
+ }
+ return sum / values.length;
+ }
+
+ private static double calculateStandardDeviation(long[] values, double
mean) {
+ if (values.length <= 1) {
+ return 0.0;
+ }
+ double sum = 0.0;
+ for (long value : values) {
+ final double difference = value - mean;
+ sum += difference * difference;
+ }
+ return Math.sqrt(sum / (values.length - 1));
+ }
+ }
+}
diff --git
a/flink-metrics/flink-metrics-core/src/test/java/org/apache/flink/metrics/SlidingWindowHistogramTest.java
b/flink-metrics/flink-metrics-core/src/test/java/org/apache/flink/metrics/SlidingWindowHistogramTest.java
new file mode 100644
index 00000000000..5a3a345a538
--- /dev/null
+++
b/flink-metrics/flink-metrics-core/src/test/java/org/apache/flink/metrics/SlidingWindowHistogramTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.flink.metrics;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SlidingWindowHistogramTest extends AbstractHistogramTest {
+
+ @Test
+ void retainsMostRecentValues() {
+ final SlidingWindowHistogram histogram = new SlidingWindowHistogram(4);
+ for (long value = 1; value <= 10; value++) {
+ histogram.update(value);
+ }
+
+ assertThat(histogram.getCount()).isEqualTo(10L);
+ assertThat(histogram.getStatistics().getValues()).containsExactly(7L,
8L, 9L, 10L);
+ }
+
+ @Test
+ void computesStatistics() {
+ final SlidingWindowHistogram histogram = new
SlidingWindowHistogram(10);
+ for (long value = 1; value <= 5; value++) {
+ histogram.update(value);
+ }
+
+ final HistogramStatistics statistics = histogram.getStatistics();
+ assertThat(statistics.getMean()).isEqualTo(3.0);
+ assertThat(statistics.getStdDev()).isEqualTo(Math.sqrt(2.5));
+ assertThat(statistics.getQuantile(0.5)).isEqualTo(3.0);
+ }
+
+ @Test
+ void statisticsAreSnapshots() {
+ final SlidingWindowHistogram histogram = new SlidingWindowHistogram(2);
+ histogram.update(1L);
+ final HistogramStatistics statistics = histogram.getStatistics();
+
+ histogram.update(2L);
+
+ assertThat(statistics.getValues()).containsExactly(1L);
+ }
+
+ @Test
+ void satisfiesHistogramContract() {
+ testHistogram(10, new SlidingWindowHistogram(10));
+ }
+
+ @Test
+ void rejectsNonPositiveWindowSize() {
+ assertThatThrownBy(() -> new SlidingWindowHistogram(0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("windowSize must be positive");
+ }
+}
diff --git
a/flink-runtime/src/main/java/org/apache/flink/runtime/entrypoint/ClusterEntrypoint.java
b/flink-runtime/src/main/java/org/apache/flink/runtime/entrypoint/ClusterEntrypoint.java
index de80e508eea..dca34f97720 100755
---
a/flink-runtime/src/main/java/org/apache/flink/runtime/entrypoint/ClusterEntrypoint.java
+++
b/flink-runtime/src/main/java/org/apache/flink/runtime/entrypoint/ClusterEntrypoint.java
@@ -419,6 +419,8 @@ public abstract class ClusterEntrypoint implements
AutoCloseableAsync, FatalErro
ConfigurationUtils.getSystemResourceMetricsProbingInterval(
configuration));
+ FileSystem.attachMetrics(processMetricGroup);
+
archivedApplicationStore =
createArchivedApplicationStore(
configuration,
commonRpcService.getScheduledExecutor());
diff --git
a/flink-runtime/src/main/java/org/apache/flink/runtime/taskexecutor/TaskManagerRunner.java
b/flink-runtime/src/main/java/org/apache/flink/runtime/taskexecutor/TaskManagerRunner.java
index c8f0e24f732..7240b4fc1f5 100644
---
a/flink-runtime/src/main/java/org/apache/flink/runtime/taskexecutor/TaskManagerRunner.java
+++
b/flink-runtime/src/main/java/org/apache/flink/runtime/taskexecutor/TaskManagerRunner.java
@@ -640,6 +640,9 @@ public class TaskManagerRunner implements FatalErrorHandler
{
resourceID,
taskManagerServicesConfiguration.getSystemResourceMetricsProbingInterval());
+ // Attach file system metrics once the process-level MetricGroup
exists.
+ FileSystem.attachMetrics(taskManagerMetricGroup.f0);
+
final ExecutorService ioExecutor =
Executors.newFixedThreadPool(
taskManagerServicesConfiguration.getNumIoThreads(),