Skip to content

Commit 8e248b9

Browse files
cursoragentlovasoa
andcommitted
feat: Add Server-Timing header support
Co-authored-by: contact <[email protected]>
1 parent 8addb16 commit 8e248b9

File tree

4 files changed

+79
-3
lines changed

4 files changed

+79
-3
lines changed

src/render.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ pub enum PageContext {
8080
/// Handles the first SQL statements, before the headers have been sent to
8181
pub struct HeaderContext {
8282
app_state: Arc<AppState>,
83-
request_context: RequestContext,
83+
pub request_context: RequestContext,
8484
pub writer: ResponseWriter,
8585
response: HttpResponseBuilder,
8686
has_status: bool,
@@ -368,7 +368,13 @@ impl HeaderContext {
368368
Ok(PageContext::Header(self))
369369
}
370370

371-
async fn start_body(self, data: JsonValue) -> anyhow::Result<PageContext> {
371+
async fn start_body(mut self, data: JsonValue) -> anyhow::Result<PageContext> {
372+
if let Some(ref timing) = self.request_context.server_timing {
373+
let header_value = timing.as_header_value();
374+
if !header_value.is_empty() {
375+
self.response.insert_header(("Server-Timing", header_value));
376+
}
377+
}
372378
let html_renderer =
373379
HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data)
374380
.await
@@ -382,6 +388,12 @@ impl HeaderContext {
382388
}
383389

384390
pub fn close(mut self) -> HttpResponse {
391+
if let Some(ref timing) = self.request_context.server_timing {
392+
let header_value = timing.as_header_value();
393+
if !header_value.is_empty() {
394+
self.response.insert_header(("Server-Timing", header_value));
395+
}
396+
}
385397
self.response.finish()
386398
}
387399
}

src/webserver/http.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::webserver::content_security_policy::ContentSecurityPolicy;
77
use crate::webserver::database::execute_queries::stop_at_first_error;
88
use crate::webserver::database::{execute_queries::stream_query_results_with_conn, DbItem};
99
use crate::webserver::http_request_info::extract_request_info;
10+
use crate::webserver::server_timing::ServerTiming;
1011
use crate::webserver::ErrorWithStatus;
1112
use crate::{AppConfig, AppState, ParsedSqlFile, DEFAULT_404_FILE};
1213
use actix_web::dev::{fn_service, ServiceFactory, ServiceRequest};
@@ -46,6 +47,7 @@ pub struct RequestContext {
4647
pub is_embedded: bool,
4748
pub source_path: PathBuf,
4849
pub content_security_policy: ContentSecurityPolicy,
50+
pub server_timing: Option<ServerTiming>,
4951
}
5052

5153
async fn stream_response(stream: impl Stream<Item = DbItem>, mut renderer: AnyRenderBodyContext) {
@@ -104,7 +106,14 @@ async fn build_response_header_and_stream<S: Stream<Item = DbItem>>(
104106
let writer = ResponseWriter::new(sender);
105107
let mut head_context = HeaderContext::new(app_state, request_context, writer);
106108
let mut stream = Box::pin(database_entries);
109+
let mut first_row = true;
107110
while let Some(item) = stream.next().await {
111+
if first_row {
112+
if let Some(ref mut timing) = head_context.request_context.server_timing {
113+
timing.record("query");
114+
}
115+
first_row = false;
116+
}
108117
let page_context = match item {
109118
DbItem::Row(data) => head_context.handle_row(data).await?,
110119
DbItem::FinishedQuery => {
@@ -167,21 +176,32 @@ async fn render_sql(
167176
let app_state = srv_req
168177
.app_data::<web::Data<AppState>>()
169178
.ok_or_else(|| ErrorInternalServerError("no state"))?
170-
.clone() // Cheap reference count increase
179+
.clone()
171180
.into_inner();
172181

182+
let mut server_timing = if !app_state.config.environment.is_prod() {
183+
Some(ServerTiming::new())
184+
} else {
185+
None
186+
};
187+
173188
let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state))
174189
.await
175190
.map_err(|e| anyhow_err_to_actix(e, &app_state))?;
176191
log::debug!("Received a request with the following parameters: {req_param:?}");
177192

193+
if let Some(ref mut timing) = server_timing {
194+
timing.record("parse");
195+
}
196+
178197
let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();
179198
let source_path: PathBuf = sql_file.source_path.clone();
180199
actix_web::rt::spawn(async move {
181200
let request_context = RequestContext {
182201
is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"),
183202
source_path,
184203
content_security_policy: ContentSecurityPolicy::with_random_nonce(),
204+
server_timing,
185205
};
186206
let mut conn = None;
187207
let database_entries_stream =

src/webserver/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub mod http_client;
3838
pub mod http_request_info;
3939
mod https;
4040
pub mod request_variables;
41+
pub mod server_timing;
4142

4243
pub use database::Database;
4344
pub use error_with_status::ErrorWithStatus;

src/webserver/server_timing.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use std::time::Instant;
2+
3+
#[derive(Debug, Clone)]
4+
pub struct ServerTiming {
5+
start: Instant,
6+
events: Vec<TimingEvent>,
7+
}
8+
9+
#[derive(Debug, Clone)]
10+
struct TimingEvent {
11+
name: &'static str,
12+
duration_ms: f64,
13+
}
14+
15+
impl ServerTiming {
16+
#[must_use]
17+
pub fn new() -> Self {
18+
Self {
19+
start: Instant::now(),
20+
events: Vec::new(),
21+
}
22+
}
23+
24+
pub fn record(&mut self, name: &'static str) {
25+
let duration_ms = self.start.elapsed().as_secs_f64() * 1000.0;
26+
self.events.push(TimingEvent { name, duration_ms });
27+
}
28+
29+
#[must_use]
30+
pub fn as_header_value(&self) -> String {
31+
self.events
32+
.iter()
33+
.map(|event| format!("{};dur={:.2}", event.name, event.duration_ms))
34+
.collect::<Vec<_>>()
35+
.join(", ")
36+
}
37+
}
38+
39+
impl Default for ServerTiming {
40+
fn default() -> Self {
41+
Self::new()
42+
}
43+
}

0 commit comments

Comments
 (0)