Skip to content

Commit 3a71098

Browse files
feat(router): Hive Console Usage Reporting (#499)
Hive Console Client integration Ref ROUTER-102 ~~Blocked by graphql-hive/console#7143 Documentation -> graphql-hive/console#7171 TODOs: - ~~Release `hive-console-sdk` and add it to Cargo.toml here~~ - ~~Update documentation and env overrides after #519 lands.~~ --------- Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
1 parent 5a19ac5 commit 3a71098

File tree

16 files changed

+520
-5
lines changed

16 files changed

+520
-5
lines changed

.changeset/usage_reporting.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
router: patch
3+
---
4+
5+
# Usage Reporting to Hive Console
6+
7+
Hive Router now supports sending usage reports to the Hive Console. This feature allows you to monitor and analyze the performance and usage of your GraphQL services directly from the Hive Console.
8+
To enable usage reporting, you need to configure the `usage_reporting` section in your Hive Router configuration file.
9+
10+
[Learn more about usage reporting in the documentation.](https://the-guild.dev/graphql/hive/docs/router/configuration/usage_reporting)
11+
```yaml
12+
usage_reporting:
13+
enabled: true
14+
access_token: "YOUR_HIVE_CONSOLE_ACCESS_TOKEN"
15+
```

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ retry-policies = "0.4.0"
5959
reqwest-retry = "0.7.0"
6060
reqwest-middleware = "0.4.2"
6161
vrl = { version = "0.28.0", features = ["compiler", "parser", "value", "diagnostic", "stdlib", "core"] }
62+
regex-automata = "0.4.10"

bin/router/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,14 @@ reqwest-retry = { workspace = true }
4545
reqwest-middleware = { workspace = true }
4646
vrl = { workspace = true }
4747
serde_json = { workspace = true }
48+
regex-automata = { workspace = true }
4849

4950
mimalloc = { version = "0.1.48", features = ["v3"] }
5051
moka = { version = "0.12.10", features = ["future"] }
5152
hive-console-sdk = "0.2.0"
5253
ulid = "1.2.1"
5354
tokio-util = "0.7.16"
5455
cookie = "0.18.1"
55-
regex-automata = "0.4.10"
5656
arc-swap = "1.7.1"
5757
lasso2 = "0.8.2"
5858
ahash = "0.8.12"

bin/router/src/lib.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::{
2020
},
2121
jwt::JwtAuthRuntime,
2222
logger::configure_logging,
23-
pipeline::graphql_request_handler,
23+
pipeline::{graphql_request_handler, usage_reporting::init_hive_user_agent},
2424
};
2525

2626
pub use crate::{schema_state::SchemaState, shared_state::RouterSharedState};
@@ -112,11 +112,23 @@ pub async fn configure_app_from_config(
112112
false => None,
113113
};
114114

115+
let hive_usage_agent = match router_config.usage_reporting.enabled {
116+
true => Some(init_hive_user_agent(
117+
bg_tasks_manager,
118+
&router_config.usage_reporting,
119+
)?),
120+
false => None,
121+
};
122+
115123
let router_config_arc = Arc::new(router_config);
116124
let schema_state =
117125
SchemaState::new_from_config(bg_tasks_manager, router_config_arc.clone()).await?;
118126
let schema_state_arc = Arc::new(schema_state);
119-
let shared_state = Arc::new(RouterSharedState::new(router_config_arc, jwt_runtime)?);
127+
let shared_state = Arc::new(RouterSharedState::new(
128+
router_config_arc,
129+
jwt_runtime,
130+
hive_usage_agent,
131+
)?);
120132

121133
Ok((shared_state, schema_state_arc))
122134
}

bin/router/src/pipeline/mod.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::sync::Arc;
1+
use std::{sync::Arc, time::Instant};
22

