postrust_lambda/lib.rs
1//! Postrust Lambda library.
2//!
3//! What the `postrust-lambda` binary is made of, for a Lambda binary of your
4//! own: one that serves routes of yours beside the API, added with
5//! [`postrust_server::Builder`].
6//!
7//! ```no_run
8//! use axum::{routing::post, Router};
9//! use postrust_lambda::postrust_server::{AppState, Builder, Host};
10//! use std::sync::Arc;
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<(), postrust_lambda::Error> {
14//! postrust_lambda::init_tracing();
15//! let hooks: Router<Arc<AppState>> = Router::new().route("/stripe", post(|| async { "ok" }));
16//! let app = Builder::new(postrust_lambda::config_from_env()?, Host::LAMBDA)
17//! .nest("/hooks", hooks)
18//! .build()
19//! .await?;
20//! postrust_lambda::run(app).await
21//! }
22//! ```
23//!
24//! Build the application before calling [`run`], in the init phase rather
25//! than on the first request: the connection and the schema cache are paid
26//! for once per sandbox, and a database that cannot be reached fails the init
27//! -- which Lambda reports and retries -- instead of failing every invocation.
28
29#![warn(missing_docs)]
30
31pub use postrust_auth;
32pub use postrust_core;
33pub use postrust_response;
34pub use postrust_server;
35pub use postrust_sql;
36
37/// The error a Lambda handler returns, as the runtime takes it.
38pub use lambda_http::Error;
39
40/// Log as the server does: `RUST_LOG` if set, otherwise `PGRST_LOG_LEVEL`,
41/// as text or as JSON lines by `PGRST_LOG_FORMAT`.
42///
43/// No timestamps, which CloudWatch adds to every line itself. Installs the
44/// global subscriber, so call it once, first.
45pub fn init_tracing() {
46 let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| {
47 let level = std::env::var("PGRST_LOG_LEVEL")
48 .ok()
49 .and_then(|v| postrust_core::LogLevel::parse_config(&v))
50 .unwrap_or_else(|| postrust_core::AppConfig::default().log_level);
51 format!("postrust={}", level.to_tracing())
52 });
53 // `PGRST_LOG_FORMAT=json` as on the server, which CloudWatch Logs
54 // Insights can query by field. The request span and its `request_id` and
55 // `trace_id` come with the router, which is the server's.
56 let format = std::env::var("PGRST_LOG_FORMAT")
57 .ok()
58 .and_then(|v| postrust_core::LogFormat::parse_config(&v))
59 .unwrap_or_default();
60
61 // With the `otel` feature and an OTLP endpoint -- the ADOT collector
62 // extension, usually -- spans are exported beside the log. The log's
63 // filter then moves onto its own layer, as on the server, so the
64 // request's debug-level spans can be exported while the log stays at its
65 // level. Without one the subscriber is exactly what it always was.
66 #[cfg(feature = "otel")]
67 if let Some(otel) = postrust_server::otel::layer() {
68 use tracing_subscriber::layer::SubscriberExt as _;
69 use tracing_subscriber::util::SubscriberInitExt as _;
70 use tracing_subscriber::Layer as _;
71 let filter = match std::env::var_os("RUST_LOG") {
72 Some(_) => filter,
73 None => format!("{filter},opentelemetry=warn,opentelemetry_sdk=warn"),
74 };
75 let log = tracing_subscriber::fmt::layer()
76 .with_target(false)
77 .without_time();
78 let (text, json) = match format {
79 postrust_core::LogFormat::Text => (Some(log), None),
80 postrust_core::LogFormat::Json => (None, Some(log.json())),
81 };
82 tracing_subscriber::registry()
83 .with(
84 text.with_filter(tracing_subscriber::EnvFilter::new(&filter))
85 .and_then(json.with_filter(tracing_subscriber::EnvFilter::new(&filter))),
86 )
87 .with(otel)
88 .init();
89 postrust_server::otel::log_startup();
90 return;
91 }
92
93 let builder = tracing_subscriber::fmt()
94 .with_env_filter(tracing_subscriber::EnvFilter::new(filter))
95 .with_target(false)
96 .without_time();
97 match format {
98 postrust_core::LogFormat::Text => builder.init(),
99 postrust_core::LogFormat::Json => builder.json().init(),
100 }
101 #[cfg(feature = "otel")]
102 postrust_server::otel::log_startup();
103}
104
105/// The configuration from the environment, sized for a Lambda sandbox.
106///
107/// A sandbox answers one invocation at a time, so the server's default pool
108/// holds connections nothing will use -- and every concurrent sandbox holds
109/// its own, which is how a burst of Lambdas exhausts `max_connections`. The
110/// pool is two connections unless `PGRST_DB_POOL` or `PGRST_DB_POOL_SIZE`
111/// says otherwise; two rather than one because a GraphQL mutation holds a
112/// transaction while its resolvers read.
113pub fn config_from_env() -> Result<postrust_core::AppConfig, Error> {
114 let mut config = postrust_core::AppConfig::try_from_env()?;
115 if ["PGRST_DB_POOL", "PGRST_DB_POOL_SIZE"]
116 .iter()
117 .all(|var| std::env::var_os(var).is_none())
118 {
119 config.db_pool_size = 2;
120 }
121 Ok(config)
122}
123
124/// Serve `app` to the Lambda runtime until the sandbox is shut down.
125///
126/// With the `otel` feature, metrics are started here (over OTLP, if it is
127/// configured; a function has no admin port to serve `/metrics` on), and
128/// whatever was recorded during an invocation is exported before its response
129/// goes back. See [`flush_after_each_invocation`].
130pub async fn run(app: postrust_server::App) -> Result<(), Error> {
131 #[cfg(feature = "otel")]
132 {
133 postrust_server::otel::init_metrics(false);
134 if postrust_server::otel::exporting() {
135 let router = app.router.layer(FlushLayer);
136 return lambda_http::run(router).await;
137 }
138 }
139 lambda_http::run(app.router).await
140}
141
142/// Lambda freezes a sandbox the moment an invocation's response is handed
143/// back, and may never thaw it. The SDK exports in batches from a thread of
144/// its own, on a timer, so whatever an invocation recorded would sit in a
145/// frozen buffer -- for minutes, or for ever. So each invocation flushes
146/// before it answers: spans and metrics go to the collector (the ADOT
147/// extension, on localhost) first, and the response after.
148///
149/// That puts the export on the invocation's latency: a round trip to a
150/// collector in the same sandbox, a millisecond or two. It is the price the
151/// ADOT documentation describes, and the only way not to lose the data.
152///
153/// The request span closes before the flush -- it belongs to the router
154/// inside this layer -- so the invocation's own trace is in what is sent.
155#[cfg(feature = "otel")]
156#[derive(Clone, Copy, Debug, Default)]
157pub struct FlushLayer;
158
159#[cfg(feature = "otel")]
160impl<S> tower::Layer<S> for FlushLayer {
161 type Service = FlushAfter<S>;
162
163 fn layer(&self, inner: S) -> Self::Service {
164 FlushAfter { inner }
165 }
166}
167
168/// The service [`FlushLayer`] produces.
169#[cfg(feature = "otel")]
170#[derive(Clone, Debug)]
171pub struct FlushAfter<S> {
172 inner: S,
173}
174
175#[cfg(feature = "otel")]
176impl<S, R> tower::Service<R> for FlushAfter<S>
177where
178 S: tower::Service<R>,
179 S::Future: Send + 'static,
180 S::Response: Send + 'static,
181 S::Error: Send + 'static,
182{
183 type Response = S::Response;
184 type Error = S::Error;
185 type Future =
186 std::pin::Pin<Box<dyn std::future::Future<Output = Result<S::Response, S::Error>> + Send>>;
187
188 fn poll_ready(
189 &mut self,
190 cx: &mut std::task::Context<'_>,
191 ) -> std::task::Poll<Result<(), Self::Error>> {
192 self.inner.poll_ready(cx)
193 }
194
195 fn call(&mut self, request: R) -> Self::Future {
196 let answer = self.inner.call(request);
197 Box::pin(async move {
198 // Awaited to completion and dropped before the flush, and with
199 // it the request's span.
200 let response = answer.await;
201 postrust_server::otel::flush().await;
202 response
203 })
204 }
205}
206
207/// Whether [`run`] flushes after each invocation: the `otel` feature is
208/// built in and something is exporting. For a binary that serves the
209/// router itself, through `lambda_http::run`, and wants the same: wrap the
210/// router in [`FlushLayer`] when this is true.
211#[cfg(feature = "otel")]
212pub fn flush_after_each_invocation() -> bool {
213 postrust_server::otel::exporting()
214}