Skip to main content

webserver_base/analytics/
proxy.rs

1//! First-party proxies for Plausible and the Sentry browser SDK.
2//!
3//! Both are **byte relays**. Nothing is parsed, rewritten, or re-reported: the
4//! upstream receives exactly what the browser sent, and the browser receives
5//! exactly what the upstream returned, headers included. That matters most for
6//! Sentry — a server that *interpreted* a client error and re-raised it through
7//! its own SDK would file a browser problem as a server one, with the wrong
8//! stack and the wrong context. Relaying the envelope keeps it a genuine
9//! browser event.
10//!
11//! The point of proxying at all is that a same-origin request is
12//! indistinguishable from the site's own assets. Plausible puts the cost of not
13//! doing it at "typically between 5% and 25%" of visitors, depending on
14//! audience.
15
16use std::net::SocketAddr;
17
18use axum::body::Bytes;
19use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
20use axum::response::{IntoResponse, Response};
21use tracing::{error, instrument};
22
23use super::ip::resolve_true_client_ip_address;
24
25/// Fetches an upstream script and returns it with the upstream's own headers.
26///
27/// Content type and cache policy are echoed rather than invented, so the
28/// proxied script behaves exactly as the vendor intends it to.
29#[instrument(skip_all)]
30pub async fn relay_script(client: &reqwest::Client, upstream: &str) -> Response {
31    let response = match client.get(upstream).send().await {
32        Ok(response) => response,
33        Err(error) => {
34            error!("failed to fetch `{upstream}`: {error}");
35            return StatusCode::BAD_GATEWAY.into_response();
36        }
37    };
38
39    let status: StatusCode =
40        StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
41    let mut headers: HeaderMap = HeaderMap::new();
42    // ETag too, so a revalidation still costs nothing when the script has not
43    // changed.
44    for name in [header::CONTENT_TYPE, header::CACHE_CONTROL, header::ETAG] {
45        if let Some(value) = response.headers().get(&name)
46            && let Ok(value) = HeaderValue::from_bytes(value.as_bytes())
47        {
48            headers.insert(name, value);
49        }
50    }
51
52    match response.bytes().await {
53        Ok(body) => (status, headers, body).into_response(),
54        Err(error) => {
55            error!("failed to read `{upstream}`: {error}");
56            StatusCode::BAD_GATEWAY.into_response()
57        }
58    }
59}
60
61/// Forwards a Plausible event, carrying the real visitor IP.
62///
63/// `X-Forwarded-For` is not optional: without it Plausible's bot filter rejects
64/// proxied events outright, because every one of them would appear to originate
65/// from this server.
66#[instrument(skip_all)]
67pub async fn relay_event(
68    client: &reqwest::Client,
69    upstream: &str,
70    headers: &HeaderMap,
71    peer: SocketAddr,
72    body: Bytes,
73) -> Response {
74    let mut request: reqwest::RequestBuilder = client.post(upstream).body(body).header(
75        "X-Forwarded-For",
76        resolve_true_client_ip_address(peer, headers),
77    );
78    if let Some(user_agent) = headers.get(header::USER_AGENT) {
79        request = request.header(header::USER_AGENT, user_agent.clone());
80    }
81    if let Some(content_type) = headers.get(header::CONTENT_TYPE) {
82        request = request.header(header::CONTENT_TYPE, content_type.clone());
83    }
84
85    match request.send().await {
86        Ok(response) => StatusCode::from_u16(response.status().as_u16())
87            .unwrap_or(StatusCode::ACCEPTED)
88            .into_response(),
89        Err(error) => {
90            error!("failed to forward an analytics event: {error}");
91            // A dropped pageview must never surface to the visitor.
92            StatusCode::ACCEPTED.into_response()
93        }
94    }
95}
96
97/// Relays a Sentry envelope verbatim.
98///
99/// The body is passed through untouched — it is a complete envelope the browser
100/// SDK built, carrying its own stack trace, breadcrumbs and release. The
101/// destination comes from the configured DSN rather than from the envelope
102/// header, so this cannot be pointed at an arbitrary Sentry project.
103#[instrument(skip_all)]
104pub async fn relay_envelope(client: &reqwest::Client, upstream: &str, body: Bytes) -> Response {
105    match client
106        .post(upstream)
107        .header(header::CONTENT_TYPE, "application/x-sentry-envelope")
108        .body(body)
109        .send()
110        .await
111    {
112        Ok(response) => StatusCode::from_u16(response.status().as_u16())
113            .unwrap_or(StatusCode::OK)
114            .into_response(),
115        Err(error) => {
116            error!("failed to relay a Sentry envelope: {error}");
117            StatusCode::OK.into_response()
118        }
119    }
120}