Skip to main content

stealthscraper_rs/
events.rs

1//! Observability events emitted during a scrape, and sinks that consume them.
2//!
3//! [`ScraperEvent`] is a lightweight, borrowed value describing something that
4//! just happened (a challenge was detected, a proxy was rotated, a solve
5//! finished). [`EventSink`] is the output port; the scraper emits to whatever
6//! sink is configured. Two adapters ship:
7//!
8//! - [`NoopEventSink`] — the zero-overhead default.
9//! - [`LogEventSink`] — forwards events to the `log` crate at sensible levels.
10//!
11//! Implement [`EventSink`] yourself to wire events into metrics/telemetry.
12
13use std::fmt;
14use std::time::Duration;
15
16use crate::challenge::ChallengeKind;
17
18/// Something noteworthy that happened during a scrape.
19///
20/// Fields borrow to keep emission allocation-free on the hot path. `host` is
21/// optional because it may not always be derivable (e.g. an `about:blank` tab).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ScraperEvent<'a> {
24    /// A bot-protection challenge was identified on the page.
25    ChallengeDetected {
26        /// Target host, if known.
27        host: Option<&'a str>,
28        /// The classified challenge kind.
29        kind: ChallengeKind,
30    },
31    /// Waiting for a challenge to clear before re-checking.
32    Waiting {
33        /// Target host, if known.
34        host: Option<&'a str>,
35        /// The challenge being waited on.
36        kind: ChallengeKind,
37        /// How long the scraper will wait before re-checking.
38        delay: Duration,
39    },
40    /// The egress proxy was rotated after a hard block.
41    ProxyRotated {
42        /// Target host, if known.
43        host: Option<&'a str>,
44        /// The newly selected upstream proxy URL, if any.
45        upstream: Option<&'a str>,
46    },
47    /// The browser profile (fingerprint identity) was rotated via a relaunch.
48    ProfileRotated {
49        /// The User-Agent of the newly applied profile.
50        user_agent: &'a str,
51    },
52    /// The page was cleared (no challenge remaining).
53    SolveSucceeded {
54        /// Target host, if known.
55        host: Option<&'a str>,
56        /// Number of retry attempts taken.
57        attempts: u32,
58        /// Whether any challenge had to be cleared along the way.
59        challenged: bool,
60    },
61    /// The challenge could not be cleared.
62    SolveFailed {
63        /// Target host, if known.
64        host: Option<&'a str>,
65        /// The challenge kind at the point of failure.
66        kind: ChallengeKind,
67        /// Human-readable failure reason.
68        reason: &'a str,
69    },
70}
71
72impl fmt::Display for ScraperEvent<'_> {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        let host = |h: &Option<&str>| h.unwrap_or("<unknown>").to_string();
75        match self {
76            ScraperEvent::ChallengeDetected { host: h, kind } => {
77                write!(f, "challenge detected on {}: {kind:?}", host(h))
78            }
79            ScraperEvent::Waiting {
80                host: h,
81                kind,
82                delay,
83            } => {
84                write!(f, "waiting {delay:?} for {kind:?} on {}", host(h))
85            }
86            ScraperEvent::ProxyRotated { host: h, upstream } => {
87                write!(
88                    f,
89                    "rotated egress proxy to {} for {}",
90                    upstream.unwrap_or("<none>"),
91                    host(h)
92                )
93            }
94            ScraperEvent::ProfileRotated { user_agent } => {
95                write!(f, "rotated browser profile (user-agent: {user_agent})")
96            }
97            ScraperEvent::SolveSucceeded {
98                host: h,
99                attempts,
100                challenged,
101            } => write!(
102                f,
103                "solve succeeded on {} (attempts={attempts}, challenged={challenged})",
104                host(h)
105            ),
106            ScraperEvent::SolveFailed {
107                host: h,
108                kind,
109                reason,
110            } => write!(f, "solve failed on {} ({kind:?}): {reason}", host(h)),
111        }
112    }
113}
114
115/// Output port for scrape observability events.
116///
117/// Must be cheap to share across threads; the scraper holds one behind an `Arc`.
118pub trait EventSink: Send + Sync {
119    /// Consume a single event. Implementations must not block for long.
120    fn emit(&self, event: &ScraperEvent<'_>);
121}
122
123/// The default sink: discards every event with zero overhead.
124#[derive(Debug, Default, Clone, Copy)]
125pub struct NoopEventSink;
126
127impl EventSink for NoopEventSink {
128    fn emit(&self, _event: &ScraperEvent<'_>) {}
129}
130
131/// Forwards events to the `log` crate, choosing a level per event kind.
132#[derive(Debug, Default, Clone, Copy)]
133pub struct LogEventSink;
134
135impl EventSink for LogEventSink {
136    fn emit(&self, event: &ScraperEvent<'_>) {
137        match event {
138            ScraperEvent::SolveFailed { .. } => log::warn!("{event}"),
139            ScraperEvent::ChallengeDetected { .. }
140            | ScraperEvent::ProxyRotated { .. }
141            | ScraperEvent::ProfileRotated { .. }
142            | ScraperEvent::SolveSucceeded { .. } => log::info!("{event}"),
143            ScraperEvent::Waiting { .. } => log::debug!("{event}"),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::sync::Mutex;
152
153    #[derive(Default)]
154    struct RecordingSink {
155        events: Mutex<Vec<String>>,
156    }
157
158    impl EventSink for RecordingSink {
159        fn emit(&self, event: &ScraperEvent<'_>) {
160            self.events.lock().unwrap().push(event.to_string());
161        }
162    }
163
164    #[test]
165    fn recording_sink_captures_emitted_events() {
166        let sink = RecordingSink::default();
167        sink.emit(&ScraperEvent::ChallengeDetected {
168            host: Some("example.com"),
169            kind: ChallengeKind::Turnstile,
170        });
171        sink.emit(&ScraperEvent::SolveSucceeded {
172            host: Some("example.com"),
173            attempts: 2,
174            challenged: true,
175        });
176
177        let recorded = sink.events.lock().unwrap();
178        assert_eq!(recorded.len(), 2);
179        assert!(recorded[0].contains("challenge detected on example.com: Turnstile"));
180        assert!(recorded[1].contains("attempts=2, challenged=true"));
181    }
182
183    #[test]
184    fn display_handles_unknown_host() {
185        let ev = ScraperEvent::SolveFailed {
186            host: None,
187            kind: ChallengeKind::AccessDenied,
188            reason: "blocked",
189        };
190        assert_eq!(
191            ev.to_string(),
192            "solve failed on <unknown> (AccessDenied): blocked"
193        );
194    }
195
196    #[test]
197    fn noop_and_log_sinks_do_not_panic() {
198        let ev = ScraperEvent::ProxyRotated {
199            host: Some("h"),
200            upstream: Some("http://p:1"),
201        };
202        NoopEventSink.emit(&ev);
203        LogEventSink.emit(&ev);
204    }
205
206    #[test]
207    fn every_variant_renders_and_logs() {
208        let events = [
209            ScraperEvent::ChallengeDetected {
210                host: None,
211                kind: ChallengeKind::JsChallenge,
212            },
213            ScraperEvent::Waiting {
214                host: Some("h"),
215                kind: ChallengeKind::IuamV1,
216                delay: Duration::from_secs(2),
217            },
218            ScraperEvent::ProxyRotated {
219                host: None,
220                upstream: None,
221            },
222            ScraperEvent::SolveSucceeded {
223                host: Some("h"),
224                attempts: 0,
225                challenged: false,
226            },
227            ScraperEvent::SolveFailed {
228                host: Some("h"),
229                kind: ChallengeKind::AccessDenied,
230                reason: "blocked",
231            },
232            ScraperEvent::ProfileRotated { user_agent: "UA" },
233        ];
234        for ev in &events {
235            assert!(!ev.to_string().is_empty());
236            LogEventSink.emit(ev);
237            NoopEventSink.emit(ev);
238        }
239
240        assert!(
241            ScraperEvent::Waiting {
242                host: None,
243                kind: ChallengeKind::IuamV1,
244                delay: Duration::from_secs(2),
245            }
246            .to_string()
247            .starts_with("waiting")
248        );
249        assert!(
250            ScraperEvent::ProxyRotated {
251                host: None,
252                upstream: None
253            }
254            .to_string()
255            .contains("<none>")
256        );
257    }
258
259    #[test]
260    fn profile_rotated_display() {
261        let ev = ScraperEvent::ProfileRotated {
262            user_agent: "Mozilla/5.0 Test",
263        };
264        assert_eq!(
265            ev.to_string(),
266            "rotated browser profile (user-agent: Mozilla/5.0 Test)"
267        );
268        LogEventSink.emit(&ev);
269    }
270}