From a04527c1354652bcba25a8f9e2e65c144d32fdb4 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 18 May 2026 21:55:02 -0400 Subject: [PATCH 1/2] Fix DX-5356 + add wire-inspection regressions for 5 deferred bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX-5356 — streams testFilter: filter_function is now required (String) instead of Option in TestFilterParams. The API rejects requests without it, so the type now matches the API contract. Mirrored across the Python pyo3 signature, Ruby hash validator, RBS sig, examples, and existing test fixtures. Adds 5 wiremock regression tests that capture the outgoing request body for the 4 silent-no-op / ignored-field bugs and confirm the SDK is sending the correct wire format. All 5 pass, meaning the failures are server-side (or upstream contract issues), not SDK bugs: - DX-5341: bulk_sets sends `add_sets` correctly - DX-5342: bulk_sets sends `delete_sets` correctly - DX-5343: update_list sends `add_items` and `remove_items` correctly - DX-5347: update_webhook_template sends `name` correctly - DX-5352: update_team_endpoints sends `endpoint_ids: []` (empty array) correctly when supplied with an empty Vec The wire-inspection tests are kept in the suite as durable regressions so future serde changes that drop or rename these fields fail loudly. --- crates/core/examples/streams_e2e.rs | 2 +- crates/core/src/admin/mod.rs | 23 ++++++++ crates/core/src/kvstore/mod.rs | 86 ++++++++++++++++++++++++++++- crates/core/src/streams/mod.rs | 4 +- crates/core/src/streams/stream.rs | 5 +- crates/core/src/webhooks/mod.rs | 29 ++++++++++ crates/python/src/lib.rs | 4 +- crates/ruby/src/lib.rs | 2 +- npm/index.d.ts | 4 +- python/sdk/_core/__init__.pyi | 2 +- ruby/sig/quicknode_sdk.rbs | 2 +- 11 files changed, 149 insertions(+), 14 deletions(-) diff --git a/crates/core/examples/streams_e2e.rs b/crates/core/examples/streams_e2e.rs index cc1ac40..b8f0272 100644 --- a/crates/core/examples/streams_e2e.rs +++ b/crates/core/examples/streams_e2e.rs @@ -33,7 +33,7 @@ async fn main() { network: "ethereum-mainnet".to_string(), dataset: StreamDataset::Block, block: "17811625".to_string(), - filter_function: Some("ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string()), + filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(), filter_language: None, address_book_config: None, }; diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 31b9927..6575d09 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -2932,6 +2932,29 @@ mod tests { assert!(resp.data.unwrap().success.unwrap()); } + // Wire-inspection regression for DX-5352: confirm an empty endpoint_ids + // array reaches the wire as `[]` (not omitted). If this passes, the empty- + // array silent no-op is server-side. + #[tokio::test] + async fn update_team_endpoints_empty_array_wire_body() { + use wiremock::matchers::body_json; + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/teams/1/endpoints")) + .and(body_json(serde_json::json!({ "endpoint_ids": [] }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"success": true}, + "error": null + }))) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let params = UpdateTeamEndpointsRequest { + endpoint_ids: vec![], + }; + sdk.admin.update_team_endpoints(1, ¶ms).await.unwrap(); + } + #[tokio::test] async fn invite_team_member_success() { let server = MockServer::start().await; diff --git a/crates/core/src/kvstore/mod.rs b/crates/core/src/kvstore/mod.rs index 965bdbc..c8beee0 100644 --- a/crates/core/src/kvstore/mod.rs +++ b/crates/core/src/kvstore/mod.rs @@ -681,7 +681,7 @@ impl KvStoreApiClient { mod tests { use super::*; use crate::{KvStoreConfig, QuicknodeSdk, SdkFullConfig}; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{body_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn make_sdk(base_url: String) -> QuicknodeSdk { @@ -938,6 +938,59 @@ mod tests { assert!(matches!(err, SdkError::Api { .. })); } + // Wire-inspection regressions for DX-5341 / DX-5342: confirm that addSets + // and deleteSets reach the wire under the names the API expects. If these + // pass, the silent no-op is server-side. + #[tokio::test] + async fn bulk_sets_wire_body_add_sets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sets/bulk")) + .and(body_json(serde_json::json!({ + "add_sets": {"k1": "v1"} + }))) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})), + ) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let mut add = HashMap::new(); + add.insert("k1".to_string(), "v1".to_string()); + sdk.kvstore + .bulk_sets(&BulkSetsParams { + add_sets: Some(add), + delete_sets: None, + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn bulk_sets_wire_body_delete_sets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sets/bulk")) + .and(body_json(serde_json::json!({ + "delete_sets": ["k1", "k2"] + }))) + .respond_with( + ResponseTemplate::new(201) + .set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})), + ) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + sdk.kvstore + .bulk_sets(&BulkSetsParams { + add_sets: None, + delete_sets: Some(vec!["k1".to_string(), "k2".to_string()]), + }) + .await + .unwrap(); + } + #[tokio::test] async fn delete_set_success() { let server = MockServer::start().await; @@ -1192,6 +1245,37 @@ mod tests { .unwrap(); } + // Wire-inspection regression for DX-5343: confirm addItems/removeItems + // reach the wire under the names the API expects. If this passes, the + // silent no-op is server-side. + #[tokio::test] + async fn update_list_wire_body() { + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/lists/my-list")) + .and(body_json(serde_json::json!({ + "add_items": ["c"], + "remove_items": ["a"] + }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})), + ) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + sdk.kvstore + .update_list( + "my-list", + &UpdateListParams { + add_items: Some(vec!["c".to_string()]), + remove_items: Some(vec!["a".to_string()]), + }, + ) + .await + .unwrap(); + } + #[tokio::test] async fn update_list_api_error() { let server = MockServer::start().await; diff --git a/crates/core/src/streams/mod.rs b/crates/core/src/streams/mod.rs index edb83be..5b107d2 100644 --- a/crates/core/src/streams/mod.rs +++ b/crates/core/src/streams/mod.rs @@ -944,7 +944,7 @@ mod tests { network: "ethereum-mainnet".to_string(), dataset: StreamDataset::Block, block: "17811625".to_string(), - filter_function: None, + filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(), filter_language: None, address_book_config: None, }; @@ -965,7 +965,7 @@ mod tests { network: "ethereum-mainnet".to_string(), dataset: StreamDataset::Block, block: "17811625".to_string(), - filter_function: None, + filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(), filter_language: None, address_book_config: None, }; diff --git a/crates/core/src/streams/stream.rs b/crates/core/src/streams/stream.rs index 156c923..7213cbe 100644 --- a/crates/core/src/streams/stream.rs +++ b/crates/core/src/streams/stream.rs @@ -806,9 +806,8 @@ pub struct TestFilterParams { pub dataset: StreamDataset, /// Specific block number to feed into the filter for the test. pub block: String, - /// Base64-encoded filter function to evaluate. - #[serde(skip_serializing_if = "Option::is_none")] - pub filter_function: Option, + /// Base64-encoded filter function to evaluate. Required by the API. To inspect raw block data with no transformation, supply a base64-encoded identity function such as `function main(d){return d;}`. + pub filter_function: String, /// Language the filter function is written in. #[serde(skip_serializing_if = "Option::is_none")] pub filter_language: Option, diff --git a/crates/core/src/webhooks/mod.rs b/crates/core/src/webhooks/mod.rs index dadf52c..77c4101 100644 --- a/crates/core/src/webhooks/mod.rs +++ b/crates/core/src/webhooks/mod.rs @@ -712,6 +712,35 @@ mod tests { assert_eq!(resp.id, "wh-1234-5678"); } + // Wire-inspection regression for DX-5347: confirm that `name` reaches the + // wire when supplied. If this passes, the "ignores name" behavior is + // server-side. + #[tokio::test] + async fn update_webhook_template_wire_body_includes_name() { + use wiremock::matchers::body_partial_json; + let server = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path_regex("/webhooks/test-id/template/evmWalletFilter")) + .and(body_partial_json(serde_json::json!({"name": "new-name"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json())) + .mount(&server) + .await; + let sdk = make_sdk(format!("{}/", server.uri())); + let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate { + wallets: vec!["0xabc".to_string()], + }); + let params = UpdateWebhookTemplateParams { + name: Some("new-name".to_string()), + notification_email: None, + destination_attributes: None, + template_args, + }; + sdk.webhooks + .update_webhook_template("test-id", ¶ms) + .await + .unwrap(); + } + #[tokio::test] async fn update_webhook_template_api_error() { let server = MockServer::start().await; diff --git a/crates/python/src/lib.rs b/crates/python/src/lib.rs index 8d792f5..ae0da52 100644 --- a/crates/python/src/lib.rs +++ b/crates/python/src/lib.rs @@ -1729,7 +1729,7 @@ impl StreamsApiClient { /// Runs a filter function against a specified block on a given network and /// dataset, returning the filter's output so it can be validated before /// being attached to a live stream. - #[pyo3(signature = (network, dataset, block, filter_function=None, filter_language=None))] + #[pyo3(signature = (network, dataset, block, filter_function, filter_language=None))] #[gen_stub(override_return_type( type_repr = "typing.Coroutine[typing.Any, typing.Any, TestFilterResponse]" ))] @@ -1739,7 +1739,7 @@ impl StreamsApiClient { network: String, dataset: String, block: String, - filter_function: Option, + filter_function: String, filter_language: Option, ) -> PyResult> { let client = self.inner.clone(); diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 75e5aca..4b49dac 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -1367,7 +1367,7 @@ impl StreamsApiClient { network: hash_require_string(&opts, "network")?, dataset, block: hash_require_string(&opts, "block")?, - filter_function: hash_get_string(&opts, "filter_function")?, + filter_function: hash_require_string(&opts, "filter_function")?, filter_language, address_book_config: None, }; diff --git a/npm/index.d.ts b/npm/index.d.ts index c4ebbbe..164a116 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -1420,8 +1420,8 @@ export interface TestFilterParams { dataset: StreamDataset /** Specific block number to feed into the filter for the test. */ block: string - /** Base64-encoded filter function to evaluate. */ - filterFunction?: string + /** Base64-encoded filter function to evaluate. Required by the API. To inspect raw block data with no transformation, supply a base64-encoded identity function such as `function main(d){return d;}`. */ + filterFunction: string /** Language the filter function is written in. */ filterLanguage?: FilterLanguage /** Address book linked to the filter, if any. */ diff --git a/python/sdk/_core/__init__.pyi b/python/sdk/_core/__init__.pyi index eb0dc58..307e8fd 100644 --- a/python/sdk/_core/__init__.pyi +++ b/python/sdk/_core/__init__.pyi @@ -5211,7 +5211,7 @@ class StreamsApiClient: r""" Pauses a stream by ID, halting delivery until it is activated again. """ - def test_filter(self, network: builtins.str, dataset: builtins.str, block: builtins.str, filter_function: typing.Optional[builtins.str] = None, filter_language: typing.Optional[builtins.str] = None) -> typing.Coroutine[typing.Any, typing.Any, TestFilterResponse]: + def test_filter(self, network: builtins.str, dataset: builtins.str, block: builtins.str, filter_function: builtins.str, filter_language: typing.Optional[builtins.str] = None) -> typing.Coroutine[typing.Any, typing.Any, TestFilterResponse]: r""" Runs a filter function against a specified block on a given network and dataset, returning the filter's output so it can be validated before diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs index 0ef4ab4..15a6f77 100644 --- a/ruby/sig/quicknode_sdk.rbs +++ b/ruby/sig/quicknode_sdk.rbs @@ -118,7 +118,7 @@ module QuicknodeSdk def delete_stream: (id: String) -> void def activate_stream: (id: String) -> void def pause_stream: (id: String) -> void - def test_filter: (network: String, dataset: String, block: String, ?filter_function: String, ?filter_language: String) -> untyped + def test_filter: (network: String, dataset: String, block: String, filter_function: String, ?filter_language: String) -> untyped def get_enabled_count: (?stream_type: String) -> untyped end From 60226749593be71a7fd2d3873daa5039a6da0c2d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 18 May 2026 22:41:08 -0400 Subject: [PATCH 2/2] Fix DX-5421: normalise EndpointMetric.tag to Vec The metrics endpoints return `tag` as either a plain string (single-axis series like `"total"` or `"p95"`) or a `[key, value]` tuple (multi-axis series like `["network", "arbitrum-mainnet"]`). The SDK declared `tag: String`, so multi-axis responses failed with DecodeError before returning to the caller. Change the field to `Vec` with a custom `tag_as_vec` deserializer that accepts both wire shapes (plain string normalises to a 1-element vec, tuple collects into N elements). Mirrors the `null_as_default` precedent in kvstore. Adds 5 unit tests for the deserializer plus a wiremock regression that replays the exact body shape from the bug report. Updates examples and READMEs across all four languages to reflect the array shape. Also adds a CLAUDE.md rule to keep Linear/PR identifiers out of code comments, and removes the 4 pre-existing DX-#### references that had crept into wire-inspection regression comments. --- CLAUDE.md | 1 + crates/core/README.md | 4 +- crates/core/examples/admin_e2e.rs | 24 ++++++- crates/core/src/admin/endpoint_metrics.rs | 82 ++++++++++++++++++++++- crates/core/src/admin/mod.rs | 54 +++++++++++++-- crates/core/src/kvstore/mod.rs | 12 ++-- crates/core/src/webhooks/mod.rs | 5 +- npm/README.md | 4 +- npm/examples/admin.ts | 7 ++ npm/index.d.ts | 8 ++- python/README.md | 4 +- python/examples/admin.py | 6 ++ python/sdk/_core/__init__.pyi | 12 ++-- ruby/README.md | 4 +- ruby/examples/admin_e2e.rb | 6 +- 15 files changed, 198 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f51271..d35c3fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,3 +197,4 @@ Core clients are tested using mocked API calls with wiremock. All functions maki ### Comments - When doing anything out of the ordinary or breaking conventions or patterns, add a comment explaining the "why" behind it +- Do not reference Linear issue IDs (e.g. `DX-1234`), PR numbers, ticket URLs, or other internal tracking identifiers in code comments. The "why" should be self-contained — describe the constraint or behaviour itself, not where to look it up. Internal context belongs in the commit message or PR description. diff --git a/crates/core/README.md b/crates/core/README.md index 7983c37..3a2bdb0 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -836,7 +836,7 @@ Returns metric series for an endpoint over a time period. **Parameters**: `id` (endpoint id, required); body: `period` (`"hour"` | `"day"` | `"week"` | `"month"`), `metric` (e.g. `"method_calls_over_time"`, `"response_status_breakdown"`). -**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetEndpointMetricsResponse` with `data: Vec`. Each `EndpointMetric` has `tag: Vec` and `data: Vec>` of `[timestamp, value]` pairs. Single-axis series (e.g. `response_time_over_time` with a percentile) come back as a one-element tag like `vec!["p95"]`; multi-axis series come back as `vec!["network", "arbitrum-mainnet"]`. ```rust // Rust @@ -853,7 +853,7 @@ Returns account-level metric series. Supports an optional `percentile` (e.g. `"p **Parameters**: `period` (required), `metric` (required), `percentile` (string, optional). -**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetAccountMetricsResponse` with `data: Vec`. See `get_endpoint_metrics` above for the `tag: Vec` shape. ```rust // Rust diff --git a/crates/core/examples/admin_e2e.rs b/crates/core/examples/admin_e2e.rs index 542c2ac..e78496d 100644 --- a/crates/core/examples/admin_e2e.rs +++ b/crates/core/examples/admin_e2e.rs @@ -97,7 +97,17 @@ async fn main() { }) .await { - Ok(resp) => println!("get_account_metrics: {} series", resp.data.len()), + Ok(resp) => { + let first = resp + .data + .first() + .map(|m| m.tag.join(":")) + .unwrap_or_else(|| "".to_string()); + println!( + "get_account_metrics: {} series, first tag: {first}", + resp.data.len() + ); + } Err(e) => eprintln!("get_account_metrics error: {e}"), } @@ -254,7 +264,17 @@ async fn main() { ) .await { - Ok(resp) => println!("get_endpoint_metrics: {} series", resp.data.len()), + Ok(resp) => { + let first = resp + .data + .first() + .map(|m| m.tag.join(":")) + .unwrap_or_else(|| "".to_string()); + println!( + "get_endpoint_metrics: {} series, first tag: {first}", + resp.data.len() + ); + } Err(e) => eprintln!("get_endpoint_metrics error: {e}"), } diff --git a/crates/core/src/admin/endpoint_metrics.rs b/crates/core/src/admin/endpoint_metrics.rs index 74979b0..d69bdfc 100644 --- a/crates/core/src/admin/endpoint_metrics.rs +++ b/crates/core/src/admin/endpoint_metrics.rs @@ -4,7 +4,34 @@ use napi_derive::napi; use pyo3::pyclass; #[cfg(feature = "python")] use pyo3_stub_gen::derive::gen_stub_pyclass; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; + +// The metrics endpoints return `tag` as either a plain string (single-axis +// series like `"total"` or `"p95"`) or a tuple like `["network", "mainnet"]` +// (multi-axis series). Normalise both to a `Vec` so callers always +// see an array. +fn tag_as_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::String(s) => Ok(vec![s]), + serde_json::Value::Array(items) => items + .into_iter() + .map(|v| match v { + serde_json::Value::String(s) => Ok(s), + other => Err(D::Error::custom(format!( + "expected string in tag array, got {other}" + ))), + }) + .collect(), + serde_json::Value::Null => Ok(Vec::new()), + other => Err(D::Error::custom(format!( + "expected string or array of strings for tag, got {other}" + ))), + } +} /// Parameters for `get_endpoint_metrics`. #[cfg_attr(feature = "python", gen_stub_pyclass)] @@ -41,8 +68,11 @@ pub struct GetAccountMetricsRequest { pub struct EndpointMetric { /// Data points, each as `[timestamp, value]`. pub data: Vec>, - /// Human-readable tag identifying the series. - pub tag: String, + /// Tag identifying the series. Single-axis metrics return a one-element + /// vector (e.g. `["total"]`, `["p95"]`); multi-axis metrics return the + /// key/value pair (e.g. `["network", "arbitrum-mainnet"]`). + #[serde(deserialize_with = "tag_as_vec")] + pub tag: Vec, } /// Response from `get_endpoint_metrics`. @@ -70,3 +100,49 @@ pub struct GetAccountMetricsResponse { /// Error message when the request did not succeed. pub error: Option, } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::EndpointMetric; + + #[test] + fn tag_deserializes_from_string() { + let m: EndpointMetric = + serde_json::from_str(r#"{"data": [[1, 2]], "tag": "total"}"#).unwrap(); + assert_eq!(m.tag, vec!["total".to_string()]); + } + + #[test] + fn tag_deserializes_from_tuple() { + let m: EndpointMetric = + serde_json::from_str(r#"{"data": [[1, 2]], "tag": ["network", "arbitrum-mainnet"]}"#) + .unwrap(); + assert_eq!( + m.tag, + vec!["network".to_string(), "arbitrum-mainnet".to_string()] + ); + } + + #[test] + fn tag_deserializes_from_null() { + let m: EndpointMetric = serde_json::from_str(r#"{"data": [[1, 2]], "tag": null}"#).unwrap(); + assert!(m.tag.is_empty()); + } + + #[test] + fn tag_rejects_mixed_array() { + let err = + serde_json::from_str::(r#"{"data": [], "tag": ["x", 5]}"#).unwrap_err(); + assert!(err.to_string().contains("expected string in tag array")); + } + + #[test] + fn tag_rejects_object() { + let err = serde_json::from_str::(r#"{"data": [], "tag": {"k": "v"}}"#) + .unwrap_err(); + assert!(err + .to_string() + .contains("expected string or array of strings for tag")); + } +} diff --git a/crates/core/src/admin/mod.rs b/crates/core/src/admin/mod.rs index 6575d09..42ec3b2 100644 --- a/crates/core/src/admin/mod.rs +++ b/crates/core/src/admin/mod.rs @@ -2638,7 +2638,7 @@ mod tests { Mock::given(method("GET")) .and(path("/endpoints/ep123/metrics")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": [{"data": [[1700000000, 42]], "tag": "mainnet"}], + "data": [{"data": [[1700000000, 42]], "tag": ["network", "mainnet"]}], "error": null }))) .mount(&server) @@ -2655,7 +2655,10 @@ mod tests { .await .unwrap(); assert_eq!(resp.data.len(), 1); - assert_eq!(resp.data[0].tag, "mainnet"); + assert_eq!( + resp.data[0].tag, + vec!["network".to_string(), "mainnet".to_string()] + ); } #[tokio::test] @@ -2679,6 +2682,47 @@ mod tests { }; let resp = sdk.admin.get_account_metrics(¶ms).await.unwrap(); assert_eq!(resp.data.len(), 1); + assert_eq!(resp.data[0].tag, vec!["total".to_string()]); + } + + // Regression: the metrics endpoints return `tag` as either a plain string + // (single-axis series) or a `[key, value]` tuple (multi-axis series). + // Exercise both shapes so any future serde change that breaks either + // branch fails loudly. + #[tokio::test] + async fn get_account_metrics_decodes_tuple_tag() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/metrics")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [ + {"tag": ["network", "arbitrum-mainnet"], "data": [[1779109200, 40]]}, + {"tag": ["network", "mainnet"], "data": [[1779116400, 40]]}, + {"tag": "p95", "data": [[1779116400, 12]]} + ], + "error": null + }))) + .mount(&server) + .await; + + let sdk = make_sdk(format!("{}/", server.uri())); + let params = GetAccountMetricsRequest { + period: "day".to_string(), + metric: "credits_over_time".to_string(), + percentile: None, + }; + let resp = sdk.admin.get_account_metrics(¶ms).await.unwrap(); + assert_eq!(resp.data.len(), 3); + assert_eq!( + resp.data[0].tag, + vec!["network".to_string(), "arbitrum-mainnet".to_string()] + ); + assert_eq!( + resp.data[1].tag, + vec!["network".to_string(), "mainnet".to_string()] + ); + assert_eq!(resp.data[2].tag, vec!["p95".to_string()]); } #[tokio::test] @@ -2932,9 +2976,9 @@ mod tests { assert!(resp.data.unwrap().success.unwrap()); } - // Wire-inspection regression for DX-5352: confirm an empty endpoint_ids - // array reaches the wire as `[]` (not omitted). If this passes, the empty- - // array silent no-op is server-side. + // Wire-inspection regression: confirm an empty endpoint_ids array reaches + // the wire as `[]` (not omitted), so any future `skip_serializing_if` + // change that drops the empty case fails loudly. #[tokio::test] async fn update_team_endpoints_empty_array_wire_body() { use wiremock::matchers::body_json; diff --git a/crates/core/src/kvstore/mod.rs b/crates/core/src/kvstore/mod.rs index c8beee0..94172b9 100644 --- a/crates/core/src/kvstore/mod.rs +++ b/crates/core/src/kvstore/mod.rs @@ -938,9 +938,9 @@ mod tests { assert!(matches!(err, SdkError::Api { .. })); } - // Wire-inspection regressions for DX-5341 / DX-5342: confirm that addSets - // and deleteSets reach the wire under the names the API expects. If these - // pass, the silent no-op is server-side. + // Wire-inspection regressions: confirm that addSets and deleteSets reach + // the wire under the names the API expects, so any future serde rename of + // these fields fails loudly. #[tokio::test] async fn bulk_sets_wire_body_add_sets() { let server = MockServer::start().await; @@ -1245,9 +1245,9 @@ mod tests { .unwrap(); } - // Wire-inspection regression for DX-5343: confirm addItems/removeItems - // reach the wire under the names the API expects. If this passes, the - // silent no-op is server-side. + // Wire-inspection regression: confirm addItems/removeItems reach the wire + // under the names the API expects, so any future serde rename of these + // fields fails loudly. #[tokio::test] async fn update_list_wire_body() { let server = MockServer::start().await; diff --git a/crates/core/src/webhooks/mod.rs b/crates/core/src/webhooks/mod.rs index 77c4101..0bbd919 100644 --- a/crates/core/src/webhooks/mod.rs +++ b/crates/core/src/webhooks/mod.rs @@ -712,9 +712,8 @@ mod tests { assert_eq!(resp.id, "wh-1234-5678"); } - // Wire-inspection regression for DX-5347: confirm that `name` reaches the - // wire when supplied. If this passes, the "ignores name" behavior is - // server-side. + // Wire-inspection regression: confirm that `name` reaches the wire when + // supplied so any future serde rename/drop of the field fails loudly. #[tokio::test] async fn update_webhook_template_wire_body_includes_name() { use wiremock::matchers::body_partial_json; diff --git a/npm/README.md b/npm/README.md index 462eec6..9b0e694 100644 --- a/npm/README.md +++ b/npm/README.md @@ -796,7 +796,7 @@ Returns metric series for an endpoint over a time period. **Parameters**: `id` (endpoint id, required); body: `period` (`"hour"` | `"day"` | `"week"` | `"month"`), `metric` (e.g. `"method_calls_over_time"`, `"response_status_breakdown"`). -**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`. Each `EndpointMetric` has a `tag: string[]` and a `data: [timestamp, value][]`. Single-axis series (e.g. `response_time_over_time` with a percentile) come back as a one-element tag like `["p95"]`; multi-axis series come back as `["network", "arbitrum-mainnet"]`. ```typescript // Node.js @@ -812,7 +812,7 @@ Returns account-level metric series. Supports an optional `percentile` (e.g. `"p **Parameters**: `period` (required), `metric` (required), `percentile` (string, optional). -**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`. See `getEndpointMetrics` above for the `tag: string[]` shape. ```typescript // Node.js diff --git a/npm/examples/admin.ts b/npm/examples/admin.ts index 405684b..a36f418 100644 --- a/npm/examples/admin.ts +++ b/npm/examples/admin.ts @@ -29,6 +29,13 @@ async function main() { console.log(`account tags: ${tags.data.tags.length}`); } + const metrics = await qn.admin.getAccountMetrics({ + period: "day", + metric: "credits_over_time", + }); + const firstTag = metrics.data[0]?.tag.join(":") ?? ""; + console.log(`getAccountMetrics: ${metrics.data.length} series, first tag: ${firstTag}`); + if (response.data.length > 0) { const sec = await qn.admin.getEndpointSecurity(response.data[0].id); console.log(`getEndpointSecurity: has_data=${sec.data !== undefined && sec.data !== null}`); diff --git a/npm/index.d.ts b/npm/index.d.ts index 164a116..91db289 100644 --- a/npm/index.d.ts +++ b/npm/index.d.ts @@ -208,8 +208,12 @@ export interface GetAccountMetricsRequest { export interface EndpointMetric { /** Data points, each as `[timestamp, value]`. */ data: Array> - /** Human-readable tag identifying the series. */ - tag: string + /** + * Tag identifying the series. Single-axis metrics return a one-element + * vector (e.g. `["total"]`, `["p95"]`); multi-axis metrics return the + * key/value pair (e.g. `["network", "arbitrum-mainnet"]`). + */ + tag: Array } /** Response from `get_endpoint_metrics`. */ export interface GetEndpointMetricsResponse { diff --git a/python/README.md b/python/README.md index 4aafae0..c7a62c4 100644 --- a/python/README.md +++ b/python/README.md @@ -802,7 +802,7 @@ Returns metric series for an endpoint over a time period. **Parameters**: `id` (endpoint id, required); body: `period` (`"hour"` | `"day"` | `"week"` | `"month"`), `metric` (e.g. `"method_calls_over_time"`, `"response_status_breakdown"`). -**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetEndpointMetricsResponse` with `data: list[EndpointMetric]`. Each `EndpointMetric` has a `tag: list[str]` and a `data: list[list[int]]` of `[timestamp, value]` pairs. Single-axis series (e.g. `response_time_over_time` with a percentile) come back as a one-element tag like `["p95"]`; multi-axis series come back as `["network", "arbitrum-mainnet"]`. ```python # Python @@ -819,7 +819,7 @@ Returns account-level metric series. Supports an optional `percentile` (e.g. `"p **Parameters**: `period` (required), `metric` (required), `percentile` (string, optional). -**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetAccountMetricsResponse` with `data: list[EndpointMetric]`. See `get_endpoint_metrics` above for the `tag: list[str]` shape. ```python # Python diff --git a/python/examples/admin.py b/python/examples/admin.py index bc83ace..842a108 100644 --- a/python/examples/admin.py +++ b/python/examples/admin.py @@ -32,6 +32,12 @@ async def main(): if tags.data is not None: print(f"account tags: {len(tags.data.tags)}") + metrics = await qn.admin.get_account_metrics( + period="day", metric="credits_over_time" + ) + first_tag = ":".join(metrics.data[0].tag) if metrics.data else "" + print(f"get_account_metrics: {len(metrics.data)} series, first tag: {first_tag}") + if response.data: sec = await qn.admin.get_endpoint_security(response.data[0].id) print(f"get_endpoint_security: has_data={sec.data is not None}") diff --git a/python/sdk/_core/__init__.pyi b/python/sdk/_core/__init__.pyi index 307e8fd..4e76384 100644 --- a/python/sdk/_core/__init__.pyi +++ b/python/sdk/_core/__init__.pyi @@ -2024,14 +2024,18 @@ class EndpointMetric: Data points, each as `[timestamp, value]`. """ @property - def tag(self) -> builtins.str: + def tag(self) -> builtins.list[builtins.str]: r""" - Human-readable tag identifying the series. + Tag identifying the series. Single-axis metrics return a one-element + vector (e.g. `["total"]`, `["p95"]`); multi-axis metrics return the + key/value pair (e.g. `["network", "arbitrum-mainnet"]`). """ @tag.setter - def tag(self, value: builtins.str) -> None: + def tag(self, value: builtins.list[builtins.str]) -> None: r""" - Human-readable tag identifying the series. + Tag identifying the series. Single-axis metrics return a one-element + vector (e.g. `["total"]`, `["p95"]`); multi-axis metrics return the + key/value pair (e.g. `["network", "arbitrum-mainnet"]`). """ @typing.final diff --git a/ruby/README.md b/ruby/README.md index e70b1e4..bb5c938 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -791,7 +791,7 @@ Returns metric series for an endpoint over a time period. **Parameters**: `id` (endpoint id, required); body: `period` (`"hour"` | `"day"` | `"week"` | `"month"`), `metric` (e.g. `"method_calls_over_time"`, `"response_status_breakdown"`). -**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetEndpointMetricsResponse` (as a `Hashie::Mash`) with `data: Array`. Each `EndpointMetric` has `tag: Array` and `data: Array>` of `[timestamp, value]` pairs. Single-axis series (e.g. `response_time_over_time` with a percentile) come back as a one-element tag like `["p95"]`; multi-axis series come back as `["network", "arbitrum-mainnet"]`. ```ruby # Ruby @@ -808,7 +808,7 @@ Returns account-level metric series. Supports an optional `percentile` (e.g. `"p **Parameters**: `period` (required), `metric` (required), `percentile` (string, optional). -**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`. +**Returns**: `GetAccountMetricsResponse` (as a `Hashie::Mash`) with `data: Array`. See `get_endpoint_metrics` above for the `tag: Array` shape. ```ruby # Ruby diff --git a/ruby/examples/admin_e2e.rb b/ruby/examples/admin_e2e.rb index 2fb2cf3..722fc74 100644 --- a/ruby/examples/admin_e2e.rb +++ b/ruby/examples/admin_e2e.rb @@ -56,7 +56,8 @@ puts "list_tags: #{resp.dig(:data, :tags)&.length || 0} tags" resp = qn.admin.get_account_metrics(period: "day", metric: "requests") -puts "get_account_metrics: #{resp[:data].length} series" +first_tag = resp[:data].first&.dig(:tag)&.join(":") || "" +puts "get_account_metrics: #{resp[:data].length} series, first tag: #{first_tag}" resp = qn.admin.list_invoices puts "list_invoices: #{resp[:data].inspect}" @@ -122,7 +123,8 @@ sleep 1 resp = qn.admin.get_endpoint_metrics(id: endpoint_id, period: "day", metric: "credits_over_time") -puts "get_endpoint_metrics: #{resp[:data].length} series" +first_tag = resp[:data].first&.dig(:tag)&.join(":") || "" +puts "get_endpoint_metrics: #{resp[:data].length} series, first tag: #{first_tag}" # ── Security options ──────────────────────────────────────────────────────────