Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Apollo usage reporting to include persisted query metrics #7166

Open
wants to merge 27 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d3daf8b
Add PQ ID to context
bonnici Apr 3, 2025
bf3cfcb
Move signature and apollo op id generation into function
bonnici Apr 3, 2025
587e45e
Refactor error handling
bonnici Apr 4, 2025
dd86724
Rename functions
bonnici Apr 4, 2025
4ea39c6
PQ reporting and fixes
bonnici Apr 4, 2025
d0a1ea8
Lint fix
bonnici Apr 4, 2025
84a6f40
Merge branch 'dev' into njm/P-416/pq-reporting
bonnici Apr 4, 2025
4038ace
Fix test
bonnici Apr 7, 2025
adf43bf
Store PQ ID in usagereporting
bonnici Apr 7, 2025
3fc8047
Merge branch 'dev' into njm/P-416/pq-reporting
bonnici Apr 7, 2025
24c66da
Fix lint - include operation name and sig only in EmptyPlan errors
bonnici Apr 7, 2025
d8513dc
Fixes
bonnici Apr 7, 2025
d372dcd
Add changset
bonnici Apr 7, 2025
9d817b6
Add tests
bonnici Apr 7, 2025
a96f8e9
lint fix
bonnici Apr 7, 2025
35a1fa3
Merge branch 'dev' into njm/P-416/pq-reporting
bonnici Apr 9, 2025
a31bfbb
PR feedback
bonnici Apr 9, 2025
6ddd998
Add missing dependency
bonnici Apr 9, 2025
097c583
Merge branch 'dev' into njm/P-416/pq-reporting
bonnici Apr 10, 2025
9743b81
Change UsageReporting to enum
bonnici Apr 10, 2025
aec51eb
Remove for_error func
bonnici Apr 10, 2025
433e9ec
Fix changeset
bonnici Apr 10, 2025
5a0e4a1
Small fixes
bonnici Apr 10, 2025
6dbb2b4
Updater with_pq_id to always require a pq id
bonnici Apr 11, 2025
2720134
Merge branch 'dev' into njm/P-416/pq-reporting
bonnici Apr 11, 2025
94d1265
Fix for PQ with multiple operation IDs
bonnici Apr 11, 2025
93702d5
More refactoring/fixing of op name/op sig/stats report key stuff
bonnici Apr 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changesets/feat_persisted_query_reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Enables reporting of persisted query usage by PQ ID to Apollo ([PR #7166](https://github.com/apollographql/router/pull/7166))

This change allows the router to report usage metrics by persisted query ID to Apollo, so that we can show usage stats for PQs.

By [@bonnici](https://github.com/bonnici) in https://github.com/apollographql/router/pull/7166
179 changes: 156 additions & 23 deletions apollo-router/src/apollo_studio_interop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ use apollo_compiler::schema::ExtendedType;
use apollo_compiler::validation::Valid;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;

use crate::json_ext::Object;
use crate::json_ext::Value as JsonValue;
use crate::plugins::telemetry::apollo_exporter::proto::reports::QueryMetadata;
use crate::plugins::telemetry::config::ApolloSignatureNormalizationAlgorithm;
use crate::spec::Fragments;
use crate::spec::Query;
Expand Down Expand Up @@ -161,17 +163,143 @@ impl AddAssign<ReferencedEnums> for AggregatedExtendedReferenceStats {
}
}

#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageReportingOperationDetails {
/// The operation name, or None if there is no operation name
operation_name: Option<String>,
/// The normalized operation signature, or None if there is no valid signature
operation_signature: Option<String>,
/// a list of all types and fields referenced in the query
#[serde(default)]
referenced_fields_by_type: HashMap<String, ReferencedFieldsForType>,
}

/// UsageReporting fields, that will be used to send stats to uplink/studio
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageReporting {
pub(crate) enum UsageReporting {
Operation(UsageReportingOperationDetails),
PersistedQuery {
operation_details: UsageReportingOperationDetails,
persisted_query_id: String,
},
Error(String),
}

impl UsageReporting {
pub(crate) fn with_pq_id(&self, maybe_pq_id: Option<String>) -> UsageReporting {
match self {
UsageReporting::Operation(op_details)
| UsageReporting::PersistedQuery {
operation_details: op_details,
..
} => match maybe_pq_id {
Some(pq_id) => UsageReporting::PersistedQuery {
operation_details: op_details.clone(),
persisted_query_id: pq_id,
},
None => UsageReporting::Operation(op_details.clone()),
},
// PQ ID has no effect on errors
UsageReporting::Error { .. } => self.clone(),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fn feels a little weird to me that we're only maybe making a pq report. It would be a bit cleaner if we forced the caller to say if it's a pq or not (make pq_id non-optional), but that'd still be weird in the error case. Not sure of a better solution and I'm not super opinionated about it 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated it so PQ ID is required, which makes it a bit nicer.


/// The `stats_report_key` is a unique identifier derived from schema and query.
/// Metric data sent to Studio must be aggregated
/// Metric data sent to Studio must be aggregated
/// via grouped key of (`client_name`, `client_version`, `stats_report_key`).
pub(crate) stats_report_key: String,
/// a list of all types and fields referenced in the query
#[serde(default)]
pub(crate) referenced_fields_by_type: HashMap<String, ReferencedFieldsForType>,
/// For errors, the report key is of the form "## <error name>\n".
/// For operations requested by PQ, the report key is of the form "pq# <pq id>".
/// For operations not requested by PQ, the report key is of the form "# <op name>\n<op sig>".
/// Note that this combination of operation name and signature is sometimes referred to in code as
/// "operation signature" even though it also contains the operation name as the first line.
pub(crate) fn get_stats_report_key(&self) -> String {
match self {
UsageReporting::Operation(operation_details) => {
let stats_report_key_op_name = operation_details
.operation_name
.as_deref()
.unwrap_or("-")
.to_string();
format!(
"# {}\n{}",
stats_report_key_op_name,
self.get_operation_signature(),
)
}
UsageReporting::PersistedQuery {
persisted_query_id, ..
} => {
format!("pq# {}", persisted_query_id)
}
UsageReporting::Error(error_key) => format!("## {}\n", error_key),
}
}

pub(crate) fn get_operation_signature(&self) -> String {
match self {
UsageReporting::Operation(operation_details)
| UsageReporting::PersistedQuery {
operation_details, ..
} => operation_details
.operation_signature
.clone()
.unwrap_or("".to_string()),
UsageReporting::Error { .. } => "".to_string(),
}
}

pub(crate) fn get_operation_id(&self) -> String {
let string_to_hash = match self {
UsageReporting::Operation { .. } | UsageReporting::PersistedQuery { .. } => {
self.get_stats_report_key()
}
UsageReporting::Error(error_key) => format!("# # {}\n", error_key),
};

let mut hasher = sha1::Sha1::new();
hasher.update(string_to_hash.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}

pub(crate) fn get_operation_name(&self) -> String {
match self {
UsageReporting::Operation(operation_details)
| UsageReporting::PersistedQuery {
operation_details, ..
} => operation_details
.operation_name
.clone()
.unwrap_or("".to_string()),
UsageReporting::Error(error_key) => format!("# {}", error_key),
}
}

pub(crate) fn get_referenced_fields(&self) -> HashMap<String, ReferencedFieldsForType> {
match self {
UsageReporting::Operation(operation_details)
| UsageReporting::PersistedQuery {
operation_details, ..
} => operation_details.referenced_fields_by_type.clone(),
UsageReporting::Error { .. } => HashMap::default(),
}
}

pub(crate) fn get_query_metadata(&self) -> Option<QueryMetadata> {
match self {
UsageReporting::PersistedQuery {
persisted_query_id, ..
} => Some(QueryMetadata {
name: self.get_operation_name(),
signature: self.get_operation_signature(),
pq_id: persisted_query_id.clone(),
}),
// For now we only want to populate query metadata for PQ operations
_ => None,
}
}
}

/// A list of fields that will be resolved for a given type
Expand All @@ -186,9 +314,10 @@ pub(crate) struct ReferencedFieldsForType {
pub(crate) is_interface: bool,
}

/// Generate a UsageReporting containing the stats_report_key (a normalized version of the operation signature)
/// and referenced fields of an operation. The document used to generate the signature and for the references can be
/// different to handle cases where the operation has been filtered, but we want to keep the same signature.
/// Generate a UsageReporting containing the data required to generate a stats_report_key (either a normalized version of
/// the operation signature or an error key or a PQ ID) and referenced fields of an operation. The document used to
/// generate the signature and for the references can be different to handle cases where the operation has been filtered,
/// but we want to keep the same signature.
pub(crate) fn generate_usage_reporting(
signature_doc: &ExecutableDocument,
references_doc: &ExecutableDocument,
Expand Down Expand Up @@ -361,13 +490,22 @@ struct UsageGenerator<'a> {

impl UsageGenerator<'_> {
fn generate_usage_reporting(&mut self) -> UsageReporting {
UsageReporting {
stats_report_key: self.generate_stats_report_key(),
UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: self.get_operation_name(),
operation_signature: self.generate_normalized_signature(),
referenced_fields_by_type: self.generate_apollo_reporting_refs(),
}
})
}

fn generate_stats_report_key(&mut self) -> String {
fn get_operation_name(&self) -> Option<String> {
self.signature_doc
.operations
.get(self.operation_name.as_deref())
.ok()
.and_then(|operation| operation.name.as_ref().map(|node| node.to_string()))
}

fn generate_normalized_signature(&mut self) -> Option<String> {
self.fragments_map.clear();

match self
Expand All @@ -376,10 +514,10 @@ impl UsageGenerator<'_> {
.get(self.operation_name.as_deref())
.ok()
{
None => "".to_string(),
None => None,
Some(operation) => {
self.extract_signature_fragments(&operation.selection_set);
self.format_operation_for_report(operation)
Some(self.format_operation_signature_for_report(operation))
}
}
}
Expand Down Expand Up @@ -410,15 +548,10 @@ impl UsageGenerator<'_> {
}
}

fn format_operation_for_report(&self, operation: &Node<Operation>) -> String {
// The result in the name of the operation
let op_name = match &operation.name {
None => "-".into(),
Some(node) => node.to_string(),
};
let mut result = format!("# {}\n", op_name);
fn format_operation_signature_for_report(&self, operation: &Node<Operation>) -> String {
let mut result = String::new();

// Followed by a sorted list of fragments
// The signature starts with a sorted list of fragments
let mut sorted_fragments: Vec<_> = self.fragments_map.iter().collect();
sorted_fragments.sort_by_key(|&(k, _)| k);

Expand Down
109 changes: 108 additions & 1 deletion apollo-router/src/apollo_studio_interop/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::*;
use crate::Configuration;

fn assert_expected_signature(actual: &UsageReporting, expected_sig: &str) {
assert_eq!(actual.stats_report_key, expected_sig);
assert_eq!(actual.get_stats_report_key(), expected_sig);
}

macro_rules! assert_extended_references {
Expand Down Expand Up @@ -300,3 +300,110 @@ async fn test_enums_from_response_fragments() {
let generated = enums_from_response(query_str, op_name, schema_str, response_str);
assert_enums_from_response!(&generated);
}

#[test]
fn apollo_operation_id_hash() {
let usage_reporting = UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: Some("IgnitionMeQuery".to_string()),
operation_signature: Some("query IgnitionMeQuery{me{id}}".to_string()),
referenced_fields_by_type: HashMap::new(),
});

assert_eq!(
"d1554552698157b05c2a462827fb4367a4548ee5",
usage_reporting.get_operation_id()
);
}

// The Apollo operation ID hash for these errors is based on a slightly different string. E.g. instead of hashing
// "## GraphQLValidationFailure\n" we should hash "# # GraphQLValidationFailure".
#[test]
fn apollo_error_operation_id_hash() {
assert_eq!(
"ea4f152696abedca148b016d72df48842b713697",
UsageReporting::Error("GraphQLValidationFailure".into()).get_operation_id()
);
assert_eq!(
"3f410834f13153f401ffe73f7e454aa500d10bf7",
UsageReporting::Error("GraphQLParseFailure".into()).get_operation_id()
);
assert_eq!(
"7486043da2085fed407d942508a572ef88dc8120",
UsageReporting::Error("GraphQLUnknownOperationName".into()).get_operation_id()
);
}

#[test]
fn test_get_stats_report_key() {
let usage_reporting_for_errors = UsageReporting::Error("GraphQLParseFailure".into());
assert_eq!(
"## GraphQLParseFailure\n",
usage_reporting_for_errors.get_stats_report_key()
);

let usage_reporting_for_pq = UsageReporting::PersistedQuery {
operation_details: UsageReportingOperationDetails {
operation_name: Some("SomeQuery".into()),
operation_signature: Some("query SomeQuery{thing{id}}".into()),
referenced_fields_by_type: HashMap::new(),
},
persisted_query_id: "SomePqId".into(),
};
assert_eq!(
"pq# SomePqId",
usage_reporting_for_pq.get_stats_report_key()
);

let usage_reporting_for_named_operation =
UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: Some("SomeQuery".into()),
operation_signature: Some("query SomeQuery{thing{id}}".into()),
referenced_fields_by_type: HashMap::new(),
});
assert_eq!(
"# SomeQuery\nquery SomeQuery{thing{id}}",
usage_reporting_for_named_operation.get_stats_report_key()
);

let usage_reporting_for_unnamed_operation =
UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: None,
operation_signature: Some("query{thing{id}}".into()),
referenced_fields_by_type: HashMap::new(),
});
assert_eq!(
"# -\nquery{thing{id}}",
usage_reporting_for_unnamed_operation.get_stats_report_key()
);
}

