1use 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
17const POSTHOG_API_KEY: Option<&str> = option_env!("POSTHOG_API_KEY");
19
20#[derive(Debug, Clone)]
22pub struct Config {
23 api_key: String,
25 base_url: Url,
27 timeout: Duration,
29}
30
31impl Config {
32 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 pub fn with_timeout(mut self, timeout: Duration) -> Self {
61 self.timeout = timeout;
62 self
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct PostHogClient {
69 config: Config,
70 client: ReqwestClient,
71}
72
73impl PostHogClient {
74 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 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 #[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 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 pub fn from_env(machine_id: impl Into<String>) -> Option<Self> {
215 if let Some(api_key) = POSTHOG_API_KEY {
217 return Self::new(api_key, machine_id).ok();
218 }
219
220 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 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 event.set_app_version(app_version);
258 event.set_is_developer(is_developer);
259 event.set_environment("production".to_string()); let context = context.get_or_insert_with(HashMap::new);
263
264 if !context.contains_key("command") {
266 context.insert("command".to_string(), json!(command.into()));
267 }
268
269 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 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 assert!(matches!(
410 result,
411 Err(PostHogError::SendEvent {
412 source: Some(SendEventErrorKind::Authentication),
413 ..
414 })
415 ));
416 }
417}