fix(sdk-rs): send headers correctly for usage reporting (#7364)

This commit is contained in:
Arda TANRIKULU 2025-12-03 19:37:28 +03:00 committed by GitHub
parent 6adbbb26f3
commit 69e2f74ab8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 430 additions and 58 deletions

View file

@ -0,0 +1,5 @@
---
'hive-console-sdk-rs': patch
---
Fix the bug where reports were not being sent correctly due to missing headers

View file

@ -32,9 +32,43 @@ jobs:
echo 'rust_changed=true' >> $GITHUB_OUTPUT
fi
publish-rust:
test-rust:
needs: detect-changes
if: needs.detect-changes.outputs.rust_changed == 'true'
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
- name: setup environment
uses: ./.github/actions/setup
with:
actor: test-rust
codegen: false
- name: Install Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
with:
toolchain: '1.90.0'
default: true
override: true
- name: Cache Rust
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
- name: Run tests
run: cargo test
publish-rust:
needs: [detect-changes, test-rust]
if: needs.detect-changes.outputs.rust_changed == 'true'
strategy:
fail-fast: false
matrix:

View file

@ -138,3 +138,69 @@ jobs:
cliVersion: ${{ needs.extract-cli-version.outputs.version }}
publishLatest: false
secrets: inherit
cargo:
needs: [npm]
runs-on: ubuntu-22.04
permissions:
pull-requests: write
actions: write
contents: write
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 2
token: ${{ secrets.BOT_GITHUB_TOKEN }}
- name: setup environment
uses: ./.github/actions/setup
with:
codegen: false # no need to run because release script will run it anyway
actor: alpha-rust
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: extract published plugin version
id: plugin-crate
if:
needs.npm.outputs.published && contains(needs.npm.outputs.publishedPackages,
'"hive-apollo-router-plugin"')
run: |
cargo install set-cargo-version
echo '${{needs.npm.outputs.publishedPackages}}' > published.json
VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-apollo-router-plugin")).version' published.json)`
set-cargo-version ./packages/libraries/router/Cargo.toml $VERSION
- name: extract published sdk version
id: sdk-crate
if:
needs.npm.outputs.published && contains(needs.npm.outputs.publishedPackages,
'"hive-console-sdk-rs"')
run: |
cargo install set-cargo-version
echo '${{needs.npm.outputs.publishedPackages}}' > published.json
VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-console-sdk-rs")).version' published.json)`
set-cargo-version ./packages/libraries/sdk-rs/Cargo.toml $VERSION
echo "crate_publish=true" >> $GITHUB_OUTPUT
# Find and replace the version of "hive-console-sdk" dependency in router Cargo.toml
if [ -f ./packages/libraries/router/Cargo.toml ]; then
SDK_VERSION_LINE=$(grep -n 'hive-console-sdk' ./packages/libraries/router/Cargo.toml | head -n 1 | cut -d: -f1)
if [ ! -z "$SDK_VERSION_LINE" ]; then
sed -i "${SDK_VERSION_LINE}s/version = \".*\"/version = \"$VERSION\"/" ./packages/libraries/router/Cargo.toml
fi
fi
- name: release to Crates.io
if:
steps.sdk-crate.outputs.crate_publish == 'true' ||
steps.plugin-crate.outputs.crate_publish == 'true'
run: |
cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }}
cargo publish --allow-dirty --no-verify

View file

@ -112,19 +112,10 @@ jobs:
VERSION: ${{ steps.cli.outputs.version }}
run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION
- name: extract published Crate version
id: rust-crate
- name: release to Crates.io
if:
steps.changesets.outputs.published && contains(steps.changesets.outputs.publishedPackages,
'"hive-apollo-router-plugin"')
run: |
echo '${{steps.changesets.outputs.publishedPackages}}' > published.json
VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-apollo-router-plugin")).version' published.json)`
echo "crate_version=$VERSION" >> $GITHUB_OUTPUT
echo "crate_publish=true" >> $GITHUB_OUTPUT
- name: release to Crates.io
if: steps.rust-crate.outputs.crate_publish == 'true'
run: |
cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }}
cargo publish --allow-dirty --no-verify

