Skip to content

Commit 6542a82

Browse files
authored
feat(router): allow to customize access log message by plugin system (#1379)
1 parent 011a290 commit 6542a82

7 files changed

Lines changed: 144 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
hive-router-internal: minor
3+
hive-router: minor
4+
hive-router-plan-executor: patch
5+
---
6+
7+
# Expose the summary message to the plugin system
8+
9+
Plugins can now override the request summary log line's message via `hive_router::set_summary_message(message)`, callable from any hook.
10+
11+
Fixes https://github.com/graphql-hive/router/issues/1378

bin/router/src/lib.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,26 @@ pub fn set_summary_attribute(key: impl Into<String>, value: impl Into<sonic_rs::
143143
summary::record(|s| s.set_custom(key, value));
144144
}
145145

146+
/// Returns the current request log summary for the current request, if one exists.
147+
pub fn get_current_summary() -> Option<Arc<summary::RequestSummary>> {
148+
summary::current_summary()
149+
}
150+
146151
/// Lets plugins attach a custom correlation to every log line of the current request (not
147-
/// just the summary), e.g. a tenant or project id extracted from the URL. Setting the same
148-
/// key again overwrites the previous value. A no-op outside a request.
152+
/// just the summary), e.g. a tenant or project id extracted from the URL.
153+
/// Setting the same key again overwrites the previous value. A no-op outside a request.
149154
pub fn set_log_correlation(key: impl Into<String>, value: impl std::fmt::Display) {
150155
request_id::set_correlation(key, value);
151156
}
152157

158+
/// Lets plugins override the request summary log line's message.
159+
/// This can be called only once per request, and only during the request's lifetime.
160+
/// Calling it more than once, for the same request is a no-op.
161+
/// Calling it outside of a request is a no-op.
162+
pub fn set_summary_message(message: impl Into<std::borrow::Cow<'static, str>>) {
163+
summary::record(|s| s.set_message(message));
164+
}
165+
153166
#[inline]
154167
fn obtain_header_value<'a>(
155168
header_map: &'a ntex::http::HeaderMap,
@@ -225,11 +238,14 @@ async fn graphql_endpoint_handler(
225238
}
226239
.await;
227240

228-
// for streamed responses the summary must be emitted when the stream ends, not now
229-
// we do that by attaching the summary guard to the response body, so it will be emitted
230-
// when the stream terminates (or the client disconnects)
231241
if response_mode.can_stream() {
242+
// Streamed responses must defer printing until the stream ends (or disconnects), not
243+
// now - attaching the guard to the response body achieves that.
232244
response = summary_guard.attach_to_response(response);
245+
} else {
246+
// Store the guard in the response's own extensions instead of allowing it to drop now.
247+
// This allows us to emit the summary log line only after the response really completes sending
248+
response.extensions_mut().insert(summary_guard);
233249
}
234250

235251
let graphql_operation = read_graphql_operation_metric_identity(&request);

e2e/src/telemetry/logging.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use hive_router::{
1111
plugins::hooks::on_http_request::{OnHttpRequestHookPayload, OnHttpRequestHookResult},
1212
plugins::hooks::on_plugin_init::{OnPluginInitPayload, OnPluginInitResult},
1313
plugins::plugin_trait::{RouterPlugin, StartHookPayload},
14-
set_summary_attribute,
14+
set_summary_attribute, set_summary_message,
1515
};
1616
use hive_router_internal::telemetry::logging::targets;
1717
use http::{HeaderMap, HeaderName, HeaderValue};
@@ -702,6 +702,7 @@ impl RouterPlugin for TestDebugLogPlugin {
702702
payload: OnHttpRequestHookPayload<'req>,
703703
) -> OnHttpRequestHookResult<'req> {
704704
debug!("i'm just a test");
705+
set_summary_message("custom summary message from plugin");
705706
set_summary_attribute("test_debug_log.simple", "hello-from-plugin");
706707
set_summary_attribute(
707708
"test_debug_log.nested",
@@ -808,3 +809,32 @@ plugins:
808809
})
809810
);
810811
}
812+
813+
#[ntex::test]
814+
async fn plugin_can_customize_summary_message() {
815+
let (_subgraphs, router) = setup_router(
816+
router_with_telemetry(
817+
"\
818+
log:
819+
level: info
820+
format: json
821+
plugins:
822+
test_debug_log:
823+
enabled: true
824+
",
825+
)
826+
.register_plugin::<TestDebugLogPlugin>(),
827+
)
828+
.await;
829+
830+
let stdout_log = router
831+
.send_graphql_request(TEST_QUERY, None, None)
832+
.capture_stdout_json()
833+
.await;
834+
835+
let req_summary = log_line_by_target(&stdout_log, targets::SUMMARY);
836+
assert_eq!(
837+
find_attr(req_summary, "message"),
838+
Some("custom summary message from plugin".to_string())
839+
);
840+
}

lib/internal/src/telemetry/logging/summary.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::collections::{BTreeMap, HashSet};
23
use std::error::Error;
34
use std::future::Future;
@@ -37,6 +38,7 @@ pub struct RequestSummary {
3738
pub duration_ms: AtomicU64,
3839
pub supergraph_identifier: AtomicU64,
3940
pub custom: Mutex<BTreeMap<String, sonic_rs::Value>>,
41+
pub message: OnceLock<Cow<'static, str>>,
4042
}
4143

4244
impl RequestSummary {
@@ -94,6 +96,11 @@ impl RequestSummary {
9496
}
9597
}
9698

