use crate::trace_correlation::AttributeValue;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OperationType {
Query,
Mutation,
Subscription,
}
impl OperationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Query => "query",
Self::Mutation => "mutation",
Self::Subscription => "subscription",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldMetrics {
pub path: String,
pub duration_us: u64,
pub cached: bool,
pub item_count: Option<usize>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheMetrics {
pub hits: usize,
pub misses: usize,
pub hit_rate: f64,
pub total_lookups: usize,
}
impl CacheMetrics {
pub fn update(&mut self) {
self.total_lookups = self.hits + self.misses;
if self.total_lookups > 0 {
self.hit_rate = self.hits as f64 / self.total_lookups as f64;
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComplexityMetrics {
pub complexity_score: u32,
pub depth: u32,
pub breadth: u32,
pub field_count: u32,
pub alias_count: u32,
pub fragment_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCategory {
Validation,
Authorization,
Resolution,
DataSource,
Internal,
Timeout,
RateLimit,
}
impl ErrorCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::Validation => "validation",
Self::Authorization => "authorization",
Self::Resolution => "resolution",
Self::DataSource => "data_source",
Self::Internal => "internal",
Self::Timeout => "timeout",
Self::RateLimit => "rate_limit",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLError {
pub message: String,
pub category: ErrorCategory,
pub path: Vec<String>,
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLSpanAttributes {
pub operation_type: Option<OperationType>,
pub operation_name: Option<String>,
pub document: Option<String>,
pub field_path: Vec<String>,
pub field_metrics: Vec<FieldMetrics>,
pub cache_metrics: CacheMetrics,
pub complexity_metrics: ComplexityMetrics,
pub errors: Vec<GraphQLError>,
pub client_name: Option<String>,
pub client_version: Option<String>,
pub schema_version: Option<String>,
pub variables_count: usize,
pub persisted_query: bool,
#[serde(skip)]
start_time: Option<Instant>,
}
impl GraphQLSpanAttributes {
pub fn new() -> Self {
Self {
operation_type: None,
operation_name: None,
document: None,
field_path: Vec::new(),
field_metrics: Vec::new(),
cache_metrics: CacheMetrics::default(),
complexity_metrics: ComplexityMetrics::default(),
errors: Vec::new(),
client_name: None,
client_version: None,
schema_version: None,
variables_count: 0,
persisted_query: false,
start_time: None,
}
}
pub fn with_operation_type(mut self, op_type: OperationType) -> Self {
self.operation_type = Some(op_type);
self
}
pub fn with_operation_name(mut self, name: impl Into<String>) -> Self {
self.operation_name = Some(name.into());
self
}
pub fn with_document(mut self, doc: impl Into<String>) -> Self {
self.document = Some(doc.into());
self
}
pub fn with_field_path(mut self, path: Vec<String>) -> Self {
self.field_path = path;
self
}
pub fn with_client(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self.client_version = Some(version.into());
self
}
pub fn with_schema_version(mut self, version: impl Into<String>) -> Self {
self.schema_version = Some(version.into());
self
}
pub fn with_variables_count(mut self, count: usize) -> Self {
self.variables_count = count;
self
}
pub fn with_persisted_query(mut self, persisted: bool) -> Self {
self.persisted_query = persisted;
self
}
pub fn with_complexity(mut self, metrics: ComplexityMetrics) -> Self {
self.complexity_metrics = metrics;
self
}
pub fn start_timing(&mut self) {
self.start_time = Some(Instant::now());
}
pub fn record_field_resolution(&mut self, field: impl Into<String>, duration_us: u64) {
let field_str = field.into();
self.field_metrics.push(FieldMetrics {
path: field_str,
duration_us,
cached: false,
item_count: None,
error: None,
});
}
pub fn record_field_resolution_with_count(
&mut self,
field: impl Into<String>,
duration_us: u64,
count: usize,
) {
let field_str = field.into();
self.field_metrics.push(FieldMetrics {
path: field_str,
duration_us,
cached: false,
item_count: Some(count),
error: None,
});
}
pub fn record_field_error(&mut self, field: impl Into<String>, error: impl Into<String>) {
let field_str = field.into();
self.field_metrics.push(FieldMetrics {
path: field_str,
duration_us: 0,
cached: false,
item_count: None,
error: Some(error.into()),
});
}
pub fn record_cache_hit(&mut self, field: impl Into<String>) {
self.cache_metrics.hits += 1;
self.cache_metrics.update();
if let Some(last_metric) = self.field_metrics.last_mut() {
if last_metric.path == field.into() {
last_metric.cached = true;
}
}
}
pub fn record_cache_miss(&mut self) {
self.cache_metrics.misses += 1;
self.cache_metrics.update();
}
pub fn add_error(&mut self, error: GraphQLError) {
self.errors.push(error);
}
pub fn to_attribute_map(&self) -> HashMap<String, AttributeValue> {
let mut attrs = HashMap::new();
if let Some(op_type) = &self.operation_type {
attrs.insert(
"graphql.operation.type".to_string(),
AttributeValue::String(op_type.as_str().to_string()),
);
}
if let Some(op_name) = &self.operation_name {
attrs.insert(
"graphql.operation.name".to_string(),
AttributeValue::String(op_name.clone()),
);
}
if let Some(doc) = &self.document {
let truncated = if doc.len() > 1000 {
format!("{}...", &doc[..1000])
} else {
doc.clone()
};
attrs.insert(
"graphql.document".to_string(),
AttributeValue::String(truncated),
);
}
if !self.field_path.is_empty() {
attrs.insert(
"graphql.field.path".to_string(),
AttributeValue::String(self.field_path.join(".")),
);
}
attrs.insert(
"graphql.field.count".to_string(),
AttributeValue::Int(self.field_metrics.len() as i64),
);
if !self.field_metrics.is_empty() {
let total_duration: u64 = self.field_metrics.iter().map(|m| m.duration_us).sum();
let avg_duration = total_duration / self.field_metrics.len() as u64;
attrs.insert(
"graphql.field.avg_duration_us".to_string(),
AttributeValue::Int(avg_duration as i64),
);
let max_duration = self
.field_metrics
.iter()
.map(|m| m.duration_us)
.max()
.unwrap_or(0);
attrs.insert(
"graphql.field.max_duration_us".to_string(),
AttributeValue::Int(max_duration as i64),
);
}
attrs.insert(
"graphql.cache.hits".to_string(),
AttributeValue::Int(self.cache_metrics.hits as i64),
);
attrs.insert(
"graphql.cache.misses".to_string(),
AttributeValue::Int(self.cache_metrics.misses as i64),
);
attrs.insert(
"graphql.cache.hit_rate".to_string(),
AttributeValue::Float(self.cache_metrics.hit_rate),
);
attrs.insert(
"graphql.complexity.score".to_string(),
AttributeValue::Int(self.complexity_metrics.complexity_score as i64),
);
attrs.insert(
"graphql.complexity.depth".to_string(),
AttributeValue::Int(self.complexity_metrics.depth as i64),
);
attrs.insert(
"graphql.complexity.breadth".to_string(),
AttributeValue::Int(self.complexity_metrics.breadth as i64),
);
if !self.errors.is_empty() {
attrs.insert(
"graphql.error.count".to_string(),
AttributeValue::Int(self.errors.len() as i64),
);
let error_categories: Vec<String> = self
.errors
.iter()
.map(|e| e.category.as_str().to_string())
.collect();
attrs.insert(
"graphql.error.categories".to_string(),
AttributeValue::StringArray(error_categories),
);
}
if let Some(client_name) = &self.client_name {
attrs.insert(
"graphql.client.name".to_string(),
AttributeValue::String(client_name.clone()),
);
}
if let Some(client_version) = &self.client_version {
attrs.insert(
"graphql.client.version".to_string(),
AttributeValue::String(client_version.clone()),
);
}
if let Some(schema_version) = &self.schema_version {
attrs.insert(
"graphql.schema.version".to_string(),
AttributeValue::String(schema_version.clone()),
);
}
attrs.insert(
"graphql.variables.count".to_string(),
AttributeValue::Int(self.variables_count as i64),
);
attrs.insert(
"graphql.persisted_query".to_string(),
AttributeValue::Bool(self.persisted_query),
);
attrs
}
pub fn get_summary(&self) -> AttributeSummary {
let total_duration: u64 = self.field_metrics.iter().map(|m| m.duration_us).sum();
let field_count = self.field_metrics.len();
let error_count = self.errors.len();
AttributeSummary {
operation_type: self.operation_type.map(|t| t.as_str().to_string()),
operation_name: self.operation_name.clone(),
field_count,
total_duration_us: total_duration,
avg_duration_us: if field_count > 0 {
total_duration / field_count as u64
} else {
0
},
cache_hit_rate: self.cache_metrics.hit_rate,
error_count,
complexity_score: self.complexity_metrics.complexity_score,
}
}
}
impl Default for GraphQLSpanAttributes {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributeSummary {
pub operation_type: Option<String>,
pub operation_name: Option<String>,
pub field_count: usize,
pub total_duration_us: u64,
pub avg_duration_us: u64,
pub cache_hit_rate: f64,
pub error_count: usize,
pub complexity_score: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_operation_type() {
assert_eq!(OperationType::Query.as_str(), "query");
assert_eq!(OperationType::Mutation.as_str(), "mutation");
assert_eq!(OperationType::Subscription.as_str(), "subscription");
}
#[test]
fn test_error_category() {
assert_eq!(ErrorCategory::Validation.as_str(), "validation");
assert_eq!(ErrorCategory::Authorization.as_str(), "authorization");
assert_eq!(ErrorCategory::Resolution.as_str(), "resolution");
}
#[test]
fn test_cache_metrics_update() {
let mut metrics = CacheMetrics {
hits: 8,
misses: 2,
..Default::default()
};
metrics.update();
assert_eq!(metrics.total_lookups, 10);
assert!((metrics.hit_rate - 0.8).abs() < 0.001);
}
#[test]
fn test_graphql_span_attributes_creation() {
let attrs = GraphQLSpanAttributes::new()
.with_operation_type(OperationType::Query)
.with_operation_name("GetUser")
.with_field_path(vec!["user".to_string(), "profile".to_string()]);
assert_eq!(attrs.operation_type, Some(OperationType::Query));
assert_eq!(attrs.operation_name, Some("GetUser".to_string()));
assert_eq!(attrs.field_path.len(), 2);
}
#[test]
fn test_record_field_resolution() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.record_field_resolution("user", 1000);
attrs.record_field_resolution("posts", 2000);
assert_eq!(attrs.field_metrics.len(), 2);
assert_eq!(attrs.field_metrics[0].path, "user");
assert_eq!(attrs.field_metrics[0].duration_us, 1000);
assert_eq!(attrs.field_metrics[1].path, "posts");
assert_eq!(attrs.field_metrics[1].duration_us, 2000);
}
#[test]
fn test_record_field_resolution_with_count() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.record_field_resolution_with_count("posts", 3000, 10);
assert_eq!(attrs.field_metrics.len(), 1);
assert_eq!(attrs.field_metrics[0].item_count, Some(10));
}
#[test]
fn test_record_field_error() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.record_field_error("user", "Not found");
assert_eq!(attrs.field_metrics.len(), 1);
assert_eq!(attrs.field_metrics[0].error, Some("Not found".to_string()));
}
#[test]
fn test_cache_hit_miss() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.record_field_resolution("user", 1000);
attrs.record_cache_hit("user");
attrs.record_cache_miss();
assert_eq!(attrs.cache_metrics.hits, 1);
assert_eq!(attrs.cache_metrics.misses, 1);
assert!((attrs.cache_metrics.hit_rate - 0.5).abs() < 0.001);
}
#[test]
fn test_add_error() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.add_error(GraphQLError {
message: "Unauthorized".to_string(),
category: ErrorCategory::Authorization,
path: vec!["user".to_string()],
code: Some("AUTH_ERROR".to_string()),
});
assert_eq!(attrs.errors.len(), 1);
assert_eq!(attrs.errors[0].category, ErrorCategory::Authorization);
}
#[test]
fn test_to_attribute_map() {
let mut attrs = GraphQLSpanAttributes::new()
.with_operation_type(OperationType::Query)
.with_operation_name("GetUser")
.with_client("apollo-client", "3.0.0")
.with_schema_version("1.2.3")
.with_variables_count(5)
.with_persisted_query(true);
attrs.record_field_resolution("user", 1000);
attrs.record_cache_hit("user");
let attr_map = attrs.to_attribute_map();
assert!(attr_map.contains_key("graphql.operation.type"));
assert!(attr_map.contains_key("graphql.operation.name"));
assert!(attr_map.contains_key("graphql.client.name"));
assert!(attr_map.contains_key("graphql.cache.hit_rate"));
assert!(attr_map.contains_key("graphql.persisted_query"));
}
#[test]
fn test_complexity_metrics() {
let complexity = ComplexityMetrics {
complexity_score: 100,
depth: 5,
breadth: 10,
field_count: 20,
alias_count: 2,
fragment_count: 3,
};
let attrs = GraphQLSpanAttributes::new().with_complexity(complexity);
assert_eq!(attrs.complexity_metrics.complexity_score, 100);
assert_eq!(attrs.complexity_metrics.depth, 5);
assert_eq!(attrs.complexity_metrics.breadth, 10);
}
#[test]
fn test_get_summary() {
let mut attrs = GraphQLSpanAttributes::new()
.with_operation_type(OperationType::Query)
.with_operation_name("GetPosts");
attrs.record_field_resolution("posts", 1000);
attrs.record_field_resolution("author", 500);
attrs.record_cache_hit("posts");
attrs.record_cache_miss();
let summary = attrs.get_summary();
assert_eq!(summary.operation_type, Some("query".to_string()));
assert_eq!(summary.operation_name, Some("GetPosts".to_string()));
assert_eq!(summary.field_count, 2);
assert_eq!(summary.total_duration_us, 1500);
assert_eq!(summary.avg_duration_us, 750);
assert!((summary.cache_hit_rate - 0.5).abs() < 0.001);
}
#[test]
fn test_document_truncation() {
let long_doc = "a".repeat(1500);
let attrs = GraphQLSpanAttributes::new().with_document(long_doc);
let attr_map = attrs.to_attribute_map();
if let Some(AttributeValue::String(doc)) = attr_map.get("graphql.document") {
assert!(doc.len() <= 1003); assert!(doc.ends_with("..."));
} else {
panic!("Document attribute not found or wrong type");
}
}
#[test]
fn test_field_metrics_aggregation() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.record_field_resolution("field1", 100);
attrs.record_field_resolution("field2", 200);
attrs.record_field_resolution("field3", 300);
let attr_map = attrs.to_attribute_map();
match attr_map.get("graphql.field.avg_duration_us") {
Some(AttributeValue::Int(avg)) => assert_eq!(*avg, 200),
_ => panic!("Avg duration not found"),
}
match attr_map.get("graphql.field.max_duration_us") {
Some(AttributeValue::Int(max)) => assert_eq!(*max, 300),
_ => panic!("Max duration not found"),
}
}
#[test]
fn test_error_categories_in_attributes() {
let mut attrs = GraphQLSpanAttributes::new();
attrs.add_error(GraphQLError {
message: "Validation failed".to_string(),
category: ErrorCategory::Validation,
path: vec!["query".to_string()],
code: None,
});
attrs.add_error(GraphQLError {
message: "Unauthorized".to_string(),
category: ErrorCategory::Authorization,
path: vec!["user".to_string()],
code: Some("AUTH_ERROR".to_string()),
});
let attr_map = attrs.to_attribute_map();
match attr_map.get("graphql.error.categories") {
Some(AttributeValue::StringArray(categories)) => {
assert_eq!(categories.len(), 2);
assert!(categories.contains(&"validation".to_string()));
assert!(categories.contains(&"authorization".to_string()));
}
_ => panic!("Error categories not found"),
}
}
#[test]
fn test_persisted_query_attribute() {
let attrs = GraphQLSpanAttributes::new().with_persisted_query(true);
let attr_map = attrs.to_attribute_map();
match attr_map.get("graphql.persisted_query") {
Some(AttributeValue::Bool(true)) => {}
_ => panic!("Persisted query attribute incorrect"),
}
}
}