enhance(sdk-rs): use usage report json schema as a source of truth (#7405)

This commit is contained in:
Arda TANRIKULU 2025-12-11 06:55:55 -05:00 committed by GitHub
parent 4183e55198
commit 24c099818e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 158 additions and 76 deletions

View file

@ -0,0 +1,6 @@
---
'hive-console-sdk-rs': patch
---
Use the JSON Schema specification of the usage reports directly to generate Rust structs as a source
of truth instead of manually written types

105
configs/cargo/Cargo.lock generated
View file

@ -2619,6 +2619,7 @@ dependencies = [
"md5",
"mockito",
"moka",
"regress",
"reqwest",
"reqwest-middleware",
"reqwest-retry 0.8.0",
@ -2629,6 +2630,7 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"typify",
]
[[package]]
@ -4438,7 +4440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"itertools 0.11.0",
"itertools 0.14.0",
"log",
"multimap 0.10.1",
"once_cell",
@ -4458,7 +4460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.11.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.108",
@ -4760,6 +4762,16 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "regress"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48"
dependencies = [
"hashbrown 0.16.0",
"memchr",
]
[[package]]
name = "reqwest"
version = "0.12.24"
@ -5124,6 +5136,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive 0.8.22",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "0.9.0"
@ -5144,12 +5168,24 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"schemars_derive 1.0.4",
"serde",
"serde_json",
"url",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.108",
]
[[package]]
name = "schemars_derive"
version = "1.0.4"
@ -5196,6 +5232,10 @@ name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@ -5300,6 +5340,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_tokenstream"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.108",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -6266,6 +6318,53 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "typify"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5bcc6f62eb1fa8aa4098f39b29f93dcb914e17158b76c50360911257aa629"
dependencies = [
"typify-impl",
"typify-macro",
]
[[package]]
name = "typify-impl"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1eb359f7ffa4f9ebe947fa11a1b2da054564502968db5f317b7e37693cb2240"
dependencies = [
"heck 0.5.0",
"log",
"proc-macro2",
"quote",
"regress",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"syn 2.0.108",
"thiserror 2.0.17",
"unicode-ident",
]
[[package]]
name = "typify-macro"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a"
dependencies = [
"proc-macro2",
"quote",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"serde_tokenstream",
"syn 2.0.108",
"typify-impl",
]
[[package]]
name = "ucd-trie"
version = "0.1.7"

View file