99+
/// Overrides the summary log line's message. First call wins; later calls are no-ops.
100+
pub fn set_message(&self, message: impl Into<Cow<'static, str>>) {
101+
let _ = self.message.set(message.into());
102+
}
103+
97104
pub fn record_subgraph(&self, name: &str) {
98105
self.subgraph_requests.fetch_add(1, Relaxed);
99106
if let Ok(mut subgraphs) = self.involved_subgraphs.lock() {
@@ -116,6 +123,7 @@ impl RequestSummary {
116123

117124
info!(
118125
target: targets::SUMMARY,
126+
message = self.message.get().map(Cow::as_ref),
119127
client_name = self.client_name.get().map(String::as_str),
120128
client_version = self.client_version.get().map(String::as_str),
121129
operation_name = self.operation_name.get().map(String::as_str),
@@ -160,6 +168,10 @@ pub fn record(f: impl FnOnce(&RequestSummary)) {
160168
let _ = REQUEST_SUMMARY.try_with(|summary| f(summary));
161169
}
162170

171+
pub fn current_summary() -> Option<Arc<RequestSummary>> {
172+
REQUEST_SUMMARY.try_with(|summary| summary.clone()).ok()
173+
}
174+
163175
pub fn emit() {
164176
if !is_enabled() {
165177
return;
@@ -242,10 +254,15 @@ impl Drop for SummaryOnDrop {
242254
};
243255
summary.set_duration(self.started_at.elapsed());
244256

245-
match self.request_ids.take() {
246-
Some(ids) => REQUEST_IDENTIFIERS.sync_scope(ids, || summary.emit()),
247-
None => summary.emit(),
248-
}
257+
// Re-enter both task-locals before emitting: by now (especially for responses whose
258+
// body outlives the original request future) they may no longer be ambiently scoped,
259+
// but the formatters look up `custom`/`correlations` independently via their own
260+
// `try_with` at format time, so both must be active for that lookup to succeed.
261+
let request_ids = self.request_ids.take();
262+
REQUEST_SUMMARY.sync_scope(summary, || match request_ids {
263+
Some(ids) => REQUEST_IDENTIFIERS.sync_scope(ids, emit),
264+
None => emit(),
265+
});
249266
}
250267
}
251268

plugin_examples/Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugin_examples/custom_logger_correlation/src/plugin.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ use hive_router::plugins::hooks::on_http_request::{
22
OnHttpRequestHookPayload, OnHttpRequestHookResult,
33
};
44
use hive_router::plugins::hooks::on_plugin_init::{OnPluginInitPayload, OnPluginInitResult};
5-
use hive_router::plugins::plugin_trait::{RouterPlugin, StartHookPayload};
6-
use hive_router::{set_log_correlation, tracing};
5+
use hive_router::plugins::plugin_trait::{EndHookPayload, RouterPlugin, StartHookPayload};
6+
use hive_router::{
7+
async_trait, get_current_summary, set_log_correlation, set_summary_message, tracing,
8+
};
9+
use std::sync::atomic::Ordering::Relaxed;
10+
use std::time::Instant;
711

812
pub struct CustomLoggerCorrelationPlugin;
913

1014
const PROJECT_ID_KEY: &str = "project_id";
1115

16+
#[async_trait]
1217
impl RouterPlugin for CustomLoggerCorrelationPlugin {
1318
type Config = ();
1419

@@ -33,12 +38,37 @@ impl RouterPlugin for CustomLoggerCorrelationPlugin {
3338
.unwrap_or("unknown_project")
3439
.to_string();
3540

41+
let started_at = Instant::now();
42+
3643
// Attach it as soon as we know it, so every log line for the rest of this
3744
// request - including this plugin's own - carries the same correlation.
3845
set_log_correlation(PROJECT_ID_KEY, project_id);
3946

4047
tracing::debug!(target: "custom_logger_correlation", "on_http_request called");
4148

42-
payload.proceed()
49+
let method = payload.router_http_request.method().to_string();
50+
let path = payload.router_http_request.path().to_string();
51+
52+
payload.on_end(move |end_payload| {
53+
let current_summary = if let Some(summary) = get_current_summary() {
54+
summary
55+
} else {
56+
return end_payload.proceed();
57+
};
58+
59+
let operation_name = current_summary.operation_name.get().cloned();
60+
let status_code = current_summary.status_code.load(Relaxed);
61+
62+
set_summary_message(format!(
63+
"[status={}] [{}ms] {} {} {}",
64+
status_code,
65+
started_at.elapsed().as_millis(),
66+
method,
67+
path,
68+
operation_name.as_deref().unwrap_or("-")
69+
));
70+
71+
end_payload.proceed()
72+
})
4373
}
4474
}

plugin_examples/custom_logger_correlation/src/test.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,28 @@ mod custom_logger_correlation_tests {
4949
.and_then(serde_json::Value::as_str),
5050
Some("test")
5151
);
52+
53+
// The request summary line's message was customized by the plugin, using data
54+
// (status code, duration, operation name) only known once the request is done.
55+
let summary_line = stdout_log
56+
.lines_json
57+
.iter()
58+
.find(|line| {
59+
line.get("target").and_then(serde_json::Value::as_str) == Some("router::request")
60+
})
61+
.expect("missing request summary line");
62+
let message = summary_line
63+
.get("message")
64+
.and_then(serde_json::Value::as_str)
65+
.expect("missing summary message");
66+
assert!(
67+
message.starts_with("[status=200] ["),
68+
"unexpected summary message: {message}"
69+
);
70+
// the query is anonymous, so the operation name falls back to "-"
71+
assert!(
72+
message.ends_with("] POST /test/graphql -"),
73+
"unexpected summary message: {message}"
74+
);
5275
}
5376
}

0 commit comments

Comments
 (0)