1use async_dropper_simple::{AsyncDrop, AsyncDropper};
2use graphql_tools::parser::schema::Document;
3use recloser::AsyncRecloser;
4use reqwest_middleware::ClientWithMiddleware;
5use std::{
6 collections::{hash_map::Entry, HashMap},
7 sync::Arc,
8 time::Duration,
9};
10use thiserror::Error;
11use tokio_util::sync::CancellationToken;
12
13use crate::agent::{buffer::AddStatus, utils::OperationProcessor};
14use crate::agent::{buffer::Buffer, builder::UsageAgentBuilder};
15
16#[derive(Debug, Clone)]
17pub struct ExecutionReport {
18 pub schema: Arc<Document<'static, String>>,
19 pub client_name: Option<String>,
20 pub client_version: Option<String>,
21 pub timestamp: u64,
22 pub duration: Duration,
23 pub ok: bool,
24 pub errors: usize,
25 pub operation_body: String,
26 pub operation_name: Option<String>,
27 pub persisted_document_hash: Option<String>,
28}
29
30typify::import_types!(schema = "./usage-report-v2.schema.json");
31
32pub struct UsageAgentInner {
33 pub(crate) endpoint: String,
34 pub(crate) buffer: Buffer<ExecutionReport>,
35 pub(crate) processor: OperationProcessor,
36 pub(crate) client: ClientWithMiddleware,
37 pub(crate) flush_interval: Duration,
38 pub(crate) circuit_breaker: AsyncRecloser,
39}
40
41pub fn non_empty_string(value: Option<String>) -> Option<String> {
42 value.filter(|str| !str.is_empty())
43}
44
45#[derive(Error, Debug)]
46pub enum AgentError {
47 #[error("unable to acquire lock: {0}")]
48 Lock(String),
49 #[error("unable to send report: unauthorized")]
50 Unauthorized,
51 #[error("unable to send report: no access")]
52 Forbidden,
53 #[error("unable to send report: rate limited")]
54 RateLimited,
55 #[error("missing token")]
56 MissingToken,
57 #[error("your access token requires providing a 'target_id' option.")]
58 MissingTargetId,
59 #[error("using 'target_id' with legacy tokens is not supported")]
60 TargetIdWithLegacyToken,
61 #[error("invalid token provided")]
62 InvalidToken,
63 #[error("invalid target id provided: {0}, it should be either a slug like \"$organizationSlug/$projectSlug/$targetSlug\" or an UUID")]
64 InvalidTargetId(String),
65 #[error("unable to instantiate the http client for reports sending: {0}")]
66 HTTPClientCreationError(reqwest::Error),
67 #[error("unable to create circuit breaker: {0}")]
68 CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError),
69 #[error("rejected by the circuit breaker")]
70 CircuitBreakerRejected,
71 #[error("unable to send report: {0}")]
72 Unknown(String),
73}
74
75pub type UsageAgent = Arc<AsyncDropper<UsageAgentInner>>;
76
77#[async_trait::async_trait]
78pub trait UsageAgentExt {
79 fn builder() -> UsageAgentBuilder {
80 UsageAgentBuilder::default()
81 }
82 async fn flush(&self) -> Result<(), AgentError>;
83 async fn start_flush_interval(&self, token: &CancellationToken);
84 async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError>;
85}
86
87impl UsageAgentInner {
88 fn produce_report(&self, reports: Vec<ExecutionReport>) -> Result<Report, AgentError> {
89 let mut report = Report {
90 size: 0,
91 map: HashMap::new(),
92 operations: Vec::new(),
93 subscription_operations: Vec::new(),
94 };
95
96 for op in reports {
98 let operation = self.processor.process(&op.operation_body, &op.schema);
99 match operation {
100 Err(e) => {
101 tracing::warn!(
102 "Dropping operation \"{}\" (phase: PROCESSING): {}",
103 op.operation_name
104 .clone()
105 .or_else(|| Some("anonymous".to_string()))
106 .unwrap(),
107 e
108 );
109 continue;
110 }
111 Ok(operation) => match operation {
112 Some(operation) => {
113 let hash = operation.hash;
114
115 let client_name = non_empty_string(op.client_name);
116 let client_version = non_empty_string(op.client_version);
117
118 let metadata: Option<Metadata> =
119 if client_name.is_some() || client_version.is_some() {
120 Some(Metadata {
121 client: Some(Client {
122 name: client_name.unwrap_or_default(),
123 version: client_version.unwrap_or_default(),
124 }),
125 })
126 } else {
127 None
128 };
129 report.operations.push(RequestOperation {
130 operation_map_key: hash.clone(),
131 timestamp: op.timestamp,
132 execution: Execution {
133 ok: op.ok,
134 duration: op
141 .duration
142 .as_nanos()
143 .try_into()
144 .ok()
145 .unwrap_or(u64::MAX),
146 errors_total: op.errors.try_into().unwrap(),
147 },
148 persisted_document_hash: op
149 .persisted_document_hash
150 .map(PersistedDocumentHash),
151 metadata,
152 });
153 if let Entry::Vacant(e) = report.map.entry(ReportMapKey(hash)) {
154 e.insert(OperationMapRecord {
155 operation: operation.operation,
156 operation_name: non_empty_string(op.operation_name),
157 fields: operation.coordinates,
158 });
159 }
160 report.size += 1;
161 }
162 None => {
163 tracing::debug!(
164 "Dropping operation (phase: PROCESSING): probably introspection query"
165 );
166 }
167 },
168 }
169 }
170
171 Ok(report)
172 }
173
174 async fn send_report(&self, report: Report) -> Result<(), AgentError> {
175 if report.size == 0 {
176 return Ok(());
177 }
178 let resp_fut = self.client.post(&self.endpoint).json(&report).send();
180
181 let resp = self
182 .circuit_breaker
183 .call(resp_fut)
184 .await
185 .map_err(|e| match e {
186 recloser::Error::Inner(e) => AgentError::Unknown(e.to_string()),
187 recloser::Error::Rejected => AgentError::CircuitBreakerRejected,
188 })?;
189
190 match resp.status() {
191 reqwest::StatusCode::OK => Ok(()),
192 reqwest::StatusCode::UNAUTHORIZED => Err(AgentError::Unauthorized),
193 reqwest::StatusCode::FORBIDDEN => Err(AgentError::Forbidden),
194 reqwest::StatusCode::TOO_MANY_REQUESTS => Err(AgentError::RateLimited),
195 _ => Err(AgentError::Unknown(format!(
196 "({}) {}",
197 resp.status(),
198 resp.text().await.unwrap_or_default()
199 ))),
200 }
201 }
202
203 async fn handle_drained(&self, drained: Vec<ExecutionReport>) -> Result<(), AgentError> {
204 if drained.is_empty() {
205 return Ok(());
206 }
207 let report = self.produce_report(drained)?;
208 self.send_report(report).await
209 }
210
211 async fn flush(&self) -> Result<(), AgentError> {
212 let execution_reports = self.buffer.drain().await;
213
214 self.handle_drained(execution_reports).await?;
215
216 Ok(())
217 }
218}
219
220#[async_trait::async_trait]
221impl UsageAgentExt for UsageAgent {
222 async fn flush(&self) -> Result<(), AgentError> {
223 self.inner().flush().await
224 }
225
226 async fn start_flush_interval(&self, token: &CancellationToken) {
227 loop {
228 tokio::time::sleep(self.inner().flush_interval).await;
229 if token.is_cancelled() {
230 println!("Shutting down.");
231 return;
232 }
233 self.flush()
234 .await
235 .unwrap_or_else(|e| tracing::error!("Failed to flush usage reports: {}", e));
236 }
237 }
238
239 async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError> {
240 if let AddStatus::Full { drained } = self.inner().buffer.add(execution_report).await {
241 self.inner().handle_drained(drained).await?;
242 }
243
244 Ok(())
245 }
246}
247
248#[async_trait::async_trait]
249impl AsyncDrop for UsageAgentInner {
250 async fn async_drop(&mut self) {
251 if let Err(e) = self.flush().await {
252 tracing::error!("Failed to flush usage reports during drop: {}", e);
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use std::{sync::Arc, time::Duration};
260
261 use graphql_tools::parser::{parse_query, parse_schema};
262 use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
263
264 use crate::agent::usage_agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt};
265
266 const CONTENT_TYPE_VALUE: &'static str = "application/json";
267 const GRAPHQL_CLIENT_NAME: &'static str = "Hive Client";
268 const GRAPHQL_CLIENT_VERSION: &'static str = "1.0.0";
269
270 #[tokio::test(flavor = "multi_thread")]
271 async fn should_send_data_to_hive() -> Result<(), Box<dyn std::error::Error>> {
272 let token = "Token";
273
274 let mut server = mockito::Server::new_async().await;
275
276 let server_url = server.url();
277
278 let timestamp = 1625247600;
279 let duration = Duration::from_millis(20);
280 let user_agent = "hive-router-sdk-test";
281
282 let mock = server
283 .mock("POST", "/200")
284 .match_header(AUTHORIZATION, format!("Bearer {}", token).as_str())
285 .match_header(CONTENT_TYPE, CONTENT_TYPE_VALUE)
286 .match_header(USER_AGENT, user_agent)
287 .match_header("X-Usage-API-Version", "2")
288 .match_request(move |request| {
289 let request_body = request.body().expect("Failed to extract body");
290 let report: Report = serde_json::from_slice(request_body)
291 .expect("Failed to parse request body as JSON");
292 assert_eq!(report.size, 1);
293 let record = report.map.values().next().expect("No operation record");
294 assert!(record.operation.contains("mutation deleteProject"));
296 assert_eq!(record.operation_name.as_deref(), Some("deleteProject"));
297 let expected_fields = vec![
299 "Mutation.deleteProject",
300 "Mutation.deleteProject.selector",
301 "DeleteProjectPayload.selector",
302 "ProjectSelector.organization",
303 "ProjectSelector.project",
304 "DeleteProjectPayload.deletedProject",
305 "Project.id",
306 "Project.cleanId",
307 "Project.name",
308 "Project.type",
309 "ProjectType.FEDERATION",
310 "ProjectType.STITCHING",
311 "ProjectType.SINGLE",
312 "ProjectType.CUSTOM",
313 "ProjectSelectorInput.organization",
314 "ID",
315 "ProjectSelectorInput.project",
316 ];
317 for field in &expected_fields {
318 assert!(
319 record.fields.contains(&field.to_string()),
320 "Missing field: {}",
321 field
322 );
323 }
324 assert_eq!(
325 record.fields.len(),
326 expected_fields.len(),
327 "Unexpected number of fields"
328 );
329
330 let operations = report.operations;
332 assert_eq!(operations.len(), 1); let operation = &operations[0];
335 let key = report.map.keys().next().expect("No operation key");
336 assert_eq!(operation.operation_map_key, key.0);
337 assert_eq!(operation.timestamp, timestamp);
338 assert_eq!(operation.execution.duration, duration.as_nanos() as u64);
339 assert_eq!(operation.execution.ok, true);
340 assert_eq!(operation.execution.errors_total, 0);
341 true
342 })
343 .expect(1)
344 .with_status(200)
345 .create_async()
346 .await;
347 let schema: graphql_tools::static_graphql::schema::Document = parse_schema(
348 r#"
349 type Query {
350 project(selector: ProjectSelectorInput!): Project
351 projectsByType(type: ProjectType!): [Project!]!
352 projects(filter: FilterInput): [Project!]!
353 }
354
355 type Mutation {
356 deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload!
357 }
358
359 input ProjectSelectorInput {
360 organization: ID!
361 project: ID!
362 }
363
364 input FilterInput {
365 type: ProjectType
366 pagination: PaginationInput
367 }
368
369 input PaginationInput {
370 limit: Int
371 offset: Int
372 }
373
374 type ProjectSelector {
375 organization: ID!
376 project: ID!
377 }
378
379 type DeleteProjectPayload {
380 selector: ProjectSelector!
381 deletedProject: Project!
382 }
383
384 type Project {
385 id: ID!
386 cleanId: ID!
387 name: String!
388 type: ProjectType!
389 buildUrl: String
390 validationUrl: String
391 }
392
393 enum ProjectType {
394 FEDERATION
395 STITCHING
396 SINGLE
397 CUSTOM
398 }
399 "#,
400 )?;
401
402 let op: graphql_tools::static_graphql::query::Document = parse_query(
403 r#"
404 mutation deleteProject($selector: ProjectSelectorInput!) {
405 deleteProject(selector: $selector) {
406 selector {
407 organization
408 project
409 }
410 deletedProject {
411 ...ProjectFields
412 }
413 }
414 }
415
416 fragment ProjectFields on Project {
417 id
418 cleanId
419 name
420 type
421 }
422 "#,
423 )?;
424
425 {
427 let usage_agent = UsageAgent::builder()
428 .token(token.into())
429 .endpoint(format!("{}/200", server_url))
430 .user_agent(user_agent.into())
431 .build()?;
432
433 usage_agent
434 .add_report(ExecutionReport {
435 schema: Arc::new(schema),
436 operation_body: op.to_string(),
437 operation_name: Some("deleteProject".to_string()),
438 client_name: Some(GRAPHQL_CLIENT_NAME.to_string()),
439 client_version: Some(GRAPHQL_CLIENT_VERSION.to_string()),
440 timestamp,
441 duration,
442 ok: true,
443 errors: 0,
444 persisted_document_hash: None,
445 })
446 .await?;
447 }
448
449 mock.assert_async().await;
450
451 Ok(())
452 }
453}