Repository: aerospike/aerospike-client-python
Pull request #773: [CLIENT-3460] Add support for metrics v2
Author: login: juliannguyen4
Created: 2025-05-08T16:12:34Z | Merged: 2025-05-30T21:00:44Z

Note:
- Created new helper functions to convert Python integers to fixed width C integer types, as well as a Python unicode object to a C string. These can raise TypeError, ValueError or other Python native exceptions, but not AerospikeError exceptions.
- Refactored MetricsPolicy parsing code to use those helper functions, but this code chains ParamError to those two native exceptions because I implemented metrics v1's MetricsPolicy to raise ParamError to the user, so I decided to make metrics v2 also raise ParamError to be consistent.
- aerospike.{ConfigProvider,Transaction}: use the two helper functions above; there is no breaking change when calling their constructors. but since the helper functions now raise a different error message, we stop checking the error message in our test code. Changing error messages are not considered breaking API changes.

https://aerospike-python-client--773.org.readthedocs.build/en/773/aerospike_helpers.metrics.html#aerospike_helpers.metrics.NamespaceMetrics


Diff:
diff --git a/.gitmodules b/.gitmodules
index f7551e2745..136ba68cbe 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -2,4 +2,4 @@
 	path = aerospike-client-c
 	# url = git@github.com:aerospike/aerospike-client-c.git
 	url = https://github.com/aerospike/aerospike-client-c.git
-	branch = CLIENT-3346
+	branch = stage
diff --git a/aerospike-client-c b/aerospike-client-c
index 14fe70355d..05f7b13094 160000
--- a/aerospike-client-c
+++ b/aerospike-client-c
@@ -1 +1 @@
-Subproject commit 14fe70355d653ccfff65118025c78ab76dfbc2cf
+Subproject commit 05f7b13094e7f57b4b3dd9ffb057f7cd3215d67a
diff --git a/aerospike_helpers/metrics/__init__.py b/aerospike_helpers/metrics/__init__.py
index e0339f25ca..3e9dbba7f6 100644
--- a/aerospike_helpers/metrics/__init__.py
+++ b/aerospike_helpers/metrics/__init__.py
@@ -16,7 +16,7 @@
 
 """Classes used for metrics.
 
-:class:`ConnectionStats`, :class:`NodeMetrics`, :class:`Node`, and :class:`Cluster` do not have a constructor
+:class:`ConnectionStats`, :class:`NamespaceMetrics`, :class:`Node`, and :class:`Cluster` do not have a constructor
 because they are not meant to be created by the user. They are only meant to be returned from :class:`MetricsListeners`
 callbacks for reading data about the server and client.
 """
@@ -38,13 +38,22 @@ class ConnectionStats:
     pass
 
 
-class NodeMetrics:
+class NamespaceMetrics:
     """
-    Each type of latency has a list of latency buckets.
+    Namespace metrics.
 
-    Latency bucket counts are cumulative and not reset on each metrics snapshot interval.
+    Each command group has its own histogram (i.e list of latency buckets).
+    Latency histogram counts are cumulative and not reset on each metrics snapshot interval.
 
     Attributes:
+        ns (str): namespace
+        bytes_in (int): Bytes received from the server.
+        bytes_out (int): Bytes sent to the server.
+        error_count (int): Command error count since node was initialized. If the error is retryable, multiple errors
+            per command may occur.
+        timeout_count (int): Command timeout count since node was initialized. If the timeout is retryable
+            (i.e socket_timeout), multiple timeouts per command may occur.
+        key_busy_count (int): Command key busy error count since node was initialized.
         conn_latency (list[int])
         write_latency (list[int])
         read_latency (list[int])
@@ -62,11 +71,7 @@ class Node:
         address (str): The IP address / host name of the node (not including the port number).
         port (int): Port number of the node's address.
         conns (:py:class:`ConnectionStats`): Synchronous connection stats on this node.
-        error_count (int): Command error count since node was initialized. If the error is retryable,
-            multiple errors per command may occur.
-        timeout_count (int): Command timeout count since node was initialized.
-            If the timeout is retryable (i.e socketTimeout), multiple timeouts per command may occur.
-        metrics (:py:class:`NodeMetrics`): Node metrics
+        metrics (list[:py:class:`NamespaceMetrics`]): Node/namespace metrics
     """
     pass
 
@@ -146,7 +151,9 @@ def __init__(
             report_size_limit: int = 0,
             interval: int = 30,
             latency_columns: int = 7,
-            latency_shift: int = 1
+            latency_shift: int = 1,
+            labels: dict[str, str] = {},
+            app_id: Optional[str] = None
     ):
         self.metrics_listeners = metrics_listeners
         self.report_dir = report_dir
@@ -154,3 +161,5 @@ def __init__(
         self.interval = interval
         self.latency_columns = latency_columns
         self.latency_shift = latency_shift
+        self.labels = labels
+        self.app_id = app_id
diff --git a/src/include/conversions.h b/src/include/conversions.h
index 72b1e9a694..0a54a87117 100644
--- a/src/include/conversions.h
+++ b/src/include/conversions.h
@@ -247,7 +247,20 @@ PyObject *create_class_instance_from_module(as_error *error_p,
                                             const char *class_name,
                                             PyObject *py_arg);
 
-// Error indicator must always be checked after this call
-// Constructor parameter name needed for constructing error message
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj);
+// Convert a Python integer into a fixed-width integer and verify it is within that range
+// We return an unsigned long long because it should be able to fit all fixed-width int types up to uint64_t
+// Returns -1 on error. Error indicator can be checked to verify if error occurred
+// TODO: replace this with new API calls in Python 3.14
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
+                                             unsigned long long max_bound);
+uint8_t convert_pyobject_to_uint8_t(PyObject *pyobject);
+
+uint16_t convert_pyobject_to_uint16_t(PyObject *pyobject);
+
+uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject);
+
+uint64_t convert_pyobject_to_uint64_t(PyObject *pyobject);
+
+// Returns NULL on error.
+const char *convert_pyobject_to_str(as_error *err, PyObject *py_obj);
diff --git a/src/include/policy.h b/src/include/policy.h
index 2f65e11389..ececf7f3c2 100644
--- a/src/include/policy.h
+++ b/src/include/policy.h
@@ -289,9 +289,11 @@ as_status pyobject_to_batch_remove_policy(AerospikeClient *self, as_error *err,
 
 // metrics_policy must be declared already
 // py_metrics_policy must be non-NULL
-as_status
-set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
-                                     as_metrics_policy *metrics_policy);
+// Returns non-zero integer value on error.
+// On error, all memory from this function is freed
+int set_as_metrics_policy_using_pyobject(as_error *err,
+                                         PyObject *py_metrics_policy,
+                                         as_metrics_policy *metrics_policy);
 
 typedef struct {
     // Use listener name for error messages
diff --git a/src/main/client/metrics.c b/src/main/client/metrics.c
index 57096d5f0d..0eec3f2a12 100644
--- a/src/main/client/metrics.c
+++ b/src/main/client/metrics.c
@@ -15,6 +15,7 @@
  ******************************************************************************/
 
 #include <aerospike/as_metrics.h>
+#include <aerospike/as_log_macros.h>
 
 #include "metrics.h"
 #include "conversions.h"
@@ -35,54 +36,67 @@ PyObject *AerospikeClient_EnableMetrics(AerospikeClient *self, PyObject *args,
     // Python Function Argument Parsing
     if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O:enable_metrics", kwlist,
                                      &py_metrics_policy)) {
-        goto RAISE_EXCEPTION_WITHOUT_AS_ERROR;
+        return NULL;
     }
 
+    // To be passed into C client
     as_metrics_policy *metrics_policy_ref;
