diff --git a/.changesets/feat_error_extensions_service.md b/.changesets/feat_error_extensions_service.md new file mode 100644 index 0000000000..f182e44ea8 --- /dev/null +++ b/.changesets/feat_error_extensions_service.md @@ -0,0 +1,29 @@ +### Add `extensions.service` for all subgraph errors ([PR #6191](https://github.com/apollographql/router/pull/6191)) + +If `include_subgraph_errors` is `true` for a particular subgraph, all errors originating in this subgraph will have the subgraph's name exposed as a `service` extension. + +For example, if subgraph errors are enabled, like so: +```yaml title="router.yaml" +include_subgraph_errors: + all: true # Propagate errors from all subgraphs +``` +Note: This option is enabled by default in the [dev mode](./configuration/overview#dev-mode-defaults). + +And this `products` subgraph returns an error, it will have a `service` extension: + +```json +{ + "data": null, + "errors": [ + { + "message": "Invalid product ID", + "path": [], + "extensions": { + "service": "products" + } + } + ] +} +``` + +By [@IvanGoncharov](https://github.com/IvanGoncharov) in https://github.com/apollographql/router/pull/6191 \ No newline at end of file diff --git a/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap b/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap index ece5fd3c9f..6ae04241f1 100644 --- a/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap +++ b/apollo-router/src/axum_factory/snapshots/apollo_router__axum_factory__tests__defer_is_not_buffered.snap @@ -26,7 +26,8 @@ expression: parts "@" ], "extensions": { - "code": "FETCH_ERROR" + "code": "FETCH_ERROR", + "service": "reviews" } } ], diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap index 9798af179e..0944c313df 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__missing_entities-2.snap @@ -28,7 +28,10 @@ expression: response "currentUser", "allOrganizations", 2 - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/src/plugins/include_subgraph_errors.rs b/apollo-router/src/plugins/include_subgraph_errors.rs index 3b399e0151..5641d0a506 100644 --- a/apollo-router/src/plugins/include_subgraph_errors.rs +++ b/apollo-router/src/plugins/include_subgraph_errors.rs @@ -42,36 +42,47 @@ impl Plugin for IncludeSubgraphErrors { } fn subgraph_service(&self, name: &str, service: subgraph::BoxService) -> subgraph::BoxService { - // Search for subgraph in our configured subgraph map. - // If we can't find it, use the "all" value - if !*self.config.subgraphs.get(name).unwrap_or(&self.config.all) { - let sub_name_response = name.to_string(); - let sub_name_error = name.to_string(); - return service - .map_response(move |mut response: SubgraphResponse| { - if !response.response.body().errors.is_empty() { + // Search for subgraph in our configured subgraph map. If we can't find it, use the "all" value + let include_subgraph_errors = *self.config.subgraphs.get(name).unwrap_or(&self.config.all); + + let sub_name_response = name.to_string(); + let sub_name_error = name.to_string(); + return service + .map_response(move |mut response: SubgraphResponse| { + let errors = &mut response.response.body_mut().errors; + if !errors.is_empty() { + if include_subgraph_errors { + for error in errors.iter_mut() { + error + .extensions + .entry("service") + .or_insert(sub_name_response.clone().into()); + } + } else { tracing::info!("redacted subgraph({sub_name_response}) errors"); - for error in response.response.body_mut().errors.iter_mut() { + for error in errors.iter_mut() { error.message = REDACTED_ERROR_MESSAGE.to_string(); error.extensions = Object::default(); } } - response - }) - // _error to stop clippy complaining about unused assignments... - .map_err(move |mut _error: BoxError| { + } + + response + }) + .map_err(move |error: BoxError| { + if include_subgraph_errors { + error + } else { // Create a redacted error to replace whatever error we have tracing::info!("redacted subgraph({sub_name_error}) error"); - _error = Box::new(crate::error::FetchError::SubrequestHttpError { + Box::new(crate::error::FetchError::SubrequestHttpError { status_code: None, service: "redacted".to_string(), reason: "redacted".to_string(), - }); - _error - }) - .boxed(); - } - service + }) + } + }) + .boxed(); } } @@ -104,7 +115,7 @@ mod test { use crate::Configuration; static UNREDACTED_PRODUCT_RESPONSE: Lazy = Lazy::new(|| { - Bytes::from_static(r#"{"data":{"topProducts":null},"errors":[{"message":"couldn't find mock for query {\"query\":\"query($first: Int) { topProducts(first: $first) { __typename upc } }\",\"variables\":{\"first\":2}}","path":[],"extensions":{"test":"value","code":"FETCH_ERROR"}}]}"#.as_bytes()) + Bytes::from_static(r#"{"data":{"topProducts":null},"errors":[{"message":"couldn't find mock for query {\"query\":\"query($first: Int) { topProducts(first: $first) { __typename upc } }\",\"variables\":{\"first\":2}}","path":[],"extensions":{"test":"value","code":"FETCH_ERROR","service":"products"}}]}"#.as_bytes()) }); static REDACTED_PRODUCT_RESPONSE: Lazy = Lazy::new(|| { diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap index 36f064b496..40f678bb4f 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_from_primary_on_deferred_responses-2.snap @@ -24,7 +24,10 @@ expression: stream.next_response().await.unwrap() "path": [ "computer", "errorField" - ] + ], + "extensions": { + "service": "computers" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap index d487599dc5..6cfe12e7a7 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_deferred_responses-2.snap @@ -17,7 +17,10 @@ expression: stream.next_response().await.unwrap() "message": "error user 0", "path": [ "currentUser" - ] + ], + "extensions": { + "service": "user" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap index c36f465d70..8cac0e7164 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_incremental_responses-2.snap @@ -23,7 +23,10 @@ expression: stream.next_response().await.unwrap() "activeOrganization", "suborga", 0 - ] + ], + "extensions": { + "service": "orga" + } } ] }, @@ -56,7 +59,10 @@ expression: stream.next_response().await.unwrap() "activeOrganization", "suborga", 2 - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap index bf618438ad..4299132063 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__errors_on_nullified_paths.snap @@ -20,7 +20,8 @@ expression: stream.next_response().await.unwrap() "bar" ], "extensions": { - "code": "NOT_FOUND" + "code": "NOT_FOUND", + "service": "S2" } } ] diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap index a4366f1d9a..a046e3aa13 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__missing_entities.snap @@ -18,7 +18,10 @@ expression: stream.next_response().await.unwrap() "path": [ "currentUser", "activeOrganization" - ] + ], + "extensions": { + "service": "orga" + } } ] } diff --git a/apollo-router/tests/integration/batching.rs b/apollo-router/tests/integration/batching.rs index 071ca5cb7a..7998a21528 100644 --- a/apollo-router/tests/integration/batching.rs +++ b/apollo-router/tests/integration/batching.rs @@ -147,6 +147,8 @@ async fn it_batches_with_errors_in_single_graph() -> Result<(), BoxError> { - errors: - message: expected error in A path: [] + extensions: + service: a - data: entryA: index: 2 @@ -200,9 +202,13 @@ async fn it_batches_with_errors_in_multi_graph() -> Result<(), BoxError> { - errors: - message: expected error in A path: [] + extensions: + service: a - errors: - message: expected error in B path: [] + extensions: + service: b - data: entryA: index: 2 @@ -256,6 +262,7 @@ async fn it_handles_short_timeouts() -> Result<(), BoxError> { path: [] extensions: code: REQUEST_TIMEOUT + service: b - data: entryA: index: 1 @@ -264,6 +271,7 @@ async fn it_handles_short_timeouts() -> Result<(), BoxError> { path: [] extensions: code: REQUEST_TIMEOUT + service: b "###); } @@ -331,16 +339,19 @@ async fn it_handles_indefinite_timeouts() -> Result<(), BoxError> { path: [] extensions: code: REQUEST_TIMEOUT + service: b - errors: - message: Request timed out path: [] extensions: code: REQUEST_TIMEOUT + service: b - errors: - message: Request timed out path: [] extensions: code: REQUEST_TIMEOUT + service: b "###); } @@ -568,6 +579,7 @@ async fn it_handles_cancelled_by_coprocessor() -> Result<(), BoxError> { path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 0 @@ -576,6 +588,7 @@ async fn it_handles_cancelled_by_coprocessor() -> Result<(), BoxError> { path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 1 @@ -725,6 +738,7 @@ async fn it_handles_single_request_cancelled_by_coprocessor() -> Result<(), BoxE path: [] extensions: code: ERR_NOT_ALLOWED + service: a - data: entryB: index: 2 diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap index 07df294289..83a52acd05 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_rate_limit-2.snap @@ -2,4 +2,4 @@ source: apollo-router/tests/integration/traffic_shaping.rs expression: response --- -"{\"data\":null,\"errors\":[{\"message\":\"Your request has been rate limited\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_RATE_LIMITED\"}}]}" +"{\"data\":null,\"errors\":[{\"message\":\"Your request has been rate limited\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_RATE_LIMITED\",\"service\":\"products\"}}]}" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap index 407674dfff..0364f2b734 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__traffic_shaping__subgraph_timeout.snap @@ -2,4 +2,4 @@ source: apollo-router/tests/integration/traffic_shaping.rs expression: response --- -"{\"data\":null,\"errors\":[{\"message\":\"Request timed out\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_TIMEOUT\"}}]}" +"{\"data\":null,\"errors\":[{\"message\":\"Request timed out\",\"path\":[],\"extensions\":{\"code\":\"REQUEST_TIMEOUT\",\"service\":\"products\"}}]}" diff --git a/apollo-router/tests/integration/subgraph_response.rs b/apollo-router/tests/integration/subgraph_response.rs index 52fc56fa27..e37a0da067 100644 --- a/apollo-router/tests/integration/subgraph_response.rs +++ b/apollo-router/tests/integration/subgraph_response.rs @@ -81,6 +81,125 @@ async fn test_subgraph_returning_different_typename_on_query_root() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_for_subgraph_error() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"] + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path":["topProducts"], + "extensions": { + "service": "products" + } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_is_preserved_for_subgraph_error() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path": ["topProducts"], + "extensions": { + "service": 42, + } + }] + }))) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": { "topProducts": null }, + "errors": [{ + "message": "Some error on subgraph", + "path":["topProducts"], + "extensions": { + "service": 42, + } + }] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_valid_extensions_service_for_invalid_subgraph_response() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config(CONFIG) + .responder(ResponseTemplate::new(200)) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let (_trace_id, response) = router + .execute_query(&json!({ "query": "{ topProducts { name } }" })) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ + "data": null, + "errors": [ + { + "message": "HTTP fetch failed from 'products': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "path": [], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "products", + "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", + "http": { "status": 200 } + } + } + ] + }) + ); + + router.graceful_shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn test_valid_error_locations() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() @@ -116,7 +235,8 @@ async fn test_valid_error_locations() -> Result<(), BoxError> { { "line": 1, "column": 2 }, { "line": 3, "column": 4 }, ], - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -153,7 +273,8 @@ async fn test_empty_error_locations() -> Result<(), BoxError> { "data": { "topProducts": null }, "errors": [{ "message":"Some error on subgraph", - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -195,6 +316,7 @@ async fn test_invalid_error_locations() -> Result<(), BoxError> { "service": "products", "reason": "invalid `locations` within error: invalid type: boolean `true`, expected u32", "code": "SUBREQUEST_MALFORMED_RESPONSE", + "service": "products" } }] }) @@ -232,7 +354,8 @@ async fn test_invalid_error_locations_with_single_negative_one_location() -> Res "data": { "topProducts": null }, "errors": [{ "message":"Some error on subgraph", - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); @@ -277,7 +400,8 @@ async fn test_invalid_error_locations_contains_negative_one_location() -> Result { "line": 1, "column": 2 }, { "line": 3, "column": 4 }, ], - "path":["topProducts"] + "path":["topProducts"], + "extensions": { "service": "products" } }] }) ); diff --git a/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap index 4c44587cdd..67390167e7 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_dependent_fetch_failure.snap @@ -16,7 +16,10 @@ expression: response "path": [ "t", "u" - ] + ], + "extensions": { + "service": "Subgraph1" + } } ], "extensions": { diff --git a/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap index 4f28e80419..3232f642c0 100644 --- a/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap +++ b/apollo-router/tests/snapshots/set_context__set_context_unrelated_fetch_failure.snap @@ -10,7 +10,10 @@ expression: response "path": [ "t", "u" - ] + ], + "extensions": { + "service": "Subgraph2" + } } ], "extensions": { diff --git a/docs/source/routing/observability/subgraph-error-inclusion.mdx b/docs/source/routing/observability/subgraph-error-inclusion.mdx index d991a3e710..ee28a0672d 100644 --- a/docs/source/routing/observability/subgraph-error-inclusion.mdx +++ b/docs/source/routing/observability/subgraph-error-inclusion.mdx @@ -31,3 +31,21 @@ To report the subgraph errors to GraphOS that is a separate configuration that i ## Logging GraphQL request errors To log the GraphQL error responses (i.e. messages returned in the GraphQL `errors` array) from the router, see the [logging configuration documentation](/router/configuration/telemetry/exporters/logging/overview). +## Exposing subgraph name through error extensions +If `include_subgraph_errors` is `true` for a particular subgraph, all errors originating in this subgraph will have the subgraph's name exposed as a `service` extension. + +For example, if subgraph errors are enabled for the `products` subgraph and this subgraph returns an error, it will have a `service` extension: +```json +{ + "data": null, + "errors": [ + { + "message": "Invalid product ID", + "path": [], + "extensions": { + "service": "products", + } + } + ] +} +```