apify_client/clients/log.rs
1//! Client for accessing a run's or build's log.
2//!
3//! Logs are accessible at the top level (`/v2/logs/{buildOrRunId}`) and nested under a
4//! run or build (`.../log`). The [`LogClient`] supports fetching the whole log as text
5//! and streaming it for real-time redirection (the "log redirection" feature).
6
7use futures_util::Stream;
8
9use crate::clients::base::{get_raw, ResourceContext};
10use crate::common::QueryParams;
11use crate::error::{ApifyClientError, ApifyClientResult};
12use crate::http_client::HttpClient;
13
14/// Options for retrieving or streaming a log ([`LogClient::get_with_options`] /
15/// [`LogClient::stream_with_options`]).
16///
17/// Covers the spec's optional `raw` query parameter on the log endpoints
18/// (`GET /v2/logs/{buildOrRunId}`, `GET /v2/actor-runs/{runId}/log`, and the last-run log
19/// variants), matching the reference client's `LogOptions`.
20#[derive(Debug, Default, Clone)]
21pub struct LogOptions {
22 /// If `true`, return the raw log content without any server-side processing (e.g. without
23 /// the per-line timestamps the API adds by default). Defaults to `false` when unset.
24 pub raw: Option<bool>,
25}
26
27/// Client for an Actor run or build log.
28#[derive(Debug, Clone)]
29pub struct LogClient {
30 ctx: ResourceContext,
31 /// The URL used for streaming; we keep it so streaming can bypass the buffered path.
32 stream_url: String,
33 token: Option<String>,
34 user_agent: String,
35}
36
37impl LogClient {
38 pub(crate) fn new(http: HttpClient, base_url: &str, resource_path: &str, id: &str) -> Self {
39 let ctx = ResourceContext::single(http, base_url, resource_path, id);
40 let stream_url = ctx.url(None);
41 let (token, user_agent) = ctx.http.stream_credentials();
42 Self {
43 ctx,
44 stream_url,
45 token,
46 user_agent,
47 }
48 }
49
50 /// Creates a log client nested under a run or build (path `.../log`).
51 pub(crate) fn nested(http: HttpClient, base_url: &str, sub_path: &str) -> Self {
52 let ctx = ResourceContext::collection(http, base_url, sub_path);
53 let stream_url = ctx.url(None);
54 let (token, user_agent) = ctx.http.stream_credentials();
55 Self {
56 ctx,
57 stream_url,
58 token,
59 user_agent,
60 }
61 }
62
63 /// Fetches the entire log as a string, or `None` if it does not exist.
64 ///
65 /// Uses the default (processed) log format. To request the raw log, use
66 /// [`LogClient::get_with_options`].
67 pub async fn get(&self) -> ApifyClientResult<Option<String>> {
68 self.get_with_options(LogOptions::default()).await
69 }
70
71 /// Fetches the entire log as a string, or `None` if it does not exist, applying the given
72 /// [`LogOptions`] (e.g. [`LogOptions::raw`]).
73 pub async fn get_with_options(&self, options: LogOptions) -> ApifyClientResult<Option<String>> {
74 let mut params = QueryParams::new();
75 params.add_bool("raw", options.raw);
76 let response = get_raw(&self.ctx, None, ¶ms).await?;
77 Ok(response.map(|r| String::from_utf8_lossy(&r.body).into_owned()))
78 }
79
80 /// Opens a streaming connection to the log, yielding chunks of bytes as they arrive.
81 ///
82 /// This powers real-time log redirection: callers can forward each chunk to their own
83 /// logger/stdout while a run is still in progress. The stream completes when the log
84 /// ends (i.e. the run finishes).
85 ///
86 /// Uses the default (processed) log format. To stream the raw log, use
87 /// [`LogClient::stream_with_options`].
88 pub async fn stream(
89 &self,
90 ) -> ApifyClientResult<impl Stream<Item = ApifyClientResult<Vec<u8>>>> {
91 self.stream_with_options(LogOptions::default()).await
92 }
93
94 /// Opens a streaming connection to the log applying the given [`LogOptions`], yielding
95 /// chunks of bytes as they arrive.
96 ///
97 /// Like [`LogClient::stream`], but lets the caller request the raw log via
98 /// [`LogOptions::raw`] (as the reference client's log redirection does, which streams
99 /// `{ raw: true }`).
100 pub async fn stream_with_options(
101 &self,
102 options: LogOptions,
103 ) -> ApifyClientResult<impl Stream<Item = ApifyClientResult<Vec<u8>>>> {
104 // Streaming needs a live connection, so we go through reqwest directly rather than
105 // the buffered backend path. The retry policy does not apply to an open stream.
106 let client = reqwest::Client::new();
107 let mut params = QueryParams::new();
108 params.push_raw("stream".to_string(), "1".to_string());
109 params.add_bool("raw", options.raw);
110 let url = params.apply_to_url(&self.stream_url);
111
112 let mut builder = client.get(&url).header("User-Agent", &self.user_agent);
113 if let Some(token) = &self.token {
114 builder = builder.header("Authorization", format!("Bearer {token}"));
115 }
116
117 let response = builder.send().await.map_err(ApifyClientError::from)?;
118 if !response.status().is_success() {
119 return Err(ApifyClientError::InvalidResponse(format!(
120 "log stream returned status {}",
121 response.status().as_u16()
122 )));
123 }
124
125 let byte_stream = response.bytes_stream();
126 Ok(futures_util::StreamExt::map(byte_stream, |chunk| {
127 chunk.map(|b| b.to_vec()).map_err(ApifyClientError::from)
128 }))
129 }
130}