+    // If enable_metrics() succeeds, our heap-allocated udata will be free'd later when metrics is disabled (like when client.close() is called)
+    bool free_udata_as_py_listener_data = false;
+
     if (py_metrics_policy == NULL || py_metrics_policy == Py_None) {
         // Use C client's config metrics policy
         metrics_policy_ref = NULL;
     }
     else {
+        // Set a transaction-level metrics policy
         as_metrics_policy_init(&metrics_policy);
-        as_status status = set_as_metrics_policy_using_pyobject(
+        metrics_policy_ref = &metrics_policy;
+        int retval = set_as_metrics_policy_using_pyobject(
             &err, py_metrics_policy, &metrics_policy);
-        if (status != AEROSPIKE_OK) {
-            goto RAISE_EXCEPTION_USING_AS_ERROR;
+        if (retval != 0) {
+            goto CLEANUP_ON_ERROR;
         }
-        metrics_policy_ref = &metrics_policy;
     }
 
     // 2 scenarios:
-    // 1. If the user does not pass their own MetricsListeners object to client.enable_metrics(), udata is NULL
-    // 2. Otherwise, udata is non-NULL and set to heap-allocated PyListenerData
-    bool free_udata_as_py_listener_data =
+    // 1. If the user passes their own MetricsPolicy and MetricsListeners object to client.enable_metrics(), udata is NOT NULL and set to heap-allocated PyListenerData
+    // 2. Otherwise, udata is NULL.
+    free_udata_as_py_listener_data =
         metrics_policy_ref && metrics_policy.metrics_listeners.udata != NULL;
 
     Py_BEGIN_ALLOW_THREADS
     aerospike_enable_metrics(self->as, &err, metrics_policy_ref);
     Py_END_ALLOW_THREADS
 
+CLEANUP_ON_ERROR:
+    if (metrics_policy_ref) {
+        // This means we initialized metrics_policy earlier
+        as_metrics_policy_destroy(metrics_policy_ref);
+    }
+
     if (err.code != AEROSPIKE_OK) {
-        // In the above scenario #1, when udata is NULL before aerospike_enable_metrics() is called:
-        // It is possible for aerospike_enable_metrics() -> as_cluster_enable_metrics() -> as_metrics_writer_create()
-        // to fail before it assigns a heap allocated value to metrics_policy.metrics_listeners.udata while setting up
-        // the metrics writer.
-        // In that case, we don't want to free udata where udata is NULL
+        if (err.code == AEROSPIKE_METRICS_CONFLICT) {
+            as_log_warn(err.message);
+            as_error_reset(&err);
+            // Even though we aren't raising an exception, the C client's enable_metrics() failed
+            // so we still have to clean up udata now (see below)
+        }
+
         if (free_udata_as_py_listener_data) {
             free_py_listener_data(
                 (PyListenerData *)metrics_policy.metrics_listeners.udata);
         }
-        goto RAISE_EXCEPTION_USING_AS_ERROR;
     }
 
-    Py_INCREF(Py_None);
-    return Py_None;
-
-RAISE_EXCEPTION_USING_AS_ERROR:
-    raise_exception(&err);
-RAISE_EXCEPTION_WITHOUT_AS_ERROR:
-    return NULL;
+    if (err.code != AEROSPIKE_OK) {
+        raise_exception(&err);
+        return NULL;
+    }
+    else {
+        Py_INCREF(Py_None);
+        return Py_None;
+    }
 }
 
 PyObject *AerospikeClient_DisableMetrics(AerospikeClient *self, PyObject *args)
@@ -94,14 +108,17 @@ PyObject *AerospikeClient_DisableMetrics(AerospikeClient *self, PyObject *args)
     aerospike_disable_metrics(self->as, &err);
     Py_END_ALLOW_THREADS
 
-    if (err.code != AEROSPIKE_OK) {
-        goto error;
+    if (err.code == AEROSPIKE_METRICS_CONFLICT) {
+        as_log_warn(err.message);
+        as_error_reset(&err);
     }
 
-    Py_INCREF(Py_None);
-    return Py_None;
-
-error:
-    raise_exception(&err);
-    return NULL;
+    if (err.code != AEROSPIKE_OK) {
+        raise_exception(&err);
+        return NULL;
+    }
+    else {
+        Py_INCREF(Py_None);
+        return Py_None;
+    }
 }
diff --git a/src/main/client/type.c b/src/main/client/type.c
index 8225978425..289d52cbd4 100644
--- a/src/main/client/type.c
+++ b/src/main/client/type.c
@@ -935,10 +935,10 @@ static int AerospikeClient_Type_Init(AerospikeClient *self, PyObject *args,
             goto RAISE_EXCEPTION_WITH_AS_ERROR;
         }
         else {
-            as_status status = set_as_metrics_policy_using_pyobject(
+            int retval = set_as_metrics_policy_using_pyobject(
                 &constructor_err, py_obj_metrics_policy,
                 &(config.policies.metrics));
-            if (status != AEROSPIKE_OK) {
+            if (retval != AEROSPIKE_OK) {
                 goto RAISE_EXCEPTION_WITH_AS_ERROR;
             }
         }
diff --git a/src/main/config_provider/type.c b/src/main/config_provider/type.c
index 47311ccef1..0f8f0864fc 100644
--- a/src/main/config_provider/type.c
+++ b/src/main/config_provider/type.c
@@ -26,7 +26,7 @@ static PyObject *AerospikeConfigProvider_new(PyTypeObject *type, PyObject *args,
 
     uint32_t interval;
     if (py_interval) {
-        interval = convert_pyobject_to_uint32_t(py_interval, "interval");
+        interval = convert_pyobject_to_uint32_t(py_interval);
         if (PyErr_Occurred()) {
             goto error;
         }
diff --git a/src/main/conversions.c b/src/main/conversions.c
index c682b86b05..255cc79794 100644
--- a/src/main/conversions.c
+++ b/src/main/conversions.c
@@ -772,21 +772,51 @@ PyObject *create_py_conn_stats_from_as_conn_stats(as_error *error_p,
     return NULL;
 }
 
-// Creates and returns a Python client NodeMetrics object from a C client's as_node_metrics struct
+// Creates and returns a Python client NamespaceMetrics object from a C client's as_ns_metrics struct
 // If an error occurs here, return NULL
-PyObject *
-create_py_node_metrics_from_as_node_metrics(as_error *error_p,
-                                            as_node_metrics *node_metrics)
+PyObject *create_py_ns_metrics_from_as_ns_metrics(as_error *error_p,
+                                                  as_ns_metrics *ns_metrics)
 {
-    PyObject *py_node_metrics = create_class_instance_from_module(
-        error_p, "aerospike_helpers.metrics", "NodeMetrics", NULL);
-    if (!py_node_metrics) {
+    PyObject *py_ns_metrics = create_class_instance_from_module(
+        error_p, "aerospike_helpers.metrics", "NamespaceMetrics", NULL);
+    if (!py_ns_metrics) {
         return NULL;
     }
+
+    PyObject *py_ns = PyUnicode_FromString(ns_metrics->ns);
+    if (py_ns == NULL) {
+        goto error;
+    }
+    int retval = PyObject_SetAttrString(py_ns_metrics, "ns", py_ns);
+    Py_DECREF(py_ns);
+    if (retval == -1) {
+        goto error;
+    }
+
+    const char *uint64_fields[] = {"bytes_in", "bytes_out", "error_count",
+                                   "timeout_count", "key_busy_count"};
+    uint64_t field_vals[] = {ns_metrics->bytes_in, ns_metrics->bytes_out,
+                             ns_metrics->error_count, ns_metrics->timeout_count,
+                             ns_metrics->key_busy_count};
+    for (unsigned long i = 0;
+         i < sizeof(uint64_fields) / sizeof(uint64_fields[0]); i++) {
+        PyObject *py_field_val = PyLong_FromUnsignedLongLong(field_vals[i]);
+        if (!py_field_val) {
+            goto error;
+        }
+
+        int retval = PyObject_SetAttrString(py_ns_metrics, uint64_fields[i],
+                                            py_field_val);
+        Py_DECREF(py_field_val);
+        if (retval == -1) {
+            goto error;
+        }
+    }
+
     const char *node_metrics_fields[] = {"conn_latency", "write_latency",
                                          "read_latency", "batch_latency",
                                          "query_latency"};
-    uint32_t max = AS_LATENCY_TYPE_NONE;
+    uint32_t max = AS_LATENCY_TYPE_MAX;
     // For each latency type, get list of buckets
     for (uint32_t i = 0; i < max; i++) {
         PyObject *py_buckets = PyList_New(0);
@@ -796,8 +826,8 @@ create_py_node_metrics_from_as_node_metrics(as_error *error_p,
                             node_metrics_fields[i]);
             goto error;
         }
-        as_latency_buckets *buckets = &node_metrics->latency[i];
-        uint32_t bucket_max = buckets->latency_columns;
+        as_latency *buckets = ns_metrics->latency[i];
+        uint32_t bucket_max = buckets->size;
         // Append each bucket to a list of buckets
         for (uint32_t j = 0; j < bucket_max; j++) {
             uint64_t bucket = as_latency_get_bucket(buckets, j);
@@ -822,7 +852,7 @@ create_py_node_metrics_from_as_node_metrics(as_error *error_p,
             }
         }
 
-        int result = PyObject_SetAttrString(py_node_metrics,
+        int result = PyObject_SetAttrString(py_ns_metrics,
                                             node_metrics_fields[i], py_buckets);
         Py_DECREF(py_buckets);
         if (result == -1) {
@@ -834,10 +864,10 @@ create_py_node_metrics_from_as_node_metrics(as_error *error_p,
         }
     }
 
-    return py_node_metrics;
+    return py_ns_metrics;
 
 error:
-    Py_DECREF(py_node_metrics);
+    Py_DECREF(py_ns_metrics);
     return NULL;
 }
 
@@ -851,19 +881,27 @@ PyObject *create_py_node_from_as_node(as_error *error_p, struct as_node_s *node)
         return NULL;
     }
 
-    PyObject *py_name = PyUnicode_FromString(node->name);
-    PyObject_SetAttrString(py_node, "name", py_name);
-    Py_DECREF(py_name);
-
     // Get address short name (reused code from C client's metrics writer code)
     as_address *address = as_node_get_address(node);
     struct sockaddr *addr = (struct sockaddr *)&address->addr;
     char address_name[AS_IP_ADDRESS_SIZE];
     as_address_short_name(addr, address_name, sizeof(address_name));
 
-    PyObject *py_address = PyUnicode_FromString(address_name);
-    PyObject_SetAttrString(py_node, "address", py_address);
-    Py_DECREF(py_address);
+    const char *str_attr_names[] = {"name", "address"};
+    const char *str_attr_values[] = {node->name, address_name};
+    for (unsigned long i = 0;
+         i < sizeof(str_attr_names) / sizeof(str_attr_names[0]); i++) {
+        PyObject *py_attr_value = PyUnicode_FromString(str_attr_values[i]);
+        if (py_attr_value == NULL) {
+            goto error;
+        }
+        int retval =
+            PyObject_SetAttrString(py_node, str_attr_names[i], py_attr_value);
+        Py_DECREF(py_attr_value);
+        if (retval == -1) {
+            goto error;
+        }
+    }
 
     uint16_t port = as_address_port(addr);
     PyObject *py_port = PyLong_FromLong(port);
@@ -881,23 +919,57 @@ PyObject *create_py_node_from_as_node(as_error *error_p, struct as_node_s *node)
     PyObject_SetAttrString(py_node, "conns", py_conn_stats);
     Py_DECREF(py_conn_stats);
 
-    PyObject *py_error_count = PyLong_FromUnsignedLongLong(node->error_count);
-    PyObject_SetAttrString(py_node, "error_count", py_error_count);
-    Py_DECREF(py_error_count);
+    const char *const as_node_stats_attr_names[] = {
+        "error_count", "timeout_count", "key_busy_count"};
+    uint64_t as_node_stats_attr_values[] = {
+        node_stats.error_count,
+        node_stats.timeout_count,
+        node_stats.key_busy_count,
+    };
+    for (unsigned long i = 0; i < sizeof(as_node_stats_attr_values) /
+                                      sizeof(as_node_stats_attr_values[0]);
+         i++) {
+        PyObject *py_attr_value =
+            PyLong_FromUnsignedLongLong(as_node_stats_attr_values[i]);
+        if (!py_attr_value) {
+            goto error;
+        }
+        int retval = PyObject_SetAttrString(
+            py_node, as_node_stats_attr_names[i], py_attr_value);
+        Py_DECREF(py_attr_value);
+        if (retval == -1) {
+            goto error;
+        }
+    }
+
+    as_ns_metrics **ns_metrics = node->metrics;
+    PyObject *py_ns_metrics_list = PyList_New(node->metrics_size);
+    if (py_ns_metrics_list == NULL) {
+        goto error;
+    }
+    for (uint8_t i = 0; i < node->metrics_size; i++) {
+        PyObject *py_ns_metrics =
+            create_py_ns_metrics_from_as_ns_metrics(error_p, ns_metrics[i]);
+        if (!py_ns_metrics) {
+            goto loop_error;
+        }
+
+        int retval = PyList_SetItem(py_ns_metrics_list, i, py_ns_metrics);
+        if (retval == -1) {
+            goto loop_error;
+        }
+        continue;
 
-    PyObject *py_timeout_count =
-        PyLong_FromUnsignedLongLong(node->timeout_count);
-    PyObject_SetAttrString(py_node, "timeout_count", py_timeout_count);
-    Py_DECREF(py_timeout_count);
+    loop_error:
+        Py_DECREF(py_ns_metrics_list);
+        goto error;
+    }
 
-    as_node_metrics *node_metrics = node->metrics;
-    PyObject *py_node_metrics =
-        create_py_node_metrics_from_as_node_metrics(error_p, node_metrics);
-    if (!py_node_metrics) {
+    int retval = PyObject_SetAttrString(py_node, "metrics", py_ns_metrics_list);
+    Py_DECREF(py_ns_metrics_list);
+    if (retval == -1) {
         goto error;
     }
-    PyObject_SetAttrString(py_node, "metrics", py_node_metrics);
-    Py_DECREF(py_node_metrics);
 
     return py_node;
 
@@ -2599,6 +2671,7 @@ as_status batch_read_records_to_pyobject(AerospikeClient *self, as_error *err,
 This fetches a string from a Python String like. If it is a unicode in Python27, we need to convert it
 to a bytes like object first, and keep track of the intermediate object for later deletion.
 */
+// TODO: replace with convert_pyobject_to_str
 as_status string_and_pyuni_from_pystring(PyObject *py_string,
                                          PyObject **pyuni_r, char **c_str_ptr,
                                          as_error *err)
@@ -2885,29 +2958,69 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err,
     return err->code;
 }
 
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj)
+// TODO: There's a helper function in the Python client wrapper code called
+// get_uint32_value, but this can replace it.
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
+                                             unsigned long long max_bound)
 {
     if (!PyLong_Check(pyobject)) {
-        PyErr_Format(PyExc_TypeError, "%s must be an integer",
-                     param_name_of_pyobj);
+        PyErr_Format(PyExc_TypeError, "%S must be an integer", pyobject);
         goto error;
     }
-    unsigned long long_value = PyLong_AsUnsignedLong(pyobject);
+    unsigned long long value = PyLong_AsUnsignedLongLong(pyobject);
     if (PyErr_Occurred()) {
         goto error;
     }
 
-    if (long_value > UINT32_MAX) {
-        PyErr_Format(PyExc_ValueError,
-                     "%s is too large for an unsigned 32-bit integer",
-                     param_name_of_pyobj);
+    if (value > max_bound) {
+        PyErr_Format(PyExc_ValueError, "%S exceeds %llu", pyobject, max_bound);
         goto error;
     }
 
-    uint32_t value = (uint32_t)long_value;
     return value;
 
 error:
-    return 0;
+    return -1;
+}
+
+uint8_t convert_pyobject_to_uint8_t(PyObject *pyobject)
+{
+    return (uint8_t)convert_pyobject_to_fixed_width_integer_type(pyobject,
+                                                                 UINT8_MAX);
+}
+
+uint16_t convert_pyobject_to_uint16_t(PyObject *pyobject)
+{
+    return (uint16_t)convert_pyobject_to_fixed_width_integer_type(pyobject,
+                                                                  UINT16_MAX);
+}
+
+uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject)
+{
+    return (uint32_t)convert_pyobject_to_fixed_width_integer_type(pyobject,
+                                                                  UINT32_MAX);
+}
+
+uint64_t convert_pyobject_to_uint64_t(PyObject *pyobject)
+{
+    return (uint64_t)convert_pyobject_to_fixed_width_integer_type(pyobject,
+                                                                  UINT64_MAX);
+}
+
+const char *convert_pyobject_to_str(as_error *err, PyObject *py_obj)
+{
+    if (!PyUnicode_Check(py_obj)) {
+        PyErr_Format(PyExc_TypeError, "%S is not a Python unicode object",
+                     py_obj);
+        goto error;
+    }
+
+    const char *str = PyUnicode_AsUTF8(py_obj);
+    if (!str) {
+        goto error;
+    }
+    return str;
+error:
+    return NULL;
 }
diff --git a/src/main/policy.c b/src/main/policy.c
index 8d0b4f1909..8a25d099d7 100644
--- a/src/main/policy.c
+++ b/src/main/policy.c
@@ -1204,9 +1204,9 @@ set_as_metrics_listeners_using_pyobject(as_error *err,
 
 #define GET_ATTR_ERROR_MSG "Unable to fetch %s attribute"
 
-as_status
-set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
-                                     as_metrics_policy *metrics_policy)
+int set_as_metrics_policy_using_pyobject(as_error *err,
+                                         PyObject *py_metrics_policy,
+                                         as_metrics_policy *metrics_policy)
 {
     if (!is_pyobj_correct_as_helpers_type(py_metrics_policy, "metrics",
                                           "MetricsPolicy", false)) {
@@ -1237,12 +1237,12 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
@@ -1250,83 +1250,133 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    uint64_t report_size_limit =
+        convert_pyobject_to_uint64_t(py_report_size_limit);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        report_size_limit_attr_name, "unsigned 64-bit integer");
         goto error;
     }
-    unsigned long long report_size_limit =
-        PyLong_AsUnsignedLongLong(py_report_size_limit);
-    if (report_size_limit == (unsigned long long)-1 && PyErr_Occurred()) {
-        PyErr_Clear();
-        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+    metrics_policy->report_size_limit = report_size_limit;
+
+    const char *interval_field_name = "interval";
+    PyObject *py_interval =
+        PyObject_GetAttrString(py_metrics_policy, interval_field_name);
+    if (!py_interval) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        interval_field_name);
         goto error;
     }
-    if (report_size_limit > UINT64_MAX) {
+
+    uint32_t interval = convert_pyobject_to_uint32_t(py_interval);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        interval_field_name, "unsigned 32-bit integer");
         goto error;
     }
+    metrics_policy->interval = interval;
 
-    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
-
-    const char *uint32_fields[] = {"interval", "latency_columns",
-                                   "latency_shift"};
-    uint32_t *uint32_ptrs[] = {&metrics_policy->interval,
-                               &metrics_policy->latency_columns,
-                               &metrics_policy->latency_shift};
+    const char *uint8_field_names[] = {"latency_columns", "latency_shift"};
+    uint8_t *field_refs[] = {&metrics_policy->latency_columns,
+                             &metrics_policy->latency_shift};
     for (unsigned long i = 0;
-         i < sizeof(uint32_fields) / sizeof(uint32_fields[0]); i++) {
-        PyObject *py_field_value =
-            PyObject_GetAttrString(py_metrics_policy, uint32_fields[i]);
-        if (!py_field_value) {
+         i < sizeof(uint8_field_names) / sizeof(uint8_field_names[0]); i++) {
+        PyObject *py_attr_value =
+            PyObject_GetAttrString(py_metrics_policy, uint8_field_names[i]);
+        if (!py_attr_value) {
             as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                            uint32_fields[i]);
+                            uint8_field_names[i]);
             goto error;
         }
 
-        // There's a helper function in the Python client wrapper code called
-        // get_uint32_value
-        // But we don't use it because it doesn't set which exact line
-        // an error occurs. It only returns an error code when it happens
-        if (!PyLong_CheckExact(py_field_value)) {
+        uint8_t attr_value = convert_pyobject_to_uint8_t(py_attr_value);
+        if (PyErr_Occurred()) {
             as_error_update(err, AEROSPIKE_ERR_PARAM,
-                            INVALID_ATTR_TYPE_ERROR_MSG, uint32_fields[i],
-                            "unsigned 32-bit integer");
-            Py_DECREF(py_field_value);
+                            INVALID_ATTR_TYPE_ERROR_MSG, uint8_field_names[i],
+                            "unsigned 8-bit integer");
             goto error;
         }
+        *field_refs[i] = attr_value;
+    }
 
-        unsigned long field_value = PyLong_AsUnsignedLong(py_field_value);
-        if (field_value == (unsigned long)-1 && PyErr_Occurred()) {
-            PyErr_Clear();
-            as_error_update(err, AEROSPIKE_ERR_PARAM,
-                            INVALID_ATTR_TYPE_ERROR_MSG, uint32_fields[i],
-                            "unsigned 32-bit integer");
-            Py_DECREF(py_field_value);
-            goto error;
-        }
+    const char *labels_attr_name = "labels";
+    PyObject *py_labels =
+        PyObject_GetAttrString(py_metrics_policy, labels_attr_name);
+    if (!py_labels) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        labels_attr_name);
+        goto error;
+    }
+    else if (PyDict_Check(py_labels)) {
+        PyObject *py_label_name, *py_label_value;
+        Py_ssize_t pos = 0;
+        while (PyDict_Next(py_labels, &pos, &py_label_name, &py_label_value)) {
+            const char *label_name =
+                convert_pyobject_to_str(err, py_label_name);
+            if (label_name == NULL) {
+                as_error_update(err, AEROSPIKE_ERR_PARAM,
+                                INVALID_ATTR_TYPE_ERROR_MSG, labels_attr_name,
+                                "dict[str, str]");
+                goto while_error;
+            }
+            const char *label_value =
+                convert_pyobject_to_str(err, py_label_value);
+            if (label_value == NULL) {
+                as_error_update(err, AEROSPIKE_ERR_PARAM,
+                                INVALID_ATTR_TYPE_ERROR_MSG, labels_attr_name,
+                                "dict[str, str]");
+                goto while_error;
+            }
 
-        if (field_value > UINT32_MAX) {
-            as_error_update(err, AEROSPIKE_ERR_PARAM,
-                            INVALID_ATTR_TYPE_ERROR_MSG, uint32_fields[i],
-                            "unsigned 32-bit integer");
-            Py_DECREF(py_field_value);
+            as_metrics_policy_add_label(metrics_policy, label_name,
+                                        label_value);
+            continue;
+
+        while_error:
+            Py_DECREF(py_labels);
             goto error;
         }
+        Py_DECREF(py_labels);
+    }
+    else {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
+                        labels_attr_name, "dict[str, str]");
+        goto error;
+    }
 