33
use hive_router_plan_executor::execution::{
44
client_request_details::{ClientRequestDetails, JwtRequestDetails, OperationDetails},
@@ -48,6 +48,7 @@ pub mod normalize;
4848
pub mod parser;
4949
pub mod progressive_override;
5050
pub mod query_plan;
51+
pub mod usage_reporting;
5152
pub mod validation;
5253

5354
static GRAPHIQL_HTML: &str = include_str!("../../static/graphiql.html");
@@ -116,6 +117,7 @@ pub async fn execute_pipeline(
116117
shared_state: &Arc<RouterSharedState>,
117118
schema_state: &Arc<SchemaState>,
118119
) -> Result<PlanExecutionOutput, PipelineError> {
120+
let start = Instant::now();
119121
perform_csrf_prevention(req, &shared_state.router_config.csrf)?;
120122

121123
let mut execution_request = get_execution_request(req, body_bytes).await?;
@@ -231,5 +233,19 @@ pub async fn execute_pipeline(
231233
};
232234
let execution_result = execute_plan(req, supergraph, shared_state, &planned_request).await?;
233235

236+
if shared_state.router_config.usage_reporting.enabled {
237+
if let Some(hive_usage_agent) = &shared_state.hive_usage_agent {
238+
usage_reporting::collect_usage_report(
239+
supergraph.supergraph_schema.clone(),
240+
start.elapsed(),
241+
req,
242+
&client_request_details,
243+
hive_usage_agent,
244+
&shared_state.router_config.usage_reporting,
245+
&execution_result,
246+
);
247+
}
248+
}
249+
234250
Ok(execution_result)
235251
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use std::{
2+
sync::Arc,
3+
time::{Duration, SystemTime, UNIX_EPOCH},
4+
};
5+
6+
use async_trait::async_trait;
7+
use graphql_parser::schema::Document;
8+
use hive_console_sdk::agent::{AgentError, ExecutionReport, UsageAgent, UsageAgentExt};
9+
use hive_router_config::usage_reporting::UsageReportingConfig;
10+
use hive_router_plan_executor::execution::{
11+
client_request_details::ClientRequestDetails, plan::PlanExecutionOutput,
12+
};
13+
use ntex::web::HttpRequest;
14+
use rand::Rng;
15+
use tokio_util::sync::CancellationToken;
16+
17+
use crate::{
18+
background_tasks::{BackgroundTask, BackgroundTasksManager},
19+
consts::ROUTER_VERSION,
20+
};
21+
22+
#[derive(Debug, thiserror::Error)]
23+
pub enum UsageReportingError {
24+
#[error("Usage Reporting - Access token is missing. Please provide it via 'HIVE_ACCESS_TOKEN' environment variable or under 'usage_reporting.access_token' in the configuration.")]
25+
MissingAccessToken,
26+
#[error("Usage Reporting - Failed to initialize usage agent: {0}")]
27+
AgentCreationError(#[from] AgentError),
28+
}
29+
30+
pub fn init_hive_user_agent(
31+
bg_tasks_manager: &mut BackgroundTasksManager,
32+
usage_config: &UsageReportingConfig,
33+
) -> Result<Arc<UsageAgent>, UsageReportingError> {
34+
let user_agent = format!("hive-router/{}", ROUTER_VERSION);
35+
let access_token = usage_config
36+
.access_token
37+
.as_deref()
38+
.ok_or(UsageReportingError::MissingAccessToken)?;
39+
40+
let hive_user_agent = UsageAgent::try_new(
41+
access_token,
42+
usage_config.endpoint.clone(),
43+
usage_config.target_id.clone(),
44+
usage_config.buffer_size,
45+
usage_config.connect_timeout,
46+
usage_config.request_timeout,
47+
usage_config.accept_invalid_certs,
48+
usage_config.flush_interval,
49+
user_agent,
50+
)?;
51+
bg_tasks_manager.register_task(hive_user_agent.clone());
52+
Ok(hive_user_agent)
53+
}
54+
55+
#[inline]
56+
pub fn collect_usage_report(
57+
schema: Arc<Document<'static, String>>,
58+
duration: Duration,
59+
req: &HttpRequest,
60+
client_request_details: &ClientRequestDetails,
61+
hive_usage_agent: &Arc<UsageAgent>,
62+
usage_config: &UsageReportingConfig,
63+
execution_result: &PlanExecutionOutput,
64+
) {
65+
let sample_rate = usage_config.sample_rate.as_f64();
66+
if sample_rate < 1.0 && !rand::rng().random_bool(sample_rate) {
67+
return;
68+
}
69+
if client_request_details
70+
.operation
71+
.name
72+
.is_some_and(|op_name| usage_config.exclude.iter().any(|s| s == op_name))
73+
{
74+
return;
75+
}
76+
let client_name = get_header_value(req, &usage_config.client_name_header);
77+
let client_version = get_header_value(req, &usage_config.client_version_header);
78+
let timestamp = SystemTime::now()
79+
.duration_since(UNIX_EPOCH)
80+
.unwrap()
81+
.as_millis() as u64;
82+
let execution_report = ExecutionReport {
83+
schema,
84+
client_name: client_name.map(|s| s.to_owned()),
85+
client_version: client_version.map(|s| s.to_owned()),
86+
timestamp,
87+
duration,
88+
ok: execution_result.error_count == 0,
89+
errors: execution_result.error_count,
90+
operation_body: client_request_details.operation.query.to_owned(),
91+
operation_name: client_request_details
92+
.operation
93+
.name
94+
.map(|op_name| op_name.to_owned()),
95+
persisted_document_hash: None,
96+
};
97+
98+
if let Err(err) = hive_usage_agent.add_report(execution_report) {
99+
tracing::error!("Failed to send usage report: {}", err);
100+
}
101+
}
102+
103+
#[inline]
104+
fn get_header_value<'req>(req: &'req HttpRequest, header_name: &str) -> Option<&'req str> {
105+
req.headers().get(header_name).and_then(|v| v.to_str().ok())
106+
}
107+
108+
#[async_trait]
109+
impl BackgroundTask for UsageAgent {
110+
fn id(&self) -> &str {
111+
"hive_console_usage_report_task"
112+
}
113+
114+
async fn run(&self, token: CancellationToken) {
115+
self.start_flush_interval(Some(token)).await
116+
}
117+
}

bin/router/src/schema_state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use arc_swap::{ArcSwap, Guard};
22
use async_trait::async_trait;
3+
use graphql_parser::schema::Document;
34
use graphql_tools::validation::utils::ValidationError;
45
use hive_router_config::{supergraph::SupergraphSource, HiveRouterConfig};
56
use hive_router_plan_executor::{
@@ -43,6 +44,7 @@ pub struct SupergraphData {
4344
pub planner: Planner,
4445
pub authorization: AuthorizationMetadata,
4546
pub subgraph_executor_map: SubgraphExecutorMap,
47+
pub supergraph_schema: Arc<Document<'static, String>>,
4648
}
4749

4850
#[derive(Debug, thiserror::Error)]
@@ -132,6 +134,7 @@ impl SchemaState {
132134
)?;
133135

134136
Ok(SupergraphData {
137+
supergraph_schema: Arc::new(parsed_supergraph_sdl),
135138
metadata,
136139
planner,
137140
authorization,

bin/router/src/shared_state.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use graphql_tools::validation::validate::ValidationPlan;
2+
use hive_console_sdk::agent::UsageAgent;
23
use hive_router_config::HiveRouterConfig;
34
use hive_router_plan_executor::headers::{
45
compile::compile_headers_plan, errors::HeaderRuleCompileError, plan::HeaderRulesPlan,
@@ -68,12 +69,14 @@ pub struct RouterSharedState {
6869
/// but no longer than `exp` date.
6970
pub jwt_claims_cache: JwtClaimsCache,
7071
pub jwt_auth_runtime: Option<JwtAuthRuntime>,
72+
pub hive_usage_agent: Option<Arc<UsageAgent>>,
7173
}
7274

7375
impl RouterSharedState {
7476
pub fn new(
7577
router_config: Arc<HiveRouterConfig>,
7678
jwt_auth_runtime: Option<JwtAuthRuntime>,
79+
hive_usage_agent: Option<Arc<UsageAgent>>,
7780
) -> Result<Self, SharedStateError> {
7881
Ok(Self {
7982
validation_plan: graphql_tools::validation::rules::default_rules_validation_plan(),
@@ -92,6 +95,7 @@ impl RouterSharedState {
9295
)
9396
.map_err(Box::new)?,
9497
jwt_auth_runtime,
98+
hive_usage_agent,
9599
})
96100
}
97101
}
@@ -104,4 +108,6 @@ pub enum SharedStateError {
104108
CORSConfig(#[from] Box<CORSConfigError>),
105109
#[error("invalid override labels config: {0}")]
106110
OverrideLabelsCompile(#[from] Box<OverrideLabelsCompileError>),
111+
#[error("error creating hive usage agent: {0}")]
112+
UsageAgent(#[from] Box<hive_console_sdk::agent::AgentError>),
107113
}

docs/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
|[**query\_planner**](#query_planner)|`object`|Query planning configuration.<br/>Default: `{"allow_expose":false,"timeout":"10s"}`<br/>||
1818
|[**supergraph**](#supergraph)|`object`|Configuration for the Federation supergraph source. By default, the router will use a local file-based supergraph source (`./supergraph.graphql`).<br/>||
1919
|[**traffic\_shaping**](#traffic_shaping)|`object`|Configuration for the traffic-shaping of the executor. Use these configurations to control how requests are being executed to subgraphs.<br/>Default: `{"all":{"dedupe_enabled":true,"pool_idle_timeout":"50s","request_timeout":"30s"},"max_connections_per_host":100}`<br/>||
20+
|[**usage\_reporting**](#usage_reporting)|`object`|Configuration for usage reporting to GraphQL Hive.<br/>Default: `{"accept_invalid_certs":false,"access_token":null,"buffer_size":1000,"client_name_header":"graphql-client-name","client_version_header":"graphql-client-version","connect_timeout":"5s","enabled":false,"endpoint":"https://app.graphql-hive.com/usage","exclude":[],"flush_interval":"5s","request_timeout":"15s","sample_rate":"100%","target_id":null}`<br/>||
2021

2122
**Additional Properties:** not allowed
2223
**Example**
@@ -118,6 +119,20 @@ traffic_shaping:
118119
pool_idle_timeout: 50s
119120
request_timeout: 30s
120121
max_connections_per_host: 100
122+
usage_reporting:
123+
accept_invalid_certs: false
124+
access_token: null
125+
buffer_size: 1000
126+
client_name_header: graphql-client-name
127+
client_version_header: graphql-client-version
128+
connect_timeout: 5s
129+
enabled: false
130+
endpoint: https://app.graphql-hive.com/usage
131+
exclude: []
132+
flush_interval: 5s
133+
request_timeout: 15s
134+
sample_rate: 100%
135+
target_id: null
121136

122137
```
123138

@@ -1885,4 +1900,58 @@ Optional per-subgraph configurations that will override the default configuratio
18851900
|**request\_timeout**||Optional timeout configuration for requests to subgraphs.<br/><br/>Example with a fixed duration:<br/>```yaml<br/> timeout:<br/> duration: 5s<br/>```<br/><br/>Or with a VRL expression that can return a duration based on the operation kind:<br/>```yaml<br/> timeout:<br/> expression: \|<br/> if (.request.operation.type == "mutation") {<br/> "10s"<br/> } else {<br/> "15s"<br/> }<br/>```<br/>||
18861901

18871902
**Additional Properties:** not allowed
1903+
<a name="usage_reporting"></a>
1904+
## usage\_reporting: object
1905+
1906+
Configuration for usage reporting to GraphQL Hive.
1907+
1908+
1909+
**Properties**
1910+
1911+
|Name|Type|Description|Required|
1912+
|----|----|-----------|--------|
1913+
|**accept\_invalid\_certs**|`boolean`|Accepts invalid SSL certificates<br/>Default: false<br/>Default: `false`<br/>||
1914+
|**access\_token**|`string`, `null`|Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission.<br/>||
1915+
|**buffer\_size**|`integer`|A maximum number of operations to hold in a buffer before sending to Hive Console<br/>Default: 1000<br/>Default: `1000`<br/>Format: `"uint"`<br/>Minimum: `0`<br/>||
1916+
|**client\_name\_header**|`string`|Default: `"graphql-client-name"`<br/>||
1917+
|**client\_version\_header**|`string`|Default: `"graphql-client-version"`<br/>||
1918+
|**connect\_timeout**|`string`|A timeout for only the connect phase of a request to Hive Console<br/>Default: 5 seconds<br/>Default: `"5s"`<br/>||
1919+
|**enabled**|`boolean`|Default: `false`<br/>||
1920+
|**endpoint**|`string`|For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`).<br/>Default: `"https://app.graphql-hive.com/usage"`<br/>||
1921+
|[**exclude**](#usage_reportingexclude)|`string[]`|A list of operations (by name) to be ignored by Hive.<br/>Default: <br/>||
1922+
|**flush\_interval**|`string`|Frequency of flushing the buffer to the server<br/>Default: 5 seconds<br/>Default: `"5s"`<br/>||
1923+
|**request\_timeout**|`string`|A timeout for the entire request to Hive Console<br/>Default: 15 seconds<br/>Default: `"15s"`<br/>||
1924+
|**sample\_rate**|`string`|Sample rate to determine sampling.<br/>0% = never being sent<br/>50% = half of the requests being sent<br/>100% = always being sent<br/>Default: 100%<br/>Default: `"100%"`<br/>||
1925+
|**target\_id**|`string`, `null`|A target ID, this can either be a slug following the format “$organizationSlug/$projectSlug/$targetSlug” (e.g “the-guild/graphql-hive/staging”) or an UUID (e.g. “a0f4c605-6541-4350-8cfe-b31f21a4bf80”). To be used when the token is configured with an organization access token.<br/>||
1926+
1927+
**Additional Properties:** not allowed
1928+
**Example**
1929+
1930+
```yaml
1931+
accept_invalid_certs: false
1932+
access_token: null
1933+
buffer_size: 1000
1934+
client_name_header: graphql-client-name
1935+
client_version_header: graphql-client-version
1936+
connect_timeout: 5s
1937+
enabled: false
1938+
endpoint: https://app.graphql-hive.com/usage
1939+
exclude: []
1940+
flush_interval: 5s
1941+
request_timeout: 15s
1942+
sample_rate: 100%
1943+
target_id: null
1944+
1945+
```
1946+
1947+
<a name="usage_reportingexclude"></a>
1948+
### usage\_reporting\.exclude\[\]: array
1949+
1950+
A list of operations (by name) to be ignored by Hive.
1951+
Example: ["IntrospectionQuery", "MeQuery"]
1952+
1953+
1954+
**Items**
1955+
1956+
**Item Type:** `string`
18881957

0 commit comments

Comments
 (0)