@ -363,6 +363,7 @@ target "apollo-router" {
contexts = {
router_pkg = "${PWD}/packages/libraries/router"
sdk_rs_pkg = "${PWD}/packages/libraries/sdk-rs"
usage_service = "${PWD}/packages/services/usage"
config = "${PWD}/configs/cargo"
}
args = {

View file

@ -22,6 +22,11 @@ COPY --from=router_pkg Cargo.toml /usr/src/router/
COPY --from=sdk_rs_pkg Cargo.toml /usr/src/sdk-rs/
COPY --from=config Cargo.lock /usr/src/router/
# Copy usage report schema
# `usage.rs` uses it with the path `../../services/usage/usage-report-v2.schema.json`
# So we need to place it accordingly
COPY --from=usage_service usage-report-v2.schema.json /usr/services/usage/
WORKDIR /usr/src/sdk-rs
# Get the dependencies cached, so we can use dummy input files so Cargo wont fail
RUN echo 'fn main() { println!(""); }' > ./src/main.rs

View file

@ -32,6 +32,8 @@ serde_json = "1"
moka = { version = "0.12.10", features = ["future", "sync"] }
sha2 = { version = "0.10.8", features = ["std"] }
tokio-util = "0.7.16"
typify = "0.5.0"
regress = "0.10.5"
[dev-dependencies]
mockito = "1.7.0"

View file

@ -3,65 +3,14 @@ use graphql_parser::schema::Document;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
collections::{hash_map::Entry, HashMap, VecDeque},
sync::{Arc, Mutex},
time::Duration,
};
use thiserror::Error;
use tokio_util::sync::CancellationToken;
#[derive(Serialize, Deserialize, Debug)]
pub struct Report {
size: usize,
map: HashMap<String, OperationMapRecord>,
operations: Vec<Operation>,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
struct OperationMapRecord {
operation: String,
#[serde(skip_serializing_if = "Option::is_none")]
operationName: Option<String>,
fields: Vec<String>,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
struct Operation {
operationMapKey: String,
timestamp: u64,
execution: Execution,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<Metadata>,
#[serde(skip_serializing_if = "Option::is_none")]
persistedDocumentHash: Option<String>,
}
#[allow(non_snake_case)]
#[derive(Serialize, Deserialize, Debug)]
struct Execution {
ok: bool,
duration: u128,
errorsTotal: usize,
}
#[derive(Serialize, Deserialize, Debug)]
struct Metadata {
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<ClientInfo>,
}
#[derive(Serialize, Deserialize, Debug)]
struct ClientInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ExecutionReport {
pub schema: Arc<Document<'static, String>>,
@ -76,6 +25,8 @@ pub struct ExecutionReport {
pub persisted_document_hash: Option<String>,
}
typify::import_types!(schema = "../../services/usage/usage-report-v2.schema.json");
#[derive(Debug, Default)]
pub struct Buffer(Mutex<VecDeque<ExecutionReport>>);
@ -201,6 +152,7 @@ impl UsageAgent {
size: 0,
map: HashMap::new(),
operations: Vec::new(),
subscription_operations: Vec::new(),
};
// iterate over reports and check if they are valid
@ -228,30 +180,42 @@ impl UsageAgent {
let metadata: Option<Metadata> =
if client_name.is_some() || client_version.is_some() {
Some(Metadata {
client: Some(ClientInfo {
name: client_name,
version: client_version,
client: Some(Client {
name: client_name.unwrap_or_default(),
version: client_version.unwrap_or_default(),
}),
})
} else {
None
};
report.operations.push(Operation {
operationMapKey: hash.clone(),
report.operations.push(RequestOperation {
operation_map_key: hash.clone(),
timestamp: op.timestamp,
execution: Execution {
ok: op.ok,
duration: op.duration.as_nanos(),
errorsTotal: op.errors,
/*
The conversion from u128 (from op.duration.as_nanos()) to u64 using try_into().unwrap() can panic if the duration is longer than u64::MAX nanoseconds (over 584 years).
While highly unlikely, it's safer to handle this potential overflow gracefully in library code to prevent panics.
A safe alternative is to convert the Result to an Option and provide a fallback value on failure,
effectively saturating at u64::MAX.
*/
duration: op
.duration
.as_nanos()
.try_into()
.ok()
.unwrap_or(u64::MAX),
errors_total: op.errors.try_into().unwrap(),
},
persistedDocumentHash: op.persisted_document_hash,
persisted_document_hash: op
.persisted_document_hash
.map(PersistedDocumentHash),
metadata,
});
if let std::collections::hash_map::Entry::Vacant(e) = report.map.entry(hash)
{
if let Entry::Vacant(e) = report.map.entry(ReportMapKey(hash)) {
e.insert(OperationMapRecord {
operation: operation.operation,
operationName: non_empty_string(op.operation_name),
operation_name: non_empty_string(op.operation_name),
fields: operation.coordinates,
});
}
@ -401,7 +365,7 @@ mod tests {
let record = report.map.values().next().expect("No operation record");
// operation
assert!(record.operation.contains("mutation deleteProject"));
assert_eq!(record.operationName.as_deref(), Some("deleteProject"));
assert_eq!(record.operation_name.as_deref(), Some("deleteProject"));
// fields
let expected_fields = vec![
"Mutation.deleteProject",
@ -434,11 +398,11 @@ mod tests {
let operation = &operations[0];
let key = report.map.keys().next().expect("No operation key");
assert_eq!(&operation.operationMapKey, key);
assert_eq!(operation.operation_map_key, key.0);
assert_eq!(operation.timestamp, timestamp);
assert_eq!(operation.execution.duration, duration.as_nanos());
assert_eq!(operation.execution.duration, duration.as_nanos() as u64);
assert_eq!(operation.execution.ok, true);
assert_eq!(operation.execution.errorsTotal, 0);
assert_eq!(operation.execution.errors_total, 0);
true
})
.expect(1)
@ -545,7 +509,7 @@ mod tests {
operation_name: Some("deleteProject".to_string()),
client_name: Some(GRAPHQL_CLIENT_NAME.to_string()),
client_version: Some(GRAPHQL_CLIENT_VERSION.to_string()),
timestamp,
timestamp: timestamp.try_into().unwrap(),
duration,
ok: true,
errors: 0,

View file

@ -5,7 +5,8 @@
"type": "object",
"properties": {
"size": {
"type": "integer"
"type": "integer",
"minimum": 0
},
"map": {
"type": "object",
@ -41,7 +42,8 @@
"type": "object",
"properties": {
"timestamp": {
"type": "integer"
"type": "integer",
"minimum": 0
},
"operationMapKey": {
"type": "string"
@ -55,10 +57,12 @@
"type": "boolean"
},
"duration": {
"type": "integer"
"type": "integer",
"minimum": 0
},
"errorsTotal": {
"type": "integer"
"type": "integer",
"minimum": 0
}
},
"required": ["ok", "duration", "errorsTotal"]
@ -101,7 +105,8 @@
"type": "object",
"properties": {
"timestamp": {
"type": "integer"
"type": "integer",
"minimum": 0
},
"operationMapKey": {
"type": "string"