Skip to main content

hive_console_sdk/agent/
usage_agent.rs

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        // iterate over reports and check if they are valid
97        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                                /*
135                                    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).
136                                    While highly unlikely, it's safer to handle this potential overflow gracefully in library code to prevent panics.
137                                    A safe alternative is to convert the Result to an Option and provide a fallback value on failure,
138                                    effectively saturating at u64::MAX.
139                                */
140                                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        // Based on https://the-guild.dev/graphql/hive/docs/specs/usage-reports#data-structure
179        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                // operation
295                assert!(record.operation.contains("mutation deleteProject"));
296                assert_eq!(record.operation_name.as_deref(), Some("deleteProject"));
297                // fields
298                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                // Operations
331                let operations = report.operations;
332                assert_eq!(operations.len(), 1); // one operation
333
334                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        // Testing async drop
426        {
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}