posthog514client_rs/
client.rs

1//! PostHog client implementation
2
3use reqwest::{Client as ReqwestClient, Url};
4use serde_json::json;
5use std::collections::HashMap;
6use std::env;
7use std::sync::Arc;
8use std::time::Duration;
9use tracing::{debug, error, instrument};
10
11use crate::error::{ConfigErrorKind, PostHogError, SendEventErrorKind};
12use crate::event::{Event514, MooseEventType};
13
14const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
15const POSTHOG_HOST: &str = "https://us.i.posthog.com";
16
17// Build-time environment variable for PostHog API key
18const POSTHOG_API_KEY: Option<&str> = option_env!("POSTHOG_API_KEY");
19
20/// Configuration for the PostHog client
21#[derive(Debug, Clone)]
22pub struct Config {
23    /// API key for authentication
24    api_key: String,
25    /// Base URL for the PostHog instance
26    base_url: Url,
27    /// Request timeout
28    timeout: Duration,
29}
30
31impl Config {
32    /// Creates a new configuration with custom settings
33    pub fn new(
34        api_key: impl Into<String>,
35        base_url: impl AsRef<str>,
36    ) -> Result<Self, PostHogError> {
37        let api_key = api_key.into();
38        if api_key.is_empty() {
39            return Err(PostHogError::configuration(
40                "API key cannot be empty",
41                Some(ConfigErrorKind::InvalidApiKey),
42            ));
43        }
44
45        let base_url = Url::parse(base_url.as_ref()).map_err(|e| {
46            PostHogError::configuration(
47                "Invalid base URL",
48                Some(ConfigErrorKind::InvalidUrl(e.to_string())),
49            )
50        })?;
51
52        Ok(Self {
53            api_key,
54            base_url,
55            timeout: DEFAULT_TIMEOUT,
56        })
57    }
58
59    /// Sets a custom timeout for requests
60    pub fn with_timeout(mut self, timeout: Duration) -> Self {
61        self.timeout = timeout;
62        self
63    }
64}
65
66/// Client for interacting with the PostHog API
67#[derive(Debug, Clone)]
68pub struct PostHogClient {
69    config: Config,
70    client: ReqwestClient,
71}
72
73impl PostHogClient {
74    /// Creates a new PostHog client with default configuration
75    pub fn new(
76        api_key: impl Into<String>,
77        base_url: impl AsRef<str>,
78    ) -> Result<Self, PostHogError> {
79        let config = Config::new(api_key, base_url)?;
80        let client = ReqwestClient::builder()
81            .timeout(config.timeout)
82            .build()
83            .map_err(|e| {
84                PostHogError::configuration(
85                    "Failed to create HTTP client",
86                    Some(ConfigErrorKind::InvalidUrl(e.to_string())),
87                )
88            })?;
89
90        Ok(Self { config, client })
91    }
92
93    /// Creates a new PostHog client with custom configuration
94    pub fn with_config(config: Config) -> Result<Self, PostHogError> {
95        let client = ReqwestClient::builder()
96            .timeout(config.timeout)
97            .build()
98            .map_err(|e| {
99                PostHogError::configuration(
100                    "Failed to create HTTP client",
101                    Some(ConfigErrorKind::InvalidUrl(e.to_string())),
102                )
103            })?;
104
105        Ok(Self { config, client })
106    }
107
108    /// Captures a single event
109    #[instrument(skip(self, event), fields(event_name = ?event.event))]
110    pub async fn capture(&self, event: Event514) -> Result<(), PostHogError> {
111        event.validate()?;
112
113        let url = self.config.base_url.join("/capture/").map_err(|e| {
114            PostHogError::configuration(
115                "Failed to construct capture URL",
116                Some(ConfigErrorKind::InvalidUrl(e.to_string())),
117            )
118        })?;
119
120        let payload = json!({
121            "api_key": self.config.api_key,
122            "event": event.event,
123            "properties": event.properties,
124            "distinct_id": event.distinct_id,
125            "timestamp": event.timestamp,
126        });
127
128        debug!("Sending event to PostHog");
129
130        let response = self
131            .client
132            .post(url)
133            .json(&payload)
134            .send()
135            .await
136            .map_err(|e| {
137                PostHogError::send_event(
138                    "Failed to send request",
139                    Some(SendEventErrorKind::Network(e.to_string())),
140                )
141            })?;
142
143        match response.status() {
144            status if status.is_success() => {
145                debug!("Successfully sent event to PostHog");
146                Ok(())
147            }
148            status if status.as_u16() == 429 => {
149                error!("Rate limited by PostHog API");
150                Err(PostHogError::send_event(
151                    "Rate limited",
152                    Some(SendEventErrorKind::RateLimited),
153                ))
154            }
155            status if status.as_u16() == 401 => {
156                error!("Authentication failed");
157                Err(PostHogError::send_event(
158                    "Authentication failed",
159                    Some(SendEventErrorKind::Authentication),
160                ))
161            }
162            status => {
163                let error_msg = response
164                    .text()
165                    .await
166                    .unwrap_or_else(|_| "Unknown error".into());
167                error!("Unexpected response from PostHog: {}", error_msg);
168                Err(PostHogError::send_event(
169                    format!("Unexpected response: {}", status),
170                    Some(SendEventErrorKind::Network(error_msg)),
171                ))
172            }
173        }
174    }
175}
176
177#[derive(Debug, Clone)]
178pub struct PostHog514Client {
179    api_key: String,
180    client: Arc<ReqwestClient>,
181    host: String,
182    machine_id: String,
183}
184
185impl PostHog514Client {
186    /// Creates a new PostHog514Client with the given API key and machine ID
187    pub fn new(
188        api_key: impl Into<String>,
189        machine_id: impl Into<String>,
190    ) -> Result<Self, PostHogError> {
191        let client = ReqwestClient::builder()
192            .timeout(Duration::from_secs(10))
193            .build()
194            .map_err(|e| {
195                PostHogError::configuration(
196                    "Failed to create HTTP client",
197                    Some(ConfigErrorKind::InvalidUrl(e.to_string())),
198                )
199            })?;
200
201        Ok(Self {
202            api_key: api_key.into(),
203            client: Arc::new(client),
204            host: POSTHOG_HOST.to_string(),
205            machine_id: machine_id.into(),
206        })
207    }
208
209    /// Creates a new PostHog514Client using the API key from the environment.
210    /// This will:
211    /// 1. First check for a build-time API key (baked into the binary)
212    /// 2. Then check for a runtime POSTHOG_API_KEY environment variable
213    /// 3. Return None if neither is available
214    pub fn from_env(machine_id: impl Into<String>) -> Option<Self> {
215        // First try build-time API key
216        if let Some(api_key) = POSTHOG_API_KEY {
217            return Self::new(api_key, machine_id).ok();
218        }
219
220        // Then try runtime environment variable
221        if let Ok(api_key) = env::var("POSTHOG_API_KEY") {
222            return Self::new(api_key, machine_id).ok();
223        }
224
225        None
226    }
227
228    /// Captures a custom event
229    pub async fn capture_event(
230        &self,
231        event_name: impl Into<String>,
232        properties: Option<HashMap<String, serde_json::Value>>,
233    ) -> Result<(), PostHogError> {
234        let event = Event514::new(event_name).with_distinct_id(self.machine_id.clone());
235
236        if let Some(properties) = properties {
237            let event = event.with_properties(properties);
238            self.capture(event).await
239        } else {
240            self.capture(event).await
241        }
242    }
243
244    pub async fn capture_cli_command(
245        &self,
246        command: impl Into<String>,
247        project: Option<String>,
248        mut context: Option<HashMap<String, serde_json::Value>>,
249        app_version: impl Into<String>,
250        is_developer: bool,
251    ) -> Result<(), PostHogError> {
252        let mut event = Event514::new_moose(MooseEventType::MooseCliCommand)
253            .with_distinct_id(self.machine_id.clone())
254            .with_project(project);
255
256        // Set 514-specific properties
257        event.set_app_version(app_version);
258        event.set_is_developer(is_developer);
259        event.set_environment("production".to_string()); // TODO: Make configurable
260
261        // Ensure we have a context to work with
262        let context = context.get_or_insert_with(HashMap::new);
263
264        // Add command to context if not already present
265        if !context.contains_key("command") {
266            context.insert("command".to_string(), json!(command.into()));
267        }
268
269        // Add context to event properties and capture
270        let event = event.with_properties(context.clone());
271        self.capture(event).await
272    }
273
274    pub async fn capture_cli_error(
275        &self,
276        error: impl std::error::Error,
277        project: Option<String>,
278        context: Option<HashMap<String, serde_json::Value>>,
279    ) -> Result<(), PostHogError> {
280        let event = Event514::new_moose(MooseEventType::MooseCliError)
281            .with_distinct_id(self.machine_id.clone())
282            .with_project(project);
283
284        if let Some(context) = context {
285            let event = event.with_properties(context);
286            let event = event.with_error(error);
287            self.capture(event).await
288        } else {
289            let event = event.with_error(error);
290            self.capture(event).await
291        }
292    }
293
294    async fn capture(&self, event: Event514) -> Result<(), PostHogError> {
295        event.validate()?;
296
297        let url = format!("{}/capture/", self.host);
298        let payload = json!({
299            "api_key": self.api_key,
300            "event": event.event,
301            "properties": event.properties,
302            "timestamp": event.timestamp,
303            "distinct_id": event.distinct_id,
304        });
305
306        let response = self
307            .client
308            .post(&url)
309            .header("Content-Type", "application/json")
310            .json(&payload)
311            .send()
312            .await
313            .map_err(|e| {
314                PostHogError::send_event(
315                    "Failed to send request",
316                    Some(SendEventErrorKind::Network(e.to_string())),
317                )
318            })?;
319
320        match response.status() {
321            reqwest::StatusCode::OK | reqwest::StatusCode::ACCEPTED => Ok(()),
322            reqwest::StatusCode::UNAUTHORIZED => Err(PostHogError::send_event(
323                "Invalid API key",
324                Some(SendEventErrorKind::Authentication),
325            )),
326            reqwest::StatusCode::TOO_MANY_REQUESTS => Err(PostHogError::send_event(
327                "Too many requests",
328                Some(SendEventErrorKind::RateLimited),
329            )),
330            status => Err(PostHogError::send_event(
331                "Unexpected response from PostHog",
332                Some(SendEventErrorKind::Network(status.to_string())),
333            )),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use mockito::Server;
342    use std::io;
343    use test_case::test_case;
344
345    #[test_case("", POSTHOG_HOST => Err(()) ; "empty api key")]
346    #[test_case("test_key", "invalid-url" => Err(()) ; "invalid url")]
347    #[test_case("test_key", POSTHOG_HOST => Ok(()) ; "valid config")]
348    fn test_config_creation(api_key: &str, base_url: &str) -> Result<(), ()> {
349        match Config::new(api_key, base_url) {
350            Ok(_) => Ok(()),
351            Err(_) => Err(()),
352        }
353    }
354
355    #[tokio::test]
356    async fn test_capture_success() {
357        let mut server = Server::new();
358        let mock = server
359            .mock("POST", "/capture/")
360            .match_body(mockito::Matcher::Json(json!({
361                "api_key": "test_key",
362                "event": "moose_cli_command",
363                "distinct_id": "user123",
364                "properties": {}
365            })))
366            .with_status(200)
367            .create();
368
369        let client = PostHogClient::new("test_key", server.url()).unwrap();
370        let event =
371            Event514::new_moose(MooseEventType::MooseCliCommand).with_distinct_id("user123");
372
373        assert!(client.capture(event).await.is_ok());
374        mock.assert();
375    }
376
377    #[tokio::test]
378    async fn test_capture_cli_command() {
379        let client = PostHog514Client::new("test_key", "machine123").unwrap();
380        let result = client
381            .capture_cli_command(
382                "moose init",
383                Some("test-project".to_string()),
384                None,
385                "1.0.0",
386                false,
387            )
388            .await;
389
390        // This will fail since we're using a fake API key
391        assert!(matches!(
392            result,
393            Err(PostHogError::SendEvent {
394                source: Some(SendEventErrorKind::Authentication),
395                ..
396            })
397        ));
398    }
399
400    #[tokio::test]
401    async fn test_capture_cli_error() {
402        let client = PostHog514Client::new("test_key", "machine123").unwrap();
403        let error = io::Error::new(io::ErrorKind::NotFound, "File not found");
404        let result = client
405            .capture_cli_error(error, Some("test-project".to_string()), None)
406            .await;
407
408        // This will fail since we're using a fake API key
409        assert!(matches!(
410            result,
411            Err(PostHogError::SendEvent {
412                source: Some(SendEventErrorKind::Authentication),
413                ..
414            })
415        ));
416    }
417}