-        *uint32_ptrs[i] = (uint32_t)field_value;
+    const char *app_id_attr_name = "app_id";
+    PyObject *py_app_id =
+        PyObject_GetAttrString(py_metrics_policy, app_id_attr_name);
+    if (!py_app_id) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        app_id_attr_name);
+        goto error;
+    }
+    else {
+        if (py_app_id != Py_None) {
+            const char *app_id = convert_pyobject_to_str(err, py_app_id);
+            if (app_id == NULL) {
+                Py_DECREF(py_app_id);
+                as_error_update(err, AEROSPIKE_ERR_PARAM,
+                                INVALID_ATTR_TYPE_ERROR_MSG, app_id_attr_name,
+                                "Optional[str]");
+                goto error;
+            }
+            as_metrics_policy_set_app_id(metrics_policy, app_id);
+        }
+        Py_DECREF(py_app_id);
     }
 
-    return AEROSPIKE_OK;
+    return 0;
 
 error:
     // udata would've been allocated if MetricsListener was successfully converted to C code
@@ -1334,5 +1384,5 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         free_py_listener_data(
             (PyListenerData *)metrics_policy->metrics_listeners.udata);
     }
-    return err->code;
+    return -1;
 }
diff --git a/src/main/transaction/type.c b/src/main/transaction/type.c
index 44311091a0..f7689ad7e8 100644
--- a/src/main/transaction/type.c
+++ b/src/main/transaction/type.c
@@ -44,8 +44,7 @@ static int AerospikeTransaction_init(AerospikeTransaction *self, PyObject *args,
     as_txn *txn;
     uint32_t reads_capacity, writes_capacity;
     if (py_reads_capacity) {
-        reads_capacity =
-            convert_pyobject_to_uint32_t(py_reads_capacity, kwlist[0]);
+        reads_capacity = convert_pyobject_to_uint32_t(py_reads_capacity);
         if (PyErr_Occurred()) {
             goto error;
         }
@@ -55,8 +54,7 @@ static int AerospikeTransaction_init(AerospikeTransaction *self, PyObject *args,
     }
 
     if (py_writes_capacity) {
-        writes_capacity =
-            convert_pyobject_to_uint32_t(py_writes_capacity, kwlist[1]);
+        writes_capacity = convert_pyobject_to_uint32_t(py_writes_capacity);
         if (PyErr_Occurred()) {
             goto error;
         }
@@ -112,7 +110,8 @@ static PyObject *AerospikeTransaction_get_timeout(AerospikeTransaction *self,
 static int AerospikeTransaction_set_timeout(AerospikeTransaction *self,
                                             PyObject *py_value, void *closure)
 {
-    uint32_t timeout = convert_pyobject_to_uint32_t(py_value, "timeout");
+    uint32_t timeout = (uint32_t)convert_pyobject_to_fixed_width_integer_type(
+        py_value, UINT32_MAX);
     if (PyErr_Occurred()) {
         return -1;
     }
diff --git a/test/dyn_config.yml b/test/dyn_config.yml
index dd817c919d..08f5b68973 100644
--- a/test/dyn_config.yml
+++ b/test/dyn_config.yml
@@ -2,10 +2,11 @@ metadata:
   app_name: configurationExample
   generation: 2
   version: 0.0.1
-
-static:
-  client:
-    config_interval: 1
 dynamic:
+  metrics:
+    enable: true
   write:
     send_key: false
+static:
+  client:
+    config_interval: 1
diff --git a/test/dyn_config_metrics_disabled.yml b/test/dyn_config_metrics_disabled.yml
new file mode 100644
index 0000000000..1d804922a2
--- /dev/null
+++ b/test/dyn_config_metrics_disabled.yml
@@ -0,0 +1,10 @@
+metadata:
+  app_name: configurationExample
+  generation: 2
+  version: 0.0.1
+dynamic:
+  metrics:
+    enable: false
+static:
+  client:
+    config_interval: 1
diff --git a/test/metrics/test_node_close_listener.py b/test/metrics/test_node_close_listener.py
index 32d81001e7..77354df1b0 100644
--- a/test/metrics/test_node_close_listener.py
+++ b/test/metrics/test_node_close_listener.py
@@ -2,7 +2,7 @@
 import docker
 
 import aerospike
-from aerospike_helpers.metrics import MetricsListeners, MetricsPolicy, Cluster, Node, ConnectionStats, NodeMetrics
+from aerospike_helpers.metrics import MetricsListeners, MetricsPolicy, Cluster, Node, ConnectionStats, NamespaceMetrics
 
 
 # Copied from new_tests/test_metrics.py
@@ -21,7 +21,7 @@ def test_node_is_populated(node: Node):
     assert type(node.error_count) == int
     assert type(node.timeout_count) == int
     # Check NodeMetrics
-    assert type(node.metrics) == NodeMetrics
+    assert type(node.metrics) == NamespaceMetrics
     metrics = node.metrics
     latency_buckets = [
         metrics.conn_latency,
diff --git a/test/new_tests/test_dynamic_config.py b/test/new_tests/test_dynamic_config.py
index cc3abdd156..b31188f785 100644
--- a/test/new_tests/test_dynamic_config.py
+++ b/test/new_tests/test_dynamic_config.py
@@ -3,6 +3,10 @@
 from .test_base_class import TestBaseClass
 import pytest
 import os
+import glob
+
+DYN_CONFIG_PATH = "./dyn_config.yml"
+METRICS_LOG_FILES = "./metrics-*.log"
 
 
 class TestDynamicConfig:
@@ -24,13 +28,32 @@ def test_config_provider_class(self):
     def test_config_provider_class_invalid_args(self):
         # See comment in test_mrt_api.py's test_transaction_class test case
         # for why Windows throws OverflowError instead of ValueError
-        with pytest.raises((ValueError, OverflowError)) as excinfo:
+        with pytest.raises((ValueError, OverflowError)):
             aerospike.ConfigProvider("path", interval=2**32)
-        if excinfo.type == ValueError:
-            assert excinfo.value.args[0] == "interval is too large for an unsigned 32-bit integer"
 
+    # We want to check that the config file we pass in is valid
+    # The C client prints logs showing that it detects changes to the dynamic config file
+    # We also want to check that enable/disable metrics prints out warning logs when dyn config is enabled
     @pytest.fixture
-    def functional_test_setup(self):
+    def show_more_logs(self):
+        aerospike.set_log_level(aerospike.LOG_LEVEL_TRACE)
+
+        yield
+
+        # TODO: currently there is no way to restore the log handler and level before running this test
+        # These are the defaults in the implementation
+        aerospike.set_log_level(aerospike.LOG_LEVEL_ERROR)
+
+    @pytest.fixture
+    def cleanup_metrics_logs(self):
+        yield
+
+        metrics_log_filenames = glob.glob(METRICS_LOG_FILES)
+        for item in metrics_log_filenames:
+            os.remove(item)
+
+    @pytest.fixture
+    def functional_test_setup(self, show_more_logs, cleanup_metrics_logs):
         config = TestBaseClass.get_connection_config()
         setup_client = aerospike.client(config)
         self.key = ("test", "demo", 1)
@@ -41,6 +64,9 @@ def functional_test_setup(self):
 
         yield
 
+        # Close file descriptors for metrics log files before removing the files
+        self.client.close()
+
         setup_client.remove(self.key)
         setup_client.close()
 
@@ -49,10 +75,6 @@ def functional_test_setup(self):
     # Dynamic config file should take precedence over both client config defaults and programmatically set values
     def test_dyn_config_file_has_highest_precedence(self, functional_test_setup, use_env_var: bool):
         config = TestBaseClass.get_connection_config()
-        # Uncomment if we want to check that the config file we pass in is valid
-        # The C client prints logs showing that it detects changes to the dynamic config file
-        # aerospike.set_log_level(aerospike.LOG_LEVEL_TRACE)
-        DYN_CONFIG_PATH = "./dyn_config.yml"
         if use_env_var:
             AEROSPIKE_CLIENT_CONFIG_URL = "AEROSPIKE_CLIENT_CONFIG_URL"
             os.environ[AEROSPIKE_CLIENT_CONFIG_URL] = DYN_CONFIG_PATH
@@ -62,25 +84,43 @@ def test_dyn_config_file_has_highest_precedence(self, functional_test_setup, use
 
         write_policy = {"key": aerospike.POLICY_KEY_SEND}
         config["policies"]["write"] = write_policy
-        client = aerospike.client(config)
+        self.client = aerospike.client(config)
 
-        client.put(self.key, bins={"a": 1}, policy=write_policy)
+        self.client.put(self.key, bins={"a": 1}, policy=write_policy)
 
         # "Send key" is disabled in dynamic config
         # The key should not be returned here
-        query = client.query("test", "demo")
+        query = self.client.query("test", "demo")
         recs = query.results()
         assert len(recs) == 1
-        # Check that record key tuple has a primary key
+        # Check that record key tuple does not have a primary key
         first_record = recs[0]
         first_record_key = first_record[0]
         assert first_record_key[2] is None
 
         # Cleanup
-        client.close()
         if use_env_var:
             del os.environ[AEROSPIKE_CLIENT_CONFIG_URL]
 
+    def test_enable_metrics_cannot_override_dyn_config(self, show_more_logs):
+        config = TestBaseClass.get_connection_config()
+        config["config_provider"] = aerospike.ConfigProvider("./dyn_config_metrics_disabled.yml")
+        client = aerospike.client(config)
+
+        client.enable_metrics()
+
+        # Cleanup
+        client.close()
+
+    def test_disable_metrics_cannot_override_dyn_config(self, show_more_logs, cleanup_metrics_logs):
+        config = TestBaseClass.get_connection_config()
+        config["config_provider"] = aerospike.ConfigProvider(DYN_CONFIG_PATH)
+        client = aerospike.client(config)
+
+        client.disable_metrics()
+
+        client.close()
+
     def test_api_invalid_provider(self):
         config = TestBaseClass.get_connection_config()
         config["config_provider"] = 0
diff --git a/test/new_tests/test_metrics.py b/test/new_tests/test_metrics.py
index 22c49d4939..a5c0ee9303 100644
--- a/test/new_tests/test_metrics.py
+++ b/test/new_tests/test_metrics.py
@@ -1,5 +1,5 @@
 from aerospike import exception as e
-from aerospike_helpers.metrics import MetricsPolicy, MetricsListeners, Cluster, Node, ConnectionStats, NodeMetrics
+from aerospike_helpers.metrics import MetricsPolicy, MetricsListeners, Cluster, Node, ConnectionStats, NamespaceMetrics
 import pytest
 import shutil
 import glob
@@ -55,6 +55,14 @@ def disable_throw_exc(_: Cluster):
 
 
 class TestMetrics:
+    # Shared between some test cases
+    listeners = MetricsListeners(
+        enable_listener=MyMetricsListeners.enable,
+        disable_listener=MyMetricsListeners.disable,
+        node_close_listener=MyMetricsListeners.node_close,
+        snapshot_listener=MyMetricsListeners.snapshot
+    )
+
     @pytest.fixture(autouse=True)
     def setup(self, as_connection, request):
         # Clear results from previous tests
@@ -122,24 +130,27 @@ def test_metrics_writer(self):
         metrics_log_filenames = glob.glob("./metrics-*.log")
         assert len(metrics_log_filenames) > 0
 
-    def test_setting_metrics_policy_custom_settings(self):
+    @pytest.mark.parametrize(
+        "app_id",
+        [
+            "application",
+            None
+        ]
+    )
+    def test_setting_metrics_policy_custom_settings(self, app_id):
         self.metrics_log_folder = "./metrics-logs"
 
-        listeners = MetricsListeners(
-            enable_listener=MyMetricsListeners.enable,
-            disable_listener=MyMetricsListeners.disable,
-            node_close_listener=MyMetricsListeners.node_close,
-            snapshot_listener=MyMetricsListeners.snapshot
-        )
         # Save bucket count for testing later
         bucket_count = 5
         policy = MetricsPolicy(
-            metrics_listeners=listeners,
+            metrics_listeners=self.listeners,
             report_dir=self.metrics_log_folder,
             report_size_limit=1000,
             interval=2,
             latency_columns=bucket_count,
-            latency_shift=2
+            latency_shift=2,
+            labels={"a": "b"},
+            app_id=app_id
         )
 
         self.as_connection.enable_metrics(policy=policy)
@@ -172,23 +183,29 @@ def test_setting_metrics_policy_custom_settings(self):
                 assert type(node.conns.in_pool) == int
                 assert type(node.conns.opened) == int
                 assert type(node.conns.closed) == int
-                assert type(node.error_count) == int
-                assert type(node.timeout_count) == int
                 # Check NodeMetrics
-                assert type(node.metrics) == NodeMetrics
-                metrics = node.metrics
-                latency_buckets = [
-                    metrics.conn_latency,
-                    metrics.write_latency,
-                    metrics.read_latency,
-                    metrics.batch_latency,
-                    metrics.query_latency
-                ]
-                for buckets in latency_buckets:
-                    assert type(buckets) == list
-                    assert len(buckets) == bucket_count
-                    for bucket in buckets:
-                        assert type(bucket) == int
+                assert type(node.metrics) == list
+                ns_metrics = node.metrics
+                for ns_metric in ns_metrics:
+                    assert type(ns_metric) == NamespaceMetrics
+                    assert type(ns_metric.ns) == str
+                    assert type(ns_metric.bytes_in) == int
+                    assert type(ns_metric.bytes_out) == int
+                    assert type(ns_metric.error_count) == int
+                    assert type(ns_metric.timeout_count) == int
+                    assert type(ns_metric.key_busy_count) == int
+                    latency_buckets = [
+                        ns_metric.conn_latency,
+                        ns_metric.write_latency,
+                        ns_metric.read_latency,
+                        ns_metric.batch_latency,
+                        ns_metric.query_latency
+                    ]
+                    for buckets in latency_buckets:
+                        assert type(buckets) == list
+                        assert len(buckets) == bucket_count
+                        for bucket in buckets:
+                            assert type(bucket) == int
 
     # Unable to test the case where an exception value could not be retrieved
     # Having the callback raise an Exception without a value does not trigger this
@@ -240,30 +257,51 @@ def test_enable_listener_throwing_exception(self, enable_callback: callable, err
                 "str"
             ),
             (
-                MetricsPolicy(report_size_limit=True),
+                MetricsPolicy(report_size_limit="1"),
                 "report_size_limit",
                 "unsigned 64-bit integer"
             ),
             (
-                MetricsPolicy(interval=True),
+                MetricsPolicy(interval="1"),
                 "interval",
                 "unsigned 32-bit integer"
             ),
             (
-                MetricsPolicy(latency_columns=True),
+                MetricsPolicy(latency_columns="1"),
                 "latency_columns",
-                "unsigned 32-bit integer"
+                "unsigned 8-bit integer"
             ),
             (
-                MetricsPolicy(latency_shift=True),
+                MetricsPolicy(latency_shift="1"),
                 "latency_shift",
-                "unsigned 32-bit integer"
+                "unsigned 8-bit integer"
             ),
-            # Pass in an integer larger than allowed for an unsigned 32-bit integer
+            # Pass in an integer larger than allowed for an unsigned 8-bit integer
             (
-                MetricsPolicy(latency_shift=2**32),
+                MetricsPolicy(latency_shift=2**8),
                 "latency_shift",
-                "unsigned 32-bit integer"
+                "unsigned 8-bit integer"
+            ),
+            # Invalid labels
+            (
+                MetricsPolicy(labels={1: "a"}),
+                "labels",
+                "dict[str, str]"
+            ),
+            (
+                MetricsPolicy(labels={"a": 1}),
+                "labels",
+                "dict[str, str]"
+            ),
+            (
+                MetricsPolicy(labels=[]),
+                "labels",
+                "dict[str, str]"
+            ),
+            (
+                MetricsPolicy(app_id=1),
+                "app_id",
+                "Optional[str]"
             )
         ]
     )
@@ -273,7 +311,13 @@ def test_metrics_policy_invalid_args(self, policy, field_name, expected_field_ty
         assert excinfo.value.msg == f"MetricsPolicy.{field_name} must be a {expected_field_type} type"
 
     def test_metrics_policy_report_dir_too_long(self):
-        policy = MetricsPolicy(report_dir=str('.' * 257))
+        policy = MetricsPolicy(
+            # We are testing that the Python client's udata for aerospike_enable_metrics() is freed properly on error
+            # because we never end up calling aerospike_enable_metrics()
+            # This is for code coverage purposes
+            metrics_listeners=self.listeners,
+            report_dir=str('.' * 257),
+        )
         with pytest.raises(e.ParamError) as excinfo:
             self.as_connection.enable_metrics(policy=policy)
         assert excinfo.value.msg == "MetricsPolicy.report_dir must be less than 256 chars"
diff --git a/test/new_tests/test_mrt_api.py b/test/new_tests/test_mrt_api.py
index e88e405391..b53abd3d63 100644
--- a/test/new_tests/test_mrt_api.py
+++ b/test/new_tests/test_mrt_api.py
@@ -2,27 +2,27 @@
 from aerospike import exception as e
 import pytest
 from contextlib import nullcontext
-from typing import Optional, Callable
+from typing import Callable
 
 
 @pytest.mark.usefixtures("as_connection")
 class TestMRTAPI:
     @pytest.mark.parametrize(
-        "kwargs, context, err_msg",
+        "kwargs, context",
         [
-            ({}, nullcontext(), None),
-            ({"reads_capacity": 256, "writes_capacity": 256}, nullcontext(), None),
+            ({}, nullcontext()),
+            ({"reads_capacity": 256, "writes_capacity": 256}, nullcontext()),
             (
                 {"reads_capacity": 256, "writes_capacity": 256, "invalid_arg": 1},
-                pytest.raises((TypeError)), "function takes at most 2 keyword arguments (3 given)"
+                pytest.raises((TypeError))
             ),
             (
                 {"reads_capacity": "256", "writes_capacity": 256},
-                pytest.raises((TypeError)), "reads_capacity must be an integer"
+                pytest.raises((TypeError))
             ),
             (
                 {"reads_capacity": 256, "writes_capacity": "256"},
-                pytest.raises((TypeError)), "writes_capacity must be an integer"
+                pytest.raises((TypeError))
             ),
             # Only need to test codepath once for uint32_t conversion helper function
             (
@@ -32,12 +32,11 @@ class TestMRTAPI:
                 # Python in Windows x64 will throw an internal error (OverflowError) when trying to convert a Python
                 # int that is larger than 4 bytes into an unsigned long.
                 # That error doesn't happen in Linux for that same scenario, so we throw our own error
-                pytest.raises((ValueError, OverflowError)),
-                "reads_capacity is too large for an unsigned 32-bit integer"
+                pytest.raises((ValueError, OverflowError))
             )
         ],
     )
-    def test_transaction_class(self, kwargs: dict, context, err_msg: Optional[str]):
+    def test_transaction_class(self, kwargs: dict, context):
         with context as excinfo:
             mrt = aerospike.Transaction(**kwargs)
         if type(context) == nullcontext:
@@ -53,9 +52,6 @@ def test_transaction_class(self, kwargs: dict, context, err_msg: Optional[str]):
             if kwargs == {"reads_capacity": 2**32, "writes_capacity": 256} and excinfo.type == OverflowError:
                 # Internal Python error thrown in Windows
                 assert str(excinfo.value) == "Python int too large to convert to C unsigned long"
-            else:
-                # Custom error thrown by Python client for other platforms
-                assert str(excinfo.value) == err_msg
 
     # Even though this is an unlikely use case, this should not cause problems.
     def test_transaction_reinit(self):
diff --git a/test/new_tests/test_new_constructor.py b/test/new_tests/test_new_constructor.py
index 584789c997..905d783cbb 100644
--- a/test/new_tests/test_new_constructor.py
+++ b/test/new_tests/test_new_constructor.py
@@ -4,7 +4,6 @@
 from .test_base_class import TestBaseClass
 import aerospike
 from aerospike import exception as e
-from aerospike import ConfigProvider
 from aerospike_helpers.operations import operations
 from aerospike_helpers.batch.records import Write, BatchRecords
 from aerospike_helpers.metrics import MetricsPolicy
@@ -218,9 +217,7 @@ def test_setting_metrics_policy():
     BUCKET_COUNT = 3
     METRICS_LOG_FILES = "./metrics-*.log"
 
-    # Enable dynamic config to apply config-level metrics policy
     config["policies"]["metrics"] = MetricsPolicy(latency_columns=BUCKET_COUNT)
-    config["config_provider"] = ConfigProvider("./dyn_config.yml")
     client = aerospike.client(config)
     try:
         client.enable_metrics()


Reviews:
user: login: dwelch-spike
body: Nice job, looks good overall especially with the tests and the new helpers. I left some comments about wrapping the new integer helper to make code more explicit, feel free to take or leave that. Let me know once you've had a look.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T18:09:11Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T18:32:17Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T18:39:26Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T18:43:24Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T18:54:50Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:01:40Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:02:06Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:03:59Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:04:37Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:05:24Z
user: login: juliannguyen4
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-30T19:06:56Z
user: login: dwelch-spike
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T20:58:55Z

Review comments:
diff_hunk: @@ -38,13 +38,22 @@ class ConnectionStats:
     pass
 
 
-class NodeMetrics:
+class NamespaceMetrics:
     """
-    Each type of latency has a list of latency buckets.
+    Namespace metrics.
 
-    Latency bucket counts are cumulative and not reset on each metrics snapshot interval.
+    Each command group has its own histogram (i.e list of latency buckets).
+    Latency histogram counts are cumulative and not reset on each metrics snapshot interval.
 
     Attributes:
+        ns (str): namespace
+        bytes_in (int): Bytes received from the server.
+        bytes_out (int): Bytes sent to the server.
+        error_count (int): Command error count since node was initialized. If the error is retryable, multiple errors
path: aerospike_helpers/metrics/__init__.py
user: login: dwelch-spike
body: `Command error count since node` or `Command error count since namespace` ? I'm guessing this class was renamed because these are namespace metrics but they differ across nodes? Is that right?
created_at: 2025-05-30T17:13:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2885,29 +2958,45 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err,
     return err->code;
 }
 
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj)
+// TODO: There's a helper function in the Python client wrapper code called
+// get_uint32_value, but this can replace it.
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
+                                             unsigned long long max_bound)
 {
-    if (!PyLong_Check(pyobject)) {
-        PyErr_Format(PyExc_TypeError, "%s must be an integer",
-                     param_name_of_pyobj);
+    if (!PyLong_CheckExact(pyobject)) {
path: src/main/conversions.c
user: login: dwelch-spike
body: Is a strict check necessary? What if someone wants to use a subclass? 
created_at: 2025-05-30T17:35:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2885,29 +2958,45 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err,
     return err->code;
 }
 
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj)
+// TODO: There's a helper function in the Python client wrapper code called
+// get_uint32_value, but this can replace it.
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
path: src/main/conversions.c
user: login: dwelch-spike
body: I like this helper function and I also like the explicit nature of the dedicated `convert_pyobject_to_uint32_t` etc functions that is replaces. In order to increase clarity and reduce the potential for incorrect usage of `max_bound` what if you wrapped this function to make functions for dedicated integer widths? For example...

```c
uint32_t convert_pyobject_to_uint32_t(Pyobject *pyobject) {
    return (uint32_t)convert_pyobject_to_fixed_width_integer_type(pyobject, UNIT32_MAX);
}
```
created_at: 2025-05-30T17:42:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,54 +36,67 @@ PyObject *AerospikeClient_EnableMetrics(AerospikeClient *self, PyObject *args,
     // Python Function Argument Parsing
     if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O:enable_metrics", kwlist,
                                      &py_metrics_policy)) {
-        goto RAISE_EXCEPTION_WITHOUT_AS_ERROR;
+        return NULL;
     }
 
+    // To be passed into C client
     as_metrics_policy *metrics_policy_ref;
+    bool free_udata_as_py_listener_data = false;
+
     if (py_metrics_policy == NULL || py_metrics_policy == Py_None) {
         // Use C client's config metrics policy
         metrics_policy_ref = NULL;
     }
     else {
+        // Set a transaction-level metrics policy
         as_metrics_policy_init(&metrics_policy);
-        as_status status = set_as_metrics_policy_using_pyobject(
+        metrics_policy_ref = &metrics_policy;
+        int retval = set_as_metrics_policy_using_pyobject(
             &err, py_metrics_policy, &metrics_policy);
-        if (status != AEROSPIKE_OK) {
-            goto RAISE_EXCEPTION_USING_AS_ERROR;
+        if (retval != 0) {
+            goto CLEANUP_ON_ERROR;
         }
-        metrics_policy_ref = &metrics_policy;
     }
 
     // 2 scenarios:
-    // 1. If the user does not pass their own MetricsListeners object to client.enable_metrics(), udata is NULL
-    // 2. Otherwise, udata is non-NULL and set to heap-allocated PyListenerData
-    bool free_udata_as_py_listener_data =
+    // 1. If the user passes their own MetricsPolicy and MetricsListeners object to client.enable_metrics(), udata is NOT NULL and set to heap-allocated PyListenerData
+    // 2. Otherwise, udata is NULL.
+    free_udata_as_py_listener_data =
         metrics_policy_ref && metrics_policy.metrics_listeners.udata != NULL;
 
     Py_BEGIN_ALLOW_THREADS
     aerospike_enable_metrics(self->as, &err, metrics_policy_ref);
     Py_END_ALLOW_THREADS
 
+CLEANUP_ON_ERROR:
+    if (metrics_policy_ref) {
+        // This means we initialized metrics_policy earlier
+        as_metrics_policy_destroy(metrics_policy_ref);
+    }
+
     if (err.code != AEROSPIKE_OK) {
-        // In the above scenario #1, when udata is NULL before aerospike_enable_metrics() is called:
-        // It is possible for aerospike_enable_metrics() -> as_cluster_enable_metrics() -> as_metrics_writer_create()
-        // to fail before it assigns a heap allocated value to metrics_policy.metrics_listeners.udata while setting up
-        // the metrics writer.
-        // In that case, we don't want to free udata where udata is NULL
+        if (err.code == AEROSPIKE_METRICS_CONFLICT) {
+            as_log_warn(err.message);
+            as_error_reset(&err);
+            // Even though we aren't raising an exception, the C client's enable_metrics() failed
+            // so we still have to clean up udata now (see below)
+        }
+
         if (free_udata_as_py_listener_data) {
+            // This will be free'd later when metrics is disabled (like when client.close() is called)
             free_py_listener_data(
                 (PyListenerData *)metrics_policy.metrics_listeners.udata);
path: src/main/client/metrics.c
user: login: dwelch-spike
body: The comment here makes me think free_py_listener_data should not be called here.
created_at: 2025-05-30T17:47:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -935,10 +935,10 @@ static int AerospikeClient_Type_Init(AerospikeClient *self, PyObject *args,
             goto RAISE_EXCEPTION_WITH_AS_ERROR;
         }
         else {
-            as_status status = set_as_metrics_policy_using_pyobject(
+            int retval = set_as_metrics_policy_using_pyobject(
                 &constructor_err, py_obj_metrics_policy,
                 &(config.policies.metrics));
-            if (status != AEROSPIKE_OK) {
+            if (retval != AEROSPIKE_OK) {
path: src/main/client/type.c
user: login: dwelch-spike
body: ```suggestion
            if (retval != 0) {
```

Why the switch to int?
created_at: 2025-05-30T17:49:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1204,9 +1204,9 @@ set_as_metrics_listeners_using_pyobject(as_error *err,
 
 #define GET_ATTR_ERROR_MSG "Unable to fetch %s attribute"
 
-as_status
-set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
-                                     as_metrics_policy *metrics_policy)
+int set_as_metrics_policy_using_pyobject(as_error *err,
+                                         PyObject *py_metrics_policy,
path: src/main/policy.c
user: login: dwelch-spike
body: I don't really have an opinion here but why the move away from as_status? That is the result type most often checked and returned in this function.
created_at: 2025-05-30T17:51:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
path: src/main/policy.c
user: login: dwelch-spike
body: I like to use fixed width types whenever possible so uint64_t here. Just makes keeping track of intended width easier.
created_at: 2025-05-30T17:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
+        convert_pyobject_to_fixed_width_integer_type(py_report_size_limit,
+                                                     UINT64_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        report_size_limit_attr_name, "unsigned 64-bit integer");
         goto error;
     }
-    unsigned long long report_size_limit =
-        PyLong_AsUnsignedLongLong(py_report_size_limit);
-    if (report_size_limit == (unsigned long long)-1 && PyErr_Occurred()) {
-        PyErr_Clear();
-        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
+
+    const char *interval_field_name = "interval";
+    PyObject *py_interval =
+        PyObject_GetAttrString(py_metrics_policy, interval_field_name);
+    if (!py_interval) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        interval_field_name);
         goto error;
     }
-    if (report_size_limit > UINT64_MAX) {
+
+    unsigned long long interval =
path: src/main/policy.c
user: login: dwelch-spike
body: This could be `uint32_t` right?
created_at: 2025-05-30T17:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
+        convert_pyobject_to_fixed_width_integer_type(py_report_size_limit,
+                                                     UINT64_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        report_size_limit_attr_name, "unsigned 64-bit integer");
         goto error;
     }
-    unsigned long long report_size_limit =
-        PyLong_AsUnsignedLongLong(py_report_size_limit);
-    if (report_size_limit == (unsigned long long)-1 && PyErr_Occurred()) {
-        PyErr_Clear();
-        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
+
+    const char *interval_field_name = "interval";
+    PyObject *py_interval =
+        PyObject_GetAttrString(py_metrics_policy, interval_field_name);
+    if (!py_interval) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        interval_field_name);
         goto error;
     }
-    if (report_size_limit > UINT64_MAX) {
+
+    unsigned long long interval =
+        convert_pyobject_to_fixed_width_integer_type(py_interval, UINT32_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        interval_field_name, "unsigned 32-bit integer");
         goto error;
     }
+    metrics_policy->interval = (uint32_t)interval;
 
-    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
-
-    const char *uint32_fields[] = {"interval", "latency_columns",
-                                   "latency_shift"};
-    uint32_t *uint32_ptrs[] = {&metrics_policy->interval,
-                               &metrics_policy->latency_columns,
-                               &metrics_policy->latency_shift};
+    const char *uint8_field_names[] = {"latency_columns", "latency_shift"};
+    uint8_t *field_refs[] = {&metrics_policy->latency_columns,
+                             &metrics_policy->latency_shift};
     for (unsigned long i = 0;
-         i < sizeof(uint32_fields) / sizeof(uint32_fields[0]); i++) {
-        PyObject *py_field_value =
-            PyObject_GetAttrString(py_metrics_policy, uint32_fields[i]);
-        if (!py_field_value) {
+         i < sizeof(uint8_field_names) / sizeof(uint8_field_names[0]); i++) {
+        PyObject *py_attr_value =
+            PyObject_GetAttrString(py_metrics_policy, uint8_field_names[i]);
+        if (!py_attr_value) {
             as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                            uint32_fields[i]);
+                            uint8_field_names[i]);
             goto error;
         }
 
-        // There's a helper function in the Python client wrapper code called
-        // get_uint32_value
-        // But we don't use it because it doesn't set which exact line
-        // an error occurs. It only returns an error code when it happens
-        if (!PyLong_CheckExact(py_field_value)) {
+        unsigned long long attr_value =
path: src/main/policy.c
user: login: dwelch-spike
body: `uint8_t`?
created_at: 2025-05-30T17:57:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -881,23 +919,57 @@ PyObject *create_py_node_from_as_node(as_error *error_p, struct as_node_s *node)
     PyObject_SetAttrString(py_node, "conns", py_conn_stats);
     Py_DECREF(py_conn_stats);
 
-    PyObject *py_error_count = PyLong_FromUnsignedLongLong(node->error_count);
-    PyObject_SetAttrString(py_node, "error_count", py_error_count);
-    Py_DECREF(py_error_count);
+    const char *const as_node_stats_attr_names[] = {
+        "error_count", "timeout_count", "key_busy_count"};
+    uint64_t as_node_stats_attr_values[] = {
+        node_stats.error_count,
+        node_stats.timeout_count,
+        node_stats.key_busy_count,
+    };
+    for (unsigned long i = 0; i < sizeof(as_node_stats_attr_values) /
+                                      sizeof(as_node_stats_attr_values[0]);
+         i++) {
+        PyObject *py_attr_value =
+            PyLong_FromUnsignedLongLong(as_node_stats_attr_values[i]);
+        if (!py_attr_value) {
+            goto error;
+        }
+        int retval = PyObject_SetAttrString(
+            py_node, as_node_stats_attr_names[i], py_attr_value);
+        Py_DECREF(py_attr_value);
+        if (retval == -1) {
+            goto error;
+        }
+    }
+
+    as_ns_metrics **ns_metrics = node->metrics;
+    PyObject *py_ns_metrics_list = PyList_New(node->metrics_size);
+    if (py_ns_metrics_list == NULL) {
+        goto error;
+    }
+    for (uint8_t i = 0; i < node->metrics_size; i++) {
+        PyObject *py_ns_metrics =
+            create_py_ns_metrics_from_as_ns_metrics(error_p, ns_metrics[0]);
+        if (!py_ns_metrics) {
+            goto loop_error;
+        }
path: src/main/conversions.c
user: login: dwelch-spike
body: Why is this in the loops? SHould ns_metrics[0] be ns_metrics[i] ?
created_at: 2025-05-30T18:07:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,13 +38,22 @@ class ConnectionStats:
     pass
 
 
-class NodeMetrics:
+class NamespaceMetrics:
     """
-    Each type of latency has a list of latency buckets.
+    Namespace metrics.
 
-    Latency bucket counts are cumulative and not reset on each metrics snapshot interval.
+    Each command group has its own histogram (i.e list of latency buckets).
+    Latency histogram counts are cumulative and not reset on each metrics snapshot interval.
 
     Attributes:
+        ns (str): namespace
+        bytes_in (int): Bytes received from the server.
+        bytes_out (int): Bytes sent to the server.
+        error_count (int): Command error count since node was initialized. If the error is retryable, multiple errors
path: aerospike_helpers/metrics/__init__.py
user: login: juliannguyen4
body: I think the description is still accurate because NamespaceMetrics are grouped into single Node classes. I verified this is correct with Brian Nichols
created_at: 2025-05-30T18:32:17Z
author_association: COLLABORATOR
diff_hunk: @@ -2885,29 +2958,45 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err,
     return err->code;
 }
 
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj)
+// TODO: There's a helper function in the Python client wrapper code called
+// get_uint32_value, but this can replace it.
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
+                                             unsigned long long max_bound)
 {
-    if (!PyLong_Check(pyobject)) {
-        PyErr_Format(PyExc_TypeError, "%s must be an integer",
-                     param_name_of_pyobj);
+    if (!PyLong_CheckExact(pyobject)) {
path: src/main/conversions.c
user: login: juliannguyen4
body: I did this because Python booleans are a subclass of integers: https://docs.python.org/3/c-api/bool.html

I agree -- I think it's safer in case an application is using a special subclass for integers. I'll make this change before merging
created_at: 2025-05-30T18:39:26Z
author_association: COLLABORATOR
diff_hunk: @@ -2885,29 +2958,45 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err,
     return err->code;
 }
 
-uint32_t convert_pyobject_to_uint32_t(PyObject *pyobject,
-                                      const char *param_name_of_pyobj)
+// TODO: There's a helper function in the Python client wrapper code called
+// get_uint32_value, but this can replace it.
+unsigned long long
+convert_pyobject_to_fixed_width_integer_type(PyObject *pyobject,
path: src/main/conversions.c
user: login: juliannguyen4
body: True, I think this would make this cleaner (we wouldn't have to cast every time we call `convert_pyobject_to_fixed_width_integer_type()`). I'll make this change
created_at: 2025-05-30T18:43:24Z
author_association: COLLABORATOR
diff_hunk: @@ -35,54 +36,67 @@ PyObject *AerospikeClient_EnableMetrics(AerospikeClient *self, PyObject *args,
     // Python Function Argument Parsing
     if (!PyArg_ParseTupleAndKeywords(args, kwds, "|O:enable_metrics", kwlist,
                                      &py_metrics_policy)) {
-        goto RAISE_EXCEPTION_WITHOUT_AS_ERROR;
+        return NULL;
     }
 
+    // To be passed into C client
     as_metrics_policy *metrics_policy_ref;
+    bool free_udata_as_py_listener_data = false;
+
     if (py_metrics_policy == NULL || py_metrics_policy == Py_None) {
         // Use C client's config metrics policy
         metrics_policy_ref = NULL;
     }
     else {
+        // Set a transaction-level metrics policy
         as_metrics_policy_init(&metrics_policy);
-        as_status status = set_as_metrics_policy_using_pyobject(
+        metrics_policy_ref = &metrics_policy;
+        int retval = set_as_metrics_policy_using_pyobject(
             &err, py_metrics_policy, &metrics_policy);
-        if (status != AEROSPIKE_OK) {
-            goto RAISE_EXCEPTION_USING_AS_ERROR;
+        if (retval != 0) {
+            goto CLEANUP_ON_ERROR;
         }
-        metrics_policy_ref = &metrics_policy;
     }
 
     // 2 scenarios:
-    // 1. If the user does not pass their own MetricsListeners object to client.enable_metrics(), udata is NULL
-    // 2. Otherwise, udata is non-NULL and set to heap-allocated PyListenerData
-    bool free_udata_as_py_listener_data =
+    // 1. If the user passes their own MetricsPolicy and MetricsListeners object to client.enable_metrics(), udata is NOT NULL and set to heap-allocated PyListenerData
+    // 2. Otherwise, udata is NULL.
+    free_udata_as_py_listener_data =
         metrics_policy_ref && metrics_policy.metrics_listeners.udata != NULL;
 
     Py_BEGIN_ALLOW_THREADS
     aerospike_enable_metrics(self->as, &err, metrics_policy_ref);
     Py_END_ALLOW_THREADS
 
+CLEANUP_ON_ERROR:
+    if (metrics_policy_ref) {
+        // This means we initialized metrics_policy earlier
+        as_metrics_policy_destroy(metrics_policy_ref);
+    }
+
     if (err.code != AEROSPIKE_OK) {
-        // In the above scenario #1, when udata is NULL before aerospike_enable_metrics() is called:
-        // It is possible for aerospike_enable_metrics() -> as_cluster_enable_metrics() -> as_metrics_writer_create()
-        // to fail before it assigns a heap allocated value to metrics_policy.metrics_listeners.udata while setting up
-        // the metrics writer.
-        // In that case, we don't want to free udata where udata is NULL
+        if (err.code == AEROSPIKE_METRICS_CONFLICT) {
+            as_log_warn(err.message);
+            as_error_reset(&err);
+            // Even though we aren't raising an exception, the C client's enable_metrics() failed
+            // so we still have to clean up udata now (see below)
+        }
+
         if (free_udata_as_py_listener_data) {
+            // This will be free'd later when metrics is disabled (like when client.close() is called)
             free_py_listener_data(
                 (PyListenerData *)metrics_policy.metrics_listeners.udata);
path: src/main/client/metrics.c
user: login: juliannguyen4
body: I moved it to a better location
created_at: 2025-05-30T18:54:50Z
author_association: COLLABORATOR
diff_hunk: @@ -1204,9 +1204,9 @@ set_as_metrics_listeners_using_pyobject(as_error *err,
 
 #define GET_ATTR_ERROR_MSG "Unable to fetch %s attribute"
 
-as_status
-set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
-                                     as_metrics_policy *metrics_policy)
+int set_as_metrics_policy_using_pyobject(as_error *err,
+                                         PyObject *py_metrics_policy,
path: src/main/policy.c
user: login: juliannguyen4
body: I did this because I was going to design `set_as_metrics_policy_using_pyobject()` to return an error either when:
1. `as_error err` was set by the Python client code.
2. A native Python exception was raised from a C-API call. `as_error err` would not be set in this case.

Eventually I backtracked and decided to always set as_error whenever an error occurred. That way the behavior is consistent throughout `set_as_metrics_policy_using_pyobject()`. but I didn't revert the return type to `int` since it still accomplishes the same thing
created_at: 2025-05-30T19:01:40Z
author_association: COLLABORATOR
diff_hunk: @@ -935,10 +935,10 @@ static int AerospikeClient_Type_Init(AerospikeClient *self, PyObject *args,
             goto RAISE_EXCEPTION_WITH_AS_ERROR;
         }
         else {
-            as_status status = set_as_metrics_policy_using_pyobject(
+            int retval = set_as_metrics_policy_using_pyobject(
                 &constructor_err, py_obj_metrics_policy,
                 &(config.policies.metrics));
-            if (status != AEROSPIKE_OK) {
+            if (retval != AEROSPIKE_OK) {
path: src/main/client/type.c
user: login: juliannguyen4
body: Explained in my comment below
created_at: 2025-05-30T19:02:06Z
author_association: COLLABORATOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
path: src/main/policy.c
user: login: juliannguyen4
body: Done
created_at: 2025-05-30T19:03:58Z
author_association: COLLABORATOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
+        convert_pyobject_to_fixed_width_integer_type(py_report_size_limit,
+                                                     UINT64_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        report_size_limit_attr_name, "unsigned 64-bit integer");
         goto error;
     }
-    unsigned long long report_size_limit =
-        PyLong_AsUnsignedLongLong(py_report_size_limit);
-    if (report_size_limit == (unsigned long long)-1 && PyErr_Occurred()) {
-        PyErr_Clear();
-        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
+
+    const char *interval_field_name = "interval";
+    PyObject *py_interval =
+        PyObject_GetAttrString(py_metrics_policy, interval_field_name);
+    if (!py_interval) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        interval_field_name);
         goto error;
     }
-    if (report_size_limit > UINT64_MAX) {
+
+    unsigned long long interval =
path: src/main/policy.c
user: login: juliannguyen4
body: Done
created_at: 2025-05-30T19:04:37Z
author_association: COLLABORATOR
diff_hunk: @@ -1237,102 +1237,156 @@ set_as_metrics_policy_using_pyobject(as_error *err, PyObject *py_metrics_policy,
         // Need to deallocate metrics listeners' udata
         goto error;
     }
-    if (!PyUnicode_Check(py_report_dir)) {
+    const char *report_dir = convert_pyobject_to_str(err, py_report_dir);
+    if (!report_dir) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
                         "report_dir", "str");
         goto error;
     }
-    const char *report_dir = PyUnicode_AsUTF8(py_report_dir);
     if (strlen(report_dir) >= sizeof(metrics_policy->report_dir)) {
         as_error_update(err, AEROSPIKE_ERR_PARAM,
                         "MetricsPolicy.report_dir must be less than 256 chars");
         goto error;
     }
     strcpy(metrics_policy->report_dir, report_dir);
 
+    const char *report_size_limit_attr_name = "report_size_limit";
     PyObject *py_report_size_limit =
-        PyObject_GetAttrString(py_metrics_policy, "report_size_limit");
+        PyObject_GetAttrString(py_metrics_policy, report_size_limit_attr_name);
     if (!py_report_size_limit) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                        "report_size_limit");
+                        report_size_limit_attr_name);
         goto error;
     }
-    if (!PyLong_CheckExact(py_report_size_limit)) {
+
+    unsigned long long report_size_limit =
+        convert_pyobject_to_fixed_width_integer_type(py_report_size_limit,
+                                                     UINT64_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        report_size_limit_attr_name, "unsigned 64-bit integer");
         goto error;
     }
-    unsigned long long report_size_limit =
-        PyLong_AsUnsignedLongLong(py_report_size_limit);
-    if (report_size_limit == (unsigned long long)-1 && PyErr_Occurred()) {
-        PyErr_Clear();
-        as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
+
+    const char *interval_field_name = "interval";
+    PyObject *py_interval =
+        PyObject_GetAttrString(py_metrics_policy, interval_field_name);
+    if (!py_interval) {
+        as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
+                        interval_field_name);
         goto error;
     }
-    if (report_size_limit > UINT64_MAX) {
+
+    unsigned long long interval =
+        convert_pyobject_to_fixed_width_integer_type(py_interval, UINT32_MAX);
+    if (PyErr_Occurred()) {
         as_error_update(err, AEROSPIKE_ERR_PARAM, INVALID_ATTR_TYPE_ERROR_MSG,
-                        "report_size_limit", "unsigned 64-bit integer");
+                        interval_field_name, "unsigned 32-bit integer");
         goto error;
     }
+    metrics_policy->interval = (uint32_t)interval;
 
-    metrics_policy->report_size_limit = (uint64_t)report_size_limit;
-
-    const char *uint32_fields[] = {"interval", "latency_columns",
-                                   "latency_shift"};
-    uint32_t *uint32_ptrs[] = {&metrics_policy->interval,
-                               &metrics_policy->latency_columns,
-                               &metrics_policy->latency_shift};
+    const char *uint8_field_names[] = {"latency_columns", "latency_shift"};
+    uint8_t *field_refs[] = {&metrics_policy->latency_columns,
+                             &metrics_policy->latency_shift};
     for (unsigned long i = 0;
-         i < sizeof(uint32_fields) / sizeof(uint32_fields[0]); i++) {
-        PyObject *py_field_value =
-            PyObject_GetAttrString(py_metrics_policy, uint32_fields[i]);
-        if (!py_field_value) {
+         i < sizeof(uint8_field_names) / sizeof(uint8_field_names[0]); i++) {
+        PyObject *py_attr_value =
+            PyObject_GetAttrString(py_metrics_policy, uint8_field_names[i]);
+        if (!py_attr_value) {
             as_error_update(err, AEROSPIKE_ERR_PARAM, GET_ATTR_ERROR_MSG,
-                            uint32_fields[i]);
+                            uint8_field_names[i]);
             goto error;
         }
 
-        // There's a helper function in the Python client wrapper code called
-        // get_uint32_value
-        // But we don't use it because it doesn't set which exact line
-        // an error occurs. It only returns an error code when it happens
-        if (!PyLong_CheckExact(py_field_value)) {
+        unsigned long long attr_value =
path: src/main/policy.c
user: login: juliannguyen4
body: Done
created_at: 2025-05-30T19:05:24Z
author_association: COLLABORATOR
diff_hunk: @@ -881,23 +919,57 @@ PyObject *create_py_node_from_as_node(as_error *error_p, struct as_node_s *node)
     PyObject_SetAttrString(py_node, "conns", py_conn_stats);
     Py_DECREF(py_conn_stats);
 
-    PyObject *py_error_count = PyLong_FromUnsignedLongLong(node->error_count);
-    PyObject_SetAttrString(py_node, "error_count", py_error_count);
-    Py_DECREF(py_error_count);
+    const char *const as_node_stats_attr_names[] = {
+        "error_count", "timeout_count", "key_busy_count"};
+    uint64_t as_node_stats_attr_values[] = {
+        node_stats.error_count,
+        node_stats.timeout_count,
+        node_stats.key_busy_count,
+    };
+    for (unsigned long i = 0; i < sizeof(as_node_stats_attr_values) /
+                                      sizeof(as_node_stats_attr_values[0]);
+         i++) {
+        PyObject *py_attr_value =
+            PyLong_FromUnsignedLongLong(as_node_stats_attr_values[i]);
+        if (!py_attr_value) {
+            goto error;
+        }
+        int retval = PyObject_SetAttrString(
+            py_node, as_node_stats_attr_names[i], py_attr_value);
+        Py_DECREF(py_attr_value);
+        if (retval == -1) {
+            goto error;
+        }
+    }
+
+    as_ns_metrics **ns_metrics = node->metrics;
+    PyObject *py_ns_metrics_list = PyList_New(node->metrics_size);
+    if (py_ns_metrics_list == NULL) {
+        goto error;
+    }
+    for (uint8_t i = 0; i < node->metrics_size; i++) {
+        PyObject *py_ns_metrics =
+            create_py_ns_metrics_from_as_ns_metrics(error_p, ns_metrics[0]);
+        if (!py_ns_metrics) {
+            goto loop_error;
+        }
path: src/main/conversions.c
user: login: juliannguyen4
body: Thanks for catching this. Fixed
created_at: 2025-05-30T19:06:56Z
author_association: COLLABORATOR

PR comments:
user: login: codecov-commenter
created_at: 2025-05-08T16:14:23Z
body: ## [Codecov](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike) Report
:x: Patch coverage is `86.66667%` with `28 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 81.79%. Comparing base ([`ce5e01a`](https://app.codecov.io/gh/aerospike/aerospike-client-python/commit/ce5e01ad98f70b07ca258449048ddd574d491c0d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike)) to head ([`5e15436`](https://app.codecov.io/gh/aerospike/aerospike-client-python/commit/5e15436fd97dd809c03abd452e127cd3a54359fb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike)).
:warning: Report is 248 commits behind head on dev.

| [Files with missing lines](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike) | Patch % | Lines |
|---|---|---|
| [src/main/conversions.c](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?src=pr&el=tree&filepath=src%2Fmain%2Fconversions.c&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike#diff-c3JjL21haW4vY29udmVyc2lvbnMuYw==) | 81.05% | [18 Missing :warning: ](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike) |
| [src/main/policy.c](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?src=pr&el=tree&filepath=src%2Fmain%2Fpolicy.c&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike#diff-c3JjL21haW4vcG9saWN5LmM=) | 87.65% | [10 Missing :warning: ](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike) |

<details><summary>Additional details and impacted files</summary>



```diff
@@            Coverage Diff             @@
##              dev     #773      +/-   ##
==========================================
+ Coverage   81.78%   81.79%   +0.01%     
==========================================
  Files          99       99              
  Lines       14552    14664     +112     
==========================================
+ Hits        11901    11995      +94     
- Misses       2651     2669      +18     
```
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aerospike/aerospike-client-python/pull/773?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aerospike).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: juliannguyen4
created_at: 2025-05-30T20:51:23Z
body: Build artifacts passes, massif usage looks ok. Valgrind shows no memory errors and no memory leaks from this PR
author_association: COLLABORATOR