#[test]
fn test_get_operation_name() {
let usage_reporting_for_errors = UsageReporting::Error("GraphQLParseFailure".into());
assert_eq!(
"# GraphQLParseFailure",
usage_reporting_for_errors.get_operation_name()
);

let usage_reporting_for_named_operation =
UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: Some("SomeQuery".into()),
operation_signature: Some("query SomeQuery{thing{id}}".into()),
referenced_fields_by_type: HashMap::new(),
});
assert_eq!(
"SomeQuery",
usage_reporting_for_named_operation.get_operation_name()
);

let usage_reporting_for_unnamed_operation =
UsageReporting::Operation(UsageReportingOperationDetails {
operation_name: None,
operation_signature: Some("query{thing{id}}".into()),
referenced_fields_by_type: HashMap::new(),
});
assert_eq!(
"",
usage_reporting_for_unnamed_operation.get_operation_name()
);
}
10 changes: 4 additions & 6 deletions apollo-router/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//! Router errors.
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;

Expand Down Expand Up @@ -271,7 +270,7 @@ pub(crate) enum QueryPlannerError {
JoinError(String),

/// empty query plan. This behavior is unexpected and we suggest opening an issue to apollographql/router with a reproduction.
EmptyPlan(UsageReporting), // usage_reporting_signature
EmptyPlan(String), // usage_reporting stats_report_key

/// unhandled planner result
UnhandledPlannerResult,
Expand Down Expand Up @@ -434,10 +433,9 @@ impl IntoGraphQLErrors for QueryPlannerError {
impl QueryPlannerError {
pub(crate) fn usage_reporting(&self) -> Option<UsageReporting> {
match self {
QueryPlannerError::SpecError(e) => Some(UsageReporting {
stats_report_key: e.get_error_key().to_string(),
referenced_fields_by_type: HashMap::new(),
}),
QueryPlannerError::SpecError(e) => {
Some(UsageReporting::Error(e.get_error_key().to_string()))
}
_ => None,
}
}
Expand Down
Loading