plecto_host/filter.rs
1//! The public per-request filter API: [`LoadedFilter`].
2
3use std::time::{Duration, Instant, SystemTime};
4
5use crate::observe;
6use crate::pool::{HookResult, LoadedInner, TrustedPool};
7use crate::runtime::{WasmtimeInstance, WasmtimeRuntime};
8use crate::{
9 Hook, HttpRequest, HttpResponse, Isolation, LogLine, RequestBodyDecision, RequestDecision,
10 RequestTrace, ResponseDecision, RunError, SpanOutcome,
11};
12
13/// A loaded filter, ready to run per request. Trusted filters reuse instances from a
14/// `TrustedPool` (checked out per request, ADR 000012); untrusted filters instantiate fresh
15/// each request.
16///
17/// A trap leaves the guest's linear memory undefined, so the host discards that instance and a
18/// later checkout rebuilds + re-inits one (self-heal, ADR 000006), with a pool-wide cooldown
19/// bounding re-init storms (review f000003 #5). The `Option` is the isolation discriminator —
20/// `None` means untrusted (fresh instance per request).
21pub struct LoadedFilter {
22 pub(crate) inner: LoadedInner<WasmtimeRuntime>,
23 pub(crate) trusted: Option<TrustedPool<WasmtimeInstance>>,
24}
25
26impl LoadedFilter {
27 pub fn isolation(&self) -> Isolation {
28 self.inner.isolation
29 }
30
31 /// Whether this filter reads the request body — i.e. it exports `on-request-body` (world
32 /// `filter-body`). The fast path buffers the body ONLY for a route with at least one such
33 /// filter; a route of header-only filters keeps the zero-copy streaming path (ADR 000038 /
34 /// ADR 000005 mechanism 2). Detected from the component's exports at load, so it is sound
35 /// (fail-closed): a filter cannot read the body without declaring it in the contract.
36 pub fn reads_body(&self) -> bool {
37 self.inner.runtime.body_export.is_some()
38 }
39
40 /// Run the request-side hook under the request's trace context (`trace`, ADR 000009). The
41 /// host times the call and emits one span — parented by `trace`, carrying the outcome and
42 /// the filter's host-log lines as events — to its `TelemetrySink`. Returns the typed
43 /// decision plus those log lines (the direct-access form), or a `RunError` the caller MUST
44 /// fail-closed on (deadline / trap / instantiation — never a pass-through to upstream).
45 pub fn on_request(
46 &self,
47 req: &HttpRequest,
48 trace: &RequestTrace,
49 ) -> std::result::Result<(RequestDecision, Vec<LogLine>), RunError> {
50 if !self.inner.sink.enabled() {
51 return self.run_on_request(req).map_err(|(err, _)| err);
52 }
53 let start = SystemTime::now();
54 let elapsed = Instant::now();
55 let result = self.run_on_request(req);
56 let outcome = match &result {
57 Ok((decision, _)) => SpanOutcome::from(decision),
58 Err((err, _)) => SpanOutcome::from(err),
59 };
60 self.emit_span(
61 trace,
62 Hook::OnRequest,
63 outcome,
64 start,
65 elapsed.elapsed(),
66 &result,
67 );
68 result.map_err(|(err, _)| err)
69 }
70
71 fn run_on_request(&self, req: &HttpRequest) -> HookResult<RequestDecision> {
72 self.inner.run_hook(self.trusted.as_ref(), |inst| {
73 self.inner.runtime.call_on_request(inst, req)
74 })
75 }
76
77 /// Run the request-side BODY hook (buffer-then-decide, ADR 000025). The host hands the filter
78 /// the fully-buffered request body; the filter returns the (possibly transformed) body to
79 /// continue, or a `short-circuit` response (synthesised before upstream is reached). Same
80 /// fail-closed contract and span emission as `on_request`.
81 pub fn on_request_body(
82 &self,
83 body: &[u8],
84 trace: &RequestTrace,
85 ) -> std::result::Result<(RequestBodyDecision, Vec<LogLine>), RunError> {
86 if !self.inner.sink.enabled() {
87 return self.run_on_request_body(body).map_err(|(err, _)| err);
88 }
89 let start = SystemTime::now();
90 let elapsed = Instant::now();
91 let result = self.run_on_request_body(body);
92 let outcome = match &result {
93 Ok((RequestBodyDecision::Continue(_), _)) => SpanOutcome::Continue,
94 Ok((RequestBodyDecision::ShortCircuit(_), _)) => SpanOutcome::ShortCircuit,
95 Err((err, _)) => SpanOutcome::from(err),
96 };
97 self.emit_span(
98 trace,
99 Hook::OnRequestBody,
100 outcome,
101 start,
102 elapsed.elapsed(),
103 &result,
104 );
105 result.map_err(|(err, _)| err)
106 }
107
108 fn run_on_request_body(&self, body: &[u8]) -> HookResult<RequestBodyDecision> {
109 // Header-only filter: no `on-request-body` export, so the body never enters guest memory.
110 // The fast path already skips buffering (`reads_body()` is false); this is the defensive
111 // floor — pass the body through unchanged without instantiating anything.
112 if self.inner.runtime.body_export.is_none() {
113 return Ok((RequestBodyDecision::Continue(body.to_vec()), Vec::new()));
114 }
115 self.inner.run_hook(self.trusted.as_ref(), |inst| {
116 self.inner.runtime.call_body_hook(inst, body)
117 })
118 }
119
120 /// Build and emit the span for one filter execution (ADR 000009). The filter's host-log
121 /// lines become span events whether the call succeeded (`Ok`) or trapped (`Err`) — a `RunError`
122 /// still carries whatever logs (including fat-guest stdio, ADR 000063) were recovered from the
123 /// failed instance before it was discarded, so a trapping guest's own diagnostic output (e.g.
124 /// a panic message) still shows up in this span. Errors never abort emission — telemetry is
125 /// best-effort and out of the fail-closed path.
126 fn emit_span<T>(
127 &self,
128 trace: &RequestTrace,
129 hook: Hook,
130 outcome: SpanOutcome,
131 start: SystemTime,
132 duration: Duration,
133 result: &HookResult<T>,
134 ) {
135 let logs: &[LogLine] = match result {
136 Ok((_, logs)) => logs,
137 Err((_, logs)) => logs,
138 };
139 let span = observe::build_filter_span(
140 trace,
141 &self.inner.filter_id,
142 self.inner.isolation,
143 hook,
144 outcome,
145 start,
146 duration,
147 logs,
148 );
149 self.inner.sink.export(&span);
150 }
151
152 /// Run the response-side hook for one response. Same fail-closed contract as `on_request`.
153 /// `req` is the as-forwarded request snapshot (ADR 000073) — the request as it left the
154 /// request-side chain — passed to a 0.3 guest as `on-response`'s first parameter and
155 /// dropped by the 0.1 / 0.2 adapters.
156 pub fn on_response(
157 &self,
158 req: &HttpRequest,
159 resp: &HttpResponse,
160 trace: &RequestTrace,
161 ) -> std::result::Result<(ResponseDecision, Vec<LogLine>), RunError> {
162 if !self.inner.sink.enabled() {
163 return self.run_on_response(req, resp).map_err(|(err, _)| err);
164 }
165 let start = SystemTime::now();
166 let elapsed = Instant::now();
167 let result = self.run_on_response(req, resp);
168 let outcome = match &result {
169 Ok((decision, _)) => SpanOutcome::from(decision),
170 Err((err, _)) => SpanOutcome::from(err),
171 };
172 self.emit_span(
173 trace,
174 Hook::OnResponse,
175 outcome,
176 start,
177 elapsed.elapsed(),
178 &result,
179 );
180 result.map_err(|(err, _)| err)
181 }
182
183 fn run_on_response(
184 &self,
185 req: &HttpRequest,
186 resp: &HttpResponse,
187 ) -> HookResult<ResponseDecision> {
188 self.inner.run_hook(self.trusted.as_ref(), |inst| {
189 self.inner.runtime.call_on_response(inst, req, resp)
190 })
191 }
192}