_unsuccessful_request` | Summary (per error code) | Count of failed requests, labelled by `KeeperException.Code` |
+| `ping_latency` | Summary | Client-observed ping round-trip time (ms) |
+| `connect_latency` | Summary | Time to establish a connection to a server (ms) |
+| `connection_established_count` | Counter | Number of connections successfully established |
+| `connection_loss_count` | Counter | Number of times an established connection was lost |
+| `reconnect_count` | Counter | Number of reconnection attempts |
+| `session_expired_count` | Counter | Number of session expirations detected by the client |
+
### Prometheus
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxn.java b/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxn.java
index c3f0079c6e9..af3b2136c16 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxn.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/ClientCnxn.java
@@ -66,6 +66,7 @@
import org.apache.zookeeper.ZooDefs.OpCode;
import org.apache.zookeeper.ZooKeeper.States;
import org.apache.zookeeper.ZooKeeper.WatchRegistration;
+import org.apache.zookeeper.client.ClientMetrics;
import org.apache.zookeeper.client.HostProvider;
import org.apache.zookeeper.client.ZKClientConfig;
import org.apache.zookeeper.client.ZooKeeperSaslClient;
@@ -279,6 +280,9 @@ static class Packet {
boolean finished;
+ /** Client elapsed time (ms) when the request was submitted; used to compute latency. */
+ long requestSubmitTimeMs;
+
AsyncCallback cb;
Object ctx;
@@ -313,6 +317,7 @@ static class Packet {
this.response = response;
this.readOnly = readOnly;
this.watchRegistration = watchRegistration;
+ this.requestSubmitTimeMs = Time.currentElapsedTime();
}
public void createBB() {
@@ -445,6 +450,8 @@ public ClientCnxn(
sendThread = new SendThread(clientCnxnSocket);
eventThread = new EventThread();
this.clientConfig = zooKeeper.getClientConfig();
+ ClientMetrics.initialize(clientConfig.getMetricsProviderClassName(),
+ clientConfig.getMetricsProviderConfiguration());
initRequestTimeout();
}
@@ -746,6 +753,7 @@ private void processEvent(Object event) {
// @VisibleForTesting
protected void finishPacket(Packet p) {
int err = p.replyHeader.getErr();
+ recordRequestMetrics(p, err);
if (p.watchRegistration != null) {
p.watchRegistration.register(err);
}
@@ -782,6 +790,26 @@ protected void finishPacket(Packet p) {
}
}
+ /**
+ * Emit client-side request metrics for a completed packet: per-operation latency (and therefore
+ * per-operation count) and, for failed requests, a count labelled by the error code.
+ */
+ private void recordRequestMetrics(Packet p, int err) {
+ RequestHeader h = p.requestHeader;
+ if (h == null) {
+ return;
+ }
+ ClientMetrics metrics = ClientMetrics.getMetrics();
+ String op = ClientMetrics.opName(h.getType());
+ if (p.requestSubmitTimeMs > 0) {
+ metrics.REQUEST_LATENCY.add(op, Time.currentElapsedTime() - p.requestSubmitTimeMs);
+ }
+ if (err != 0) {
+ Code code = Code.get(err);
+ metrics.UNSUCCESSFUL_REQUEST.add(code != null ? code.name() : String.valueOf(err), 1);
+ }
+ }
+
void queueEvent(String clientPath, int err, Set materializedWatchers, EventType eventType) {
KeeperState sessionState = KeeperState.SyncConnected;
if (KeeperException.Code.SESSIONEXPIRED.intValue() == err
@@ -876,6 +904,7 @@ public RWServerFoundException(String msg) {
class SendThread extends ZooKeeperThread {
private long lastPingSentNs;
+ private long connectStartTimeMs;
private final ClientCnxnSocket clientCnxnSocket;
private boolean isFirstConnect = true;
@@ -887,9 +916,11 @@ void readResponse(ByteBuffer incomingBuffer) throws IOException {
replyHdr.deserialize(bbia, "header");
switch (replyHdr.getXid()) {
case PING_XID:
+ long pingRttMs = (System.nanoTime() - lastPingSentNs) / 1000000;
LOG.debug("Got ping response for session id: 0x{} after {}ms.",
Long.toHexString(sessionId),
- ((System.nanoTime() - lastPingSentNs) / 1000000));
+ pingRttMs);
+ ClientMetrics.getMetrics().PING_LATENCY.add(pingRttMs);
return;
case AUTHPACKET_XID:
LOG.debug("Got auth session id: 0x{}", Long.toHexString(sessionId));
@@ -1141,7 +1172,9 @@ private void sendPing() {
private void startConnect(InetSocketAddress addr) throws IOException {
// initializing it for new connection
saslLoginFailed = false;
+ connectStartTimeMs = Time.currentElapsedTime();
if (!isFirstConnect) {
+ ClientMetrics.getMetrics().RECONNECT_COUNT.inc();
try {
Thread.sleep(ThreadLocalRandom.current().nextLong(1000));
} catch (InterruptedException e) {
@@ -1334,6 +1367,7 @@ public void run() {
private void cleanAndNotifyState() {
cleanup();
if (state.isAlive()) {
+ ClientMetrics.getMetrics().CONNECTION_LOSS_COUNT.inc();
eventThread.queueEvent(new WatchedEvent(Event.EventType.None, Event.KeeperState.Disconnected, null));
}
clientCnxnSocket.updateNow();
@@ -1435,6 +1469,7 @@ void onConnected(
"Unable to reconnect to ZooKeeper service, session 0x%s has expired",
Long.toHexString(sessionId));
LOG.warn(warnInfo);
+ ClientMetrics.getMetrics().SESSION_EXPIRED_COUNT.inc();
throw new SessionExpiredException(warnInfo);
}
@@ -1449,6 +1484,11 @@ void onConnected(
sessionPasswd = _sessionPasswd;
changeZkState((isRO) ? States.CONNECTEDREADONLY : States.CONNECTED);
seenRwServerBefore |= !isRO;
+ ClientMetrics establishedMetrics = ClientMetrics.getMetrics();
+ establishedMetrics.CONNECTION_ESTABLISHED_COUNT.inc();
+ if (connectStartTimeMs > 0) {
+ establishedMetrics.CONNECT_LATENCY.add(Time.currentElapsedTime() - connectStartTimeMs);
+ }
LOG.info(
"Session establishment complete on server {}, session id = 0x{}, negotiated timeout = {}{}",
clientCnxnSocket.getRemoteSocketAddress(),
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/client/ClientMetrics.java b/zookeeper-server/src/main/java/org/apache/zookeeper/client/ClientMetrics.java
new file mode 100644
index 00000000000..d9eb51d4216
--- /dev/null
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/client/ClientMetrics.java
@@ -0,0 +1,243 @@
+/*
+ * 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.zookeeper.client;
+
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.zookeeper.ZooDefs.OpCode;
+import org.apache.zookeeper.metrics.Counter;
+import org.apache.zookeeper.metrics.MetricsContext;
+import org.apache.zookeeper.metrics.MetricsContext.DetailLevel;
+import org.apache.zookeeper.metrics.MetricsProvider;
+import org.apache.zookeeper.metrics.MetricsProviderLifeCycleException;
+import org.apache.zookeeper.metrics.Summary;
+import org.apache.zookeeper.metrics.SummarySet;
+import org.apache.zookeeper.metrics.impl.DefaultMetricsProvider;
+import org.apache.zookeeper.metrics.impl.MetricsProviderBootstrap;
+import org.apache.zookeeper.metrics.impl.NullMetricsProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Tracks ZooKeeper client-side metrics.
+ *
+ * This is the client-side counterpart to {@code ServerMetrics}. Metrics are emitted only when a
+ * {@link MetricsProvider} is configured on the client through
+ * {@link org.apache.zookeeper.client.ZKClientConfig#ZOOKEEPER_CLIENT_METRICS_PROVIDER_CLASS_NAME}.
+ * By default the client uses the {@link NullMetricsProvider}, so there is no overhead unless the
+ * feature is explicitly enabled.
+ */
+public final class ClientMetrics {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ClientMetrics.class);
+
+ /**
+ * Dummy instance useful for tests and as a no-op default.
+ */
+ public static final ClientMetrics NULL_METRICS = new ClientMetrics(NullMetricsProvider.INSTANCE);
+
+ /**
+ * Dummy instance useful for tests.
+ */
+ public static final ClientMetrics DEFAULT_METRICS_FOR_TESTS = new ClientMetrics(new DefaultMetricsProvider());
+
+ /**
+ * Real instance used for tracking client side metrics. The final value is
+ * assigned after the {@link MetricsProvider} bootstrap.
+ */
+ private static volatile ClientMetrics CURRENT = DEFAULT_METRICS_FOR_TESTS;
+
+ /**
+ * Access current ClientMetrics.
+ *
+ * @return a reference to the current Metrics
+ */
+ public static ClientMetrics getMetrics() {
+ return CURRENT;
+ }
+
+ public static void metricsProviderInitialized(MetricsProvider metricsProvider) {
+ LOG.info("ClientMetrics initialized with provider {}", metricsProvider);
+ CURRENT = new ClientMetrics(metricsProvider);
+ }
+
+ /**
+ * Tracks whether a real (non-null) metrics provider has already been booted for this JVM.
+ * Client metrics are a per-JVM singleton (mirroring {@code ServerMetrics}); the first client to
+ * configure a provider wins, and later clients do not re-boot or tear it down.
+ */
+ private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false);
+
+ /**
+ * Bootstraps the client metrics provider from client configuration, if not already done.
+ *
+ * When no provider (or the {@link NullMetricsProvider}) is configured, client metrics stay a
+ * no-op and impose no overhead. When a real provider is configured, it is booted once per JVM;
+ * subsequent calls are ignored so that multiple {@code ZooKeeper} instances share a single
+ * provider and do not, for example, attempt to bind the same Prometheus port twice.
+ *
+ * @param providerClassName the configured metrics provider class name.
+ * @param config the configuration to pass to the provider.
+ */
+ public static void initialize(String providerClassName, Properties config) {
+ if (INITIALIZED.get()) {
+ return;
+ }
+ if (providerClassName == null || NullMetricsProvider.class.getName().equals(providerClassName)) {
+ CURRENT = NULL_METRICS;
+ return;
+ }
+ if (!INITIALIZED.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ MetricsProvider provider = MetricsProviderBootstrap.startMetricsProvider(providerClassName, config);
+ metricsProviderInitialized(provider);
+ LOG.info("Client metrics enabled with provider {}", providerClassName);
+ } catch (MetricsProviderLifeCycleException error) {
+ INITIALIZED.set(false);
+ CURRENT = NULL_METRICS;
+ LOG.error("Cannot boot client MetricsProvider {}; client metrics are disabled", providerClassName, error);
+ }
+ }
+
+ /**
+ * Build a fresh instance bound to the given provider without touching the global
+ * {@link #CURRENT} reference. Useful for tests that need an isolated set of metrics.
+ */
+ public static ClientMetrics newInstanceForTests(MetricsProvider metricsProvider) {
+ return new ClientMetrics(metricsProvider);
+ }
+
+ private final MetricsProvider metricsProvider;
+
+ /**
+ * Latency (milliseconds) of completed client requests, labelled by operation name.
+ * The {@code cnt__request_latency} value doubles as the per-operation request count.
+ */
+ public final SummarySet REQUEST_LATENCY;
+
+ /**
+ * Count of unsuccessful client requests, labelled by {@code KeeperException.Code} name.
+ */
+ public final SummarySet UNSUCCESSFUL_REQUEST;
+
+ /**
+ * Round-trip time (milliseconds) of client pings.
+ */
+ public final Summary PING_LATENCY;
+
+ /**
+ * Time (milliseconds) taken to establish a connection to a server.
+ */
+ public final Summary CONNECT_LATENCY;
+
+ public final Counter CONNECTION_ESTABLISHED_COUNT;
+ public final Counter CONNECTION_LOSS_COUNT;
+ public final Counter RECONNECT_COUNT;
+ public final Counter SESSION_EXPIRED_COUNT;
+
+ private ClientMetrics(MetricsProvider metricsProvider) {
+ this.metricsProvider = metricsProvider;
+ MetricsContext metricsContext = this.metricsProvider.getRootContext();
+
+ REQUEST_LATENCY = metricsContext.getSummarySet("request_latency", DetailLevel.ADVANCED);
+ UNSUCCESSFUL_REQUEST = metricsContext.getSummarySet("unsuccessful_request", DetailLevel.BASIC);
+ PING_LATENCY = metricsContext.getSummary("ping_latency", DetailLevel.BASIC);
+ CONNECT_LATENCY = metricsContext.getSummary("connect_latency", DetailLevel.BASIC);
+
+ CONNECTION_ESTABLISHED_COUNT = metricsContext.getCounter("connection_established_count");
+ CONNECTION_LOSS_COUNT = metricsContext.getCounter("connection_loss_count");
+ RECONNECT_COUNT = metricsContext.getCounter("reconnect_count");
+ SESSION_EXPIRED_COUNT = metricsContext.getCounter("session_expired_count");
+ }
+
+ public MetricsProvider getMetricsProvider() {
+ return metricsProvider;
+ }
+
+ /**
+ * Maps a ZooKeeper opcode to a human readable operation name used as a metric label.
+ *
+ * @param opCode the {@link OpCode} value from the request header.
+ * @return a readable operation name, or {@code op_} for unrecognized opcodes.
+ */
+ public static String opName(int opCode) {
+ switch (opCode) {
+ case OpCode.create:
+ return "create";
+ case OpCode.create2:
+ return "create2";
+ case OpCode.createTTL:
+ return "createTTL";
+ case OpCode.createContainer:
+ return "createContainer";
+ case OpCode.delete:
+ return "delete";
+ case OpCode.deleteContainer:
+ return "deleteContainer";
+ case OpCode.exists:
+ return "exists";
+ case OpCode.getData:
+ return "getData";
+ case OpCode.setData:
+ return "setData";
+ case OpCode.getACL:
+ return "getACL";
+ case OpCode.setACL:
+ return "setACL";
+ case OpCode.getChildren:
+ return "getChildren";
+ case OpCode.getChildren2:
+ return "getChildren2";
+ case OpCode.getAllChildrenNumber:
+ return "getAllChildrenNumber";
+ case OpCode.getEphemerals:
+ return "getEphemerals";
+ case OpCode.sync:
+ return "sync";
+ case OpCode.ping:
+ return "ping";
+ case OpCode.multi:
+ return "multi";
+ case OpCode.reconfig:
+ return "reconfig";
+ case OpCode.check:
+ return "check";
+ case OpCode.addWatch:
+ return "addWatch";
+ case OpCode.checkWatches:
+ return "checkWatches";
+ case OpCode.removeWatches:
+ return "removeWatches";
+ case OpCode.setWatches:
+ return "setWatches";
+ case OpCode.setWatches2:
+ return "setWatches2";
+ case OpCode.auth:
+ return "auth";
+ case OpCode.sasl:
+ return "sasl";
+ case OpCode.closeSession:
+ return "closeSession";
+ default:
+ return "op_" + opCode;
+ }
+ }
+}
diff --git a/zookeeper-server/src/main/java/org/apache/zookeeper/client/ZKClientConfig.java b/zookeeper-server/src/main/java/org/apache/zookeeper/client/ZKClientConfig.java
index 10c61375d1d..8b5254e261d 100644
--- a/zookeeper-server/src/main/java/org/apache/zookeeper/client/ZKClientConfig.java
+++ b/zookeeper-server/src/main/java/org/apache/zookeeper/client/ZKClientConfig.java
@@ -19,9 +19,12 @@
package org.apache.zookeeper.client;
import java.io.File;
+import java.util.Map;
+import java.util.Properties;
import org.apache.yetus.audience.InterfaceAudience;
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.common.ZKConfig;
+import org.apache.zookeeper.metrics.impl.NullMetricsProvider;
import org.apache.zookeeper.server.quorum.QuorumPeerConfig.ConfigException;
/**
@@ -65,6 +68,22 @@ public class ZKClientConfig extends ZKConfig {
*/
public static final long ZOOKEEPER_REQUEST_TIMEOUT_DEFAULT = 0;
+ /**
+ * Fully qualified class name of the {@link org.apache.zookeeper.metrics.MetricsProvider} to use
+ * on the client. Defaults to {@link NullMetricsProvider} so that client-side metrics are opt-in
+ * and impose no overhead unless explicitly enabled.
+ */
+ public static final String ZOOKEEPER_CLIENT_METRICS_PROVIDER_CLASS_NAME =
+ "zookeeper.clientMetricsProvider.className";
+
+ /**
+ * Prefix for client metrics provider configuration. Every system property that starts with this
+ * prefix (except {@code className}) is passed to the provider with the prefix stripped, mirroring
+ * the {@code metricsProvider.*} handling on the server.
+ */
+ public static final String ZOOKEEPER_CLIENT_METRICS_PROVIDER_PREFIX =
+ "zookeeper.clientMetricsProvider.";
+
public ZKClientConfig() {
super();
initFromJavaSystemProperties();
@@ -85,6 +104,8 @@ public ZKClientConfig(String configPath) throws ConfigException {
private void initFromJavaSystemProperties() {
setProperty(ZOOKEEPER_REQUEST_TIMEOUT, System.getProperty(ZOOKEEPER_REQUEST_TIMEOUT));
setProperty(ZOOKEEPER_SERVER_PRINCIPAL, System.getProperty(ZOOKEEPER_SERVER_PRINCIPAL));
+ setProperty(ZOOKEEPER_CLIENT_METRICS_PROVIDER_CLASS_NAME,
+ System.getProperty(ZOOKEEPER_CLIENT_METRICS_PROVIDER_CLASS_NAME));
}
@Override
@@ -142,4 +163,34 @@ public long getLong(String key, long defaultValue) {
return defaultValue;
}
+ /**
+ * Returns the configured client {@link org.apache.zookeeper.metrics.MetricsProvider} class name,
+ * or the {@link NullMetricsProvider} (no-op) when none is configured.
+ *
+ * @return the metrics provider class name to bootstrap on the client.
+ */
+ public String getMetricsProviderClassName() {
+ return getProperty(ZOOKEEPER_CLIENT_METRICS_PROVIDER_CLASS_NAME, NullMetricsProvider.class.getName());
+ }
+
+ /**
+ * Collects the configuration to pass to the client metrics provider. Every system property that
+ * starts with {@link #ZOOKEEPER_CLIENT_METRICS_PROVIDER_PREFIX} (except {@code className}) is
+ * added to the returned {@link Properties} with the prefix stripped.
+ *
+ * @return the metrics provider configuration.
+ */
+ public Properties getMetricsProviderConfiguration() {
+ Properties config = new Properties();
+ for (Map.Entry