View file

@ -1326,6 +1326,15 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colored"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -2625,7 +2634,7 @@ dependencies = [
"rand 0.9.2",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"reqwest-retry 0.7.0",
"schemars 1.0.4",
"serde",
"serde_json",
@ -2646,10 +2655,11 @@ dependencies = [
"graphql-parser",
"graphql-tools",
"md5",
"mockito",
"moka",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"reqwest-retry 0.8.0",
"serde",
"serde_json",
"sha2",
@ -3590,6 +3600,31 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "mockito"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.2",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "moka"
version = "0.12.11"
@ -4451,7 +4486,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",
@ -4471,7 +4506,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",
@ -4846,13 +4881,34 @@ dependencies = [
"parking_lot 0.11.2",
"reqwest",
"reqwest-middleware",
"retry-policies",
"retry-policies 0.4.0",
"thiserror 1.0.69",
"tokio",
"tracing",
"wasm-timer",
]
[[package]]
name = "reqwest-retry"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "105747e3a037fe5bf17458d794de91149e575b6183fc72c85623a44abb9683f5"
dependencies = [
"anyhow",
"async-trait",
"futures",
"getrandom 0.2.16",
"http 1.3.1",
"hyper 1.7.0",
"reqwest",
"reqwest-middleware",
"retry-policies 0.5.1",
"thiserror 2.0.17",
"tokio",
"tracing",
"wasmtimer",
]
[[package]]
name = "resolv-conf"
version = "0.7.5"
@ -4868,6 +4924,15 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "retry-policies"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be"
dependencies = [
"rand 0.9.2",
]
[[package]]
name = "rhai"
version = "1.21.0"
@ -6365,13 +6430,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.18.1"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde",
"serde_core",
"wasm-bindgen",
]
@ -6530,6 +6595,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasmtimer"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b"
dependencies = [
"futures",
"js-sys",
"parking_lot 0.12.5",
"pin-utils",
"slab",
"wasm-bindgen",
]
[[package]]
name = "web-sys"
version = "0.3.82"

View file

@ -432,11 +432,14 @@ mod hive_usage_tests {
plugin::{test::MockSupergraphService, Plugin, PluginInit},
services::supergraph,
};
use http::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use httpmock::{Method::POST, Mock, MockServer};
use jsonschema::Validator;
use serde_json::json;
use tower::ServiceExt;
use crate::consts::PLUGIN_VERSION;
use super::{Config, UsagePlugin};
lazy_static::lazy_static! {
@ -481,17 +484,26 @@ mod hive_usage_tests {
fn activate_usage_mock(&'_ self) -> Mock<'_> {
self.mocked_upstream.mock(|when, then| {
when.method(POST).path("/usage").matches(|r| {
// This mock also validates that the content of the reported usage is valid
// when it comes to the JSON schema validation.
// if it does not match, the request matching will fail and this will lead
// to a failed assertion
let body = r.body.as_ref().unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
let body = serde_json::from_str(&body).unwrap();
when.method(POST)
.path("/usage")
.header(CONTENT_TYPE.as_str(), "application/json")
.header(
USER_AGENT.as_str(),
format!("hive-apollo-router/{}", PLUGIN_VERSION),
)
.header(AUTHORIZATION.as_str(), "Bearer 123")
.header("X-Usage-API-Version", "2")
.matches(|r| {
// This mock also validates that the content of the reported usage is valid
// when it comes to the JSON schema validation.
// if it does not match, the request matching will fail and this will lead
// to a failed assertion
let body = r.body.as_ref().unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
let body = serde_json::from_str(&body).unwrap();
SCHEMA_VALIDATOR.is_valid(&body)
});
SCHEMA_VALIDATOR.is_valid(&body)
});
then.status(200);
})
}
@ -576,21 +588,4 @@ mod hive_usage_tests {
mock.assert();
mock.assert_hits(1);
}
#[tokio::test]
async fn invalid_query_reported() {
let instance = UsageTestHelper::new().await;
let req = supergraph::Request::fake_builder()
.query("query {")
.build()
.unwrap();
let mock = instance.activate_usage_mock();
instance.execute_operation(req).await.next_response().await;
instance.wait_for_processing().await;
mock.assert();
mock.assert_hits(1);
}
}

View file

@ -15,13 +15,12 @@ path = "src/lib.rs"
async-trait = "0.1.77"
axum-core = "0.5"
thiserror = "2.0.11"
reqwest = { version = "0.12.0", default-features = false, features = [
reqwest = { version = "0.12.24", default-features = false, features = [
"rustls-tls",
"blocking",
"json",
] }
reqwest-retry = "0.7.0"
reqwest-middleware = "0.4.0"
reqwest-retry = "0.8.0"
reqwest-middleware = "0.4.2"
anyhow = "1"
tracing = "0.1"
serde = "1"
@ -34,3 +33,5 @@ moka = { version = "0.12.10", features = ["future", "sync"] }
sha2 = { version = "0.10.8", features = ["std"] }
tokio-util = "0.7.16"
[dev-dependencies]
mockito = "1.7.0"

View file

@ -3,7 +3,7 @@ use graphql_parser::schema::Document;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
@ -12,7 +12,7 @@ use std::{
use thiserror::Error;
use tokio_util::sync::CancellationToken;
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Report {
size: usize,
map: HashMap<String, OperationMapRecord>,
@ -20,7 +20,7 @@ pub struct Report {
}
#[allow(non_snake_case)]
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
struct OperationMapRecord {
operation: String,
#[serde(skip_serializing_if = "Option::is_none")]
@ -29,7 +29,7 @@ struct OperationMapRecord {
}
#[allow(non_snake_case)]
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
struct Operation {
operationMapKey: String,
timestamp: u64,
@ -41,20 +41,20 @@ struct Operation {
}
#[allow(non_snake_case)]
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
struct Execution {
ok: bool,
duration: u128,
errorsTotal: usize,
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
struct Metadata {
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<ClientInfo>,
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
struct ClientInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
@ -114,7 +114,7 @@ pub struct UsageAgent {
}
fn non_empty_string(value: Option<String>) -> Option<String> {
value.filter(|str| str.is_empty())
value.filter(|str| !str.is_empty())
}
#[derive(Error, Debug)]
@ -171,6 +171,7 @@ impl UsageAgent {
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.user_agent(user_agent)
.default_headers(default_headers)
.build()
.map_err(AgentError::HTTPClientCreationError)?;
let client = ClientBuilder::new(reqwest_agent)
@ -269,6 +270,9 @@ impl UsageAgent {
}
pub async fn send_report(&self, report: Report) -> Result<(), AgentError> {
if report.size == 0 {
return Ok(());
}
let report_body =
serde_json::to_vec(&report).map_err(|e| AgentError::Unknown(e.to_string()))?;
// Based on https://the-guild.dev/graphql/hive/docs/specs/usage-reports#data-structure
@ -357,3 +361,200 @@ impl UsageAgentExt for Arc<UsageAgent> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::{sync::Arc, time::Duration};
use graphql_parser::{parse_query, parse_schema};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
use crate::agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt};
const CONTENT_TYPE_VALUE: &'static str = "application/json";
const GRAPHQL_CLIENT_NAME: &'static str = "Hive Client";
const GRAPHQL_CLIENT_VERSION: &'static str = "1.0.0";
#[tokio::test]
async fn should_send_data_to_hive() {
let token = "Token";
let mut server = mockito::Server::new_async().await;
let server_url = server.url();
let timestamp = 1625247600;
let duration = Duration::from_millis(20);
let user_agent = format!("hive-router-sdk-test");
let mock = server
.mock("POST", "/200")
.match_header(AUTHORIZATION, format!("Bearer {}", token).as_str())
.match_header(CONTENT_TYPE, CONTENT_TYPE_VALUE)
.match_header(USER_AGENT, user_agent.as_str())
.match_header("X-Usage-API-Version", "2")
.match_request(move |request| {
let request_body = request.body().expect("Failed to extract body");
let report: Report = serde_json::from_slice(request_body)
.expect("Failed to parse request body as JSON");
assert_eq!(report.size, 1);
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"));
// fields
let expected_fields = vec![
"Mutation.deleteProject",
"Mutation.deleteProject.selector",
"Mutation.deleteProject.selector!",
"DeleteProjectPayload.selector",
"ProjectSelector.organization",
"ProjectSelector.project",
"DeleteProjectPayload.deletedProject",
"Project.id",
"Project.cleanId",
"Project.name",
"Project.type",
"ProjectType.FEDERATION",
"ProjectType.STITCHING",
"ProjectType.SINGLE",
"ProjectType.CUSTOM",
"ProjectSelectorInput.organization",
"ID",
"ProjectSelectorInput.project",
];
for field in &expected_fields {
assert!(record.fields.contains(&field.to_string()));
}
assert_eq!(record.fields.len(), expected_fields.len());
// Operations
let operations = report.operations;
assert_eq!(operations.len(), 1); // one operation
let operation = &operations[0];
let key = report.map.keys().next().expect("No operation key");
assert_eq!(&operation.operationMapKey, key);
assert_eq!(operation.timestamp, timestamp);
assert_eq!(operation.execution.duration, duration.as_nanos());
assert_eq!(operation.execution.ok, true);
assert_eq!(operation.execution.errorsTotal, 0);
true
})
.expect(1)
.with_status(200)
.create_async()
.await;
let schema: graphql_tools::static_graphql::schema::Document = parse_schema(
r#"
type Query {
project(selector: ProjectSelectorInput!): Project
projectsByType(type: ProjectType!): [Project!]!
projects(filter: FilterInput): [Project!]!
}
type Mutation {
deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload!
}
input ProjectSelectorInput {
organization: ID!
project: ID!
}
input FilterInput {
type: ProjectType
pagination: PaginationInput
}
input PaginationInput {
limit: Int
offset: Int
}
type ProjectSelector {
organization: ID!
project: ID!
}
type DeleteProjectPayload {
selector: ProjectSelector!
deletedProject: Project!
}
type Project {
id: ID!
cleanId: ID!
name: String!
type: ProjectType!
buildUrl: String
validationUrl: String
}
enum ProjectType {
FEDERATION
STITCHING
SINGLE
CUSTOM
}
"#,
)
.expect("Failed to parse schema");
let op: graphql_tools::static_graphql::query::Document = parse_query(
r#"
mutation deleteProject($selector: ProjectSelectorInput!) {
deleteProject(selector: $selector) {
selector {
organization
project
}
deletedProject {
...ProjectFields
}
}
}
fragment ProjectFields on Project {
id
cleanId
name
type
}
"#,
)
.expect("Failed to parse query");
let usage_agent = UsageAgent::try_new(
token,
format!("{}/200", server_url),
None,
10,
Duration::from_millis(500),
Duration::from_millis(500),
false,
Duration::from_millis(10),
user_agent,
)
.expect("Failed to create UsageAgent");
usage_agent
.add_report(ExecutionReport {
schema: Arc::new(schema),
operation_body: op.to_string(),
operation_name: Some("deleteProject".to_string()),
client_name: Some(GRAPHQL_CLIENT_NAME.to_string()),
client_version: Some(GRAPHQL_CLIENT_VERSION.to_string()),
timestamp,
duration,
ok: true,
errors: 0,
persisted_document_hash: None,
})
.expect("Failed to add report");
usage_agent.flush().await;
mock.assert_async().await;
}
}