Skip to content

Commit d35ffa7

Browse files
authored
feat: add manual rust error tracking (#129)
* feat: add manual rust error tracking * test: align error tracking payload assertions * fix: address error tracking review comments * feat: add anonymous error capture helper * docs: use version placeholder in README quickstart * refactor: remove public exception_from_error helper Callers build payloads with ExceptionCapture::from_error directly; capture_error/capture_error_anon keep applying client-level Error Tracking options internally. * refactor: make Exception payload-only and apply client options at capture Exceptions are now plain events: Exception holds only exception-specific data (items, fingerprint, level) and converts into an Event via into_event/into_event_anon, so distinct ID, props, groups, flags, and timestamps use the standard Event API instead of duplicated builders. Raw frames are captured unclassified at construction and client-level ErrorTrackingOptions (stacktrace opt-out, in-app classification, frame and source limits) are applied at capture time in the v0/v1 prep paths, so free-standing constructors always honor the capturing client's configuration. * refactor: unify error capture under capture_exception Replace capture_error/capture_error_anon and the personless capture_exception(Exception) with capture_exception(&error, distinct_id) and capture_exception_anon(&error) across the async, blocking, and global APIs, matching the capture_exception entry point of other PostHog SDKs. Built Exception payloads now go through Exception::into_event + capture. * refactor: adopt options-based capture_exception API capture_exception(&error) now captures personlessly, and capture_exception_with(&error, CaptureExceptionOptions) carries the optional context: distinct_id, custom properties, groups, fingerprint, and severity level. capture_exception_anon is removed, and the Exception payload type with its into_event conversions is no longer exported - python/node expose no payload tier either, keeping the public surface to two methods plus one options struct. * refactor: finalize exception events eagerly in build_exception_event Exception construction is now reachable only through client methods that hold the client's ErrorTrackingOptions, so client policy (stack walk gating, in-app classification, frame and source-chain limits, reserved $exception_* properties) is applied when the event is built instead of at capture time. Disabling capture_stacktrace now skips the stack walk entirely rather than discarding the captured frames at send. This removes the pending-exception staging field from Event and reverts prepare_event/build_events to main's exception-agnostic shapes. * refactor: make frame and source-chain limits internal constants max_frames and max_error_sources leave ErrorTrackingOptions: no other SDK exposes them, nobody asked for tunability, and the configurable source cap silently clamped to the build-time bound anyway. The caps remain as MAX_FRAMES/MAX_ERROR_SOURCES; re-adding knobs later is non-breaking. * feat: emit one frame per inlined function resolve_frame yields a symbol per logical layer when the compiler inlined functions into a physical frame; the previous first-symbol-only guard kept the innermost layer and silently dropped the inline ancestry, which exists in no other frame. Emit each layer as its own frame (innermost first, matching current-first capture order), like the Go runtime does for our Go SDK. MAX_FRAMES now counts logical frames. * refactor: mark exception internals pub(crate) The error_tracking module is private, so these were never reachable, but the bare pub kept reading as public API in review diffs. Make the crate-internal surface explicit; the public ET API stays exactly capture_exception/_with, CaptureExceptionOptions, and ErrorTrackingOptions. * docs: document the error tracking public surface /// docs with examples on capture_exception/_with, CaptureExceptionOptions, and every ErrorTrackingOptions field (derive_builder copies field docs onto the generated setters), including the capture-site-vs-origin stacktrace caveat and crate-prefix in-app patterns. README gains the configuration example and a captured-properties table. * test: split error tracking integration tests by capture transport test_error_tracking.rs asserts the V0 wire shape, so it is gated out under capture-v1; test_error_tracking_v1.rs adds V1-envelope twins (single-attempt clients against an empty-results 2xx, asserting one well-formed request). Fixes the error-tracking + capture-v1 combo failing with 404s against v0-shaped mocks. * fix: clean up lints surfaced by feature-combo clippy before_send on the flag-event hosts is only read by the v0 ship path, so scope a dead_code allow under capture-v1 (the v1 flag path does not apply before_send hooks today). Restructure the blocking capture cfg splits as tail blocks so needless_return doesn't fire under capture-v1. * ci: run error tracking feature combinations The matrix never enabled error-tracking, so every ET test compiled to nothing in CI; cover it alone and combined with capture-v1, on both clients. * build: drop the backtrace version pin The pin guarded the declared 1.78 MSRV, but every toolchain it could help (1.78-1.84) is already locked out by edition-2024 transitive manifests (idna_adapter via reqwest->url, present on main's lockfile too), so it protects nothing reachable while exporting =0.3.74 resolution conflicts to any consumer tree wanting backtrace ^0.3.75. Verified against 0.3.76 across all feature combos. Whether to restore the 1.78 floor or bump rust-version is left as a separate decision. * feat: enable error tracking by default Every other PostHog SDK ships error tracking in the box; with the backtrace pin gone there is no resolution cost to carry it as a default feature, and capture_exception works out of the box. Opt out with default-features = false. The CI matrix collapses accordingly: the default and capture-v1 steps now cover error tracking on the async client, keeping explicit steps only for the blocking client.
1 parent f9504c9 commit d35ffa7

15 files changed

Lines changed: 2024 additions & 7 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ jobs:
143143
- name: Unit test (capture-v1, blocking client)
144144
cache-key: capture-v1-blocking-client
145145
command: cargo test --verbose --no-default-features --features capture-v1
146+
- name: Unit test (error-tracking, blocking client)
147+
cache-key: error-tracking-blocking-client
148+
command: cargo test --verbose --no-default-features --features error-tracking
149+
- name: Unit test (error-tracking + capture-v1, blocking client)
150+
cache-key: error-tracking-capture-v1-blocking-client
151+
command: cargo test --verbose --no-default-features --features error-tracking,capture-v1
146152
- name: E2E test
147153
cache-key: e2e
148154
command: cargo test --verbose --features e2e-test --no-default-features
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
cargo/posthog-rs: minor
3+
---
4+
5+
Add manual Rust error tracking capture APIs, enabled by default via the `error-tracking` feature.

Cargo.lock

Lines changed: 46 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ serde_json = "1.0.64"
2121
semver = "1.0.24"
2222
derive_builder = "0.20.2"
2323
uuid = { version = "1.13.2", features = ["serde", "v7"] }
24+
backtrace = { version = "0.3.74", optional = true }
2425
os_info = "3.14"
2526
sha1 = "0.10"
2627
regex = "1.10"
@@ -45,11 +46,12 @@ futures = "0.3"
4546
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
4647

4748
[features]
48-
default = ["async-client"]
49+
default = ["async-client", "error-tracking"]
4950
e2e-test = []
5051
async-client = ["tokio"]
5152
capture-v1 = ["brotli", "zstd"]
5253
test-harness = []
54+
error-tracking = ["dep:backtrace"]
5355

5456
[workspace]
5557
members = [".", "compliance/adapter"]

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The official Rust SDK for [PostHog](https://posthog.com). See the [PostHog docs]
99

1010
- **Event capture** - Send events to PostHog for product analytics
1111
- **Feature flags** - Evaluate feature flags with local or remote evaluation
12+
- **Error tracking** - Capture Rust errors with stack traces
1213
- **A/B testing** - Support for multivariate flags and experiments
1314
- **Group analytics** - Track events and flags for B2B use cases
1415
- **Async and sync clients** - Choose based on your runtime
@@ -19,7 +20,7 @@ Add `posthog-rs` to your `Cargo.toml`.
1920

2021
```toml
2122
[dependencies]
22-
posthog-rs = "0.3.7"
23+
posthog-rs = "$version"
2324
```
2425

2526
```rust
@@ -46,6 +47,65 @@ if is_enabled {
4647
}
4748
```
4849

50+
## Error Tracking
51+
52+
Capture Rust errors manually with stack traces and send them to PostHog Error Tracking.
53+
Error tracking ships enabled by default (the `error-tracking` feature); opt out
54+
with `default-features = false`.
55+
56+
```rust
57+
use posthog_rs::{client, CaptureExceptionOptions};
58+
59+
let client = client("your-api-key").await;
60+
let error = std::io::Error::new(std::io::ErrorKind::Other, "checkout failed");
61+
62+
// Associate the exception with a person and attach optional context.
63+
client.capture_exception_with(
64+
&error,
65+
CaptureExceptionOptions::new()
66+
.distinct_id("user-123")
67+
.property("route", "/checkout").unwrap()
68+
.fingerprint("checkout-error"),
69+
).await.unwrap();
70+
71+
// Bare capture is personless.
72+
client.capture_exception(&error).await.unwrap();
73+
```
74+
75+
`CaptureExceptionOptions` carries everything optional: `distinct_id` to
76+
associate a person, custom properties, groups, a custom `fingerprint`, and a
77+
severity `level` (defaults to `"error"`).
78+
79+
The stacktrace is captured at the call site (a bubbled-up `Err` carries no
80+
stack of its own); the error's type, message, and `source()` chain are always
81+
sent regardless. Configure stacktrace capture and in-app frame classification
82+
through `ErrorTrackingOptionsBuilder` on `ClientOptions::error_tracking`
83+
in-app patterns match both file paths and function symbols, so a crate prefix
84+
like `"other_crate::"` marks that crate's frames as library code:
85+
86+
```rust
87+
use posthog_rs::{ClientOptionsBuilder, ErrorTrackingOptionsBuilder};
88+
89+
let options = ClientOptionsBuilder::default()
90+
.api_key("your-api-key".to_string())
91+
.error_tracking(
92+
ErrorTrackingOptionsBuilder::default()
93+
.in_app_exclude_paths(vec!["other_crate::".to_string()])
94+
.build()
95+
.unwrap(),
96+
)
97+
.build()
98+
.unwrap();
99+
```
100+
101+
### Captured properties
102+
103+
| Property | Contents |
104+
|---|---|
105+
| `$exception_list` | One entry per error in the `source()` chain: type, message, and mechanism, with the captured stacktrace attached to the first entry. |
106+
| `$exception_level` | `"error"` unless set via `CaptureExceptionOptions::level`. |
107+
| `$exception_fingerprint` | Only present when set via `CaptureExceptionOptions::fingerprint`; overrides server-side issue grouping. |
108+
49109
## Feature Flags
50110

51111
The SDK now supports PostHog feature flags, allowing you to control feature rollout and run A/B tests.

examples/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PostHog Rust SDK Examples
22

3-
This directory contains example applications demonstrating how to use the PostHog Rust SDK, particularly the feature flags functionality.
3+
This directory contains example applications demonstrating how to use the PostHog Rust SDK, including feature flags, local evaluation, configuration, and manual error tracking.
44

55
## Running the Examples
66

@@ -56,6 +56,20 @@ Shows:
5656
- Production settings with timeouts and geoip configuration
5757
- High-performance local evaluation setup
5858

59+
### 4. Error Tracking Example
60+
61+
Demonstrates manual error tracking capture:
62+
63+
```bash
64+
export POSTHOG_API_KEY=phc_your_project_key
65+
cargo run --example error_tracking
66+
```
67+
68+
Shows:
69+
- Capturing a Rust error as a PostHog Error Tracking event
70+
- Attaching a distinct ID
71+
- Adding custom exception properties
72+
5973
## Key Concepts
6074

6175
### Feature Flag Types

examples/error_tracking.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//! Manual Error Tracking capture.
2+
//!
3+
//! Run with:
4+
//! POSTHOG_API_KEY=phc_... cargo run --example error_tracking
5+
6+
#[cfg(all(feature = "async-client", feature = "error-tracking"))]
7+
#[tokio::main]
8+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
9+
use posthog_rs::{client, CaptureExceptionOptions};
10+
11+
let api_key = std::env::var("POSTHOG_API_KEY")?;
12+
let host = std::env::var("POSTHOG_HOST").unwrap_or_else(|_| posthog_rs::DEFAULT_HOST.into());
13+
let client = client((api_key.as_str(), host.as_str())).await;
14+
15+
let error = std::io::Error::other("checkout failed");
16+
17+
// Associate the error with a person and attach context.
18+
client
19+
.capture_exception_with(
20+
&error,
21+
CaptureExceptionOptions::new()
22+
.distinct_id("user-123")
23+
.property("route", "/checkout")?,
24+
)
25+
.await?;
26+
27+
// Personless capture.
28+
client.capture_exception(&error).await?;
29+
30+
Ok(())
31+
}
32+
33+
#[cfg(not(all(feature = "async-client", feature = "error-tracking")))]
34+
fn main() {
35+
println!("This example requires the async-client and error-tracking features (both default).");
36+
println!("Run with: cargo run --example error_tracking");
37+
}

src/client/async_client.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use std::collections::{HashMap, HashSet};
2+
#[cfg(feature = "error-tracking")]
3+
use std::error::Error as StdError;
24
use std::sync::{Arc, OnceLock};
35
use std::time::Duration;
46

@@ -11,6 +13,8 @@ use tracing::{debug, instrument, trace, warn};
1113
use uuid::Uuid;
1214

1315
use crate::endpoints::Endpoint;
16+
#[cfg(feature = "error-tracking")]
17+
use crate::error_tracking::{build_exception_event, CaptureExceptionOptions};
1418
#[cfg(not(feature = "capture-v1"))]
1519
use crate::event::InnerEvent;
1620
#[cfg(feature = "capture-v1")]
@@ -66,6 +70,9 @@ struct AsyncFlagEventHost {
6670
http_client: HttpClient,
6771
options: ClientOptions,
6872
capture_url: String,
73+
// Read by the v0 ship path only; unused under capture-v1, where the
74+
// flag-event path does not currently apply before_send hooks.
75+
#[cfg_attr(feature = "capture-v1", allow(dead_code))]
6976
before_send: Vec<BeforeSendHook>,
7077
dedup_cache: FlagEventDedupCache,
7178
/// Tokio runtime handle captured at host construction (which always runs
@@ -304,6 +311,88 @@ impl Client {
304311
self.capture_v0(event).await
305312
}
306313

314+
/// Capture a Rust error personlessly, sending it to PostHog Error Tracking.
315+
///
316+
/// The error's type, message, and full `source()` chain are sent as
317+
/// `$exception_list`, with a stacktrace of the capture site attached to
318+
/// the first entry (see `ErrorTrackingOptions::capture_stacktrace`).
319+
///
320+
/// Accepts any [`std::error::Error`], including `&dyn Error`. A
321+
/// `Box<dyn Error>` does not implement `Error` itself, so pass the
322+
/// dereferenced trait object: `capture_exception(&*boxed)`.
323+
///
324+
/// To associate the exception with a person or attach custom properties,
325+
/// groups, a fingerprint, or a severity level, use
326+
/// [`Client::capture_exception_with`].
327+
///
328+
/// # Examples
329+
///
330+
/// ```no_run
331+
/// # async fn example() -> Result<(), posthog_rs::Error> {
332+
/// let client = posthog_rs::client("phc_project_api_key").await;
333+
/// let error = std::io::Error::other("checkout failed");
334+
///
335+
/// client.capture_exception(&error).await?;
336+
/// # Ok(())
337+
/// # }
338+
/// ```
339+
#[cfg(feature = "error-tracking")]
340+
pub async fn capture_exception<E>(&self, error: &E) -> Result<(), Error>
341+
where
342+
E: StdError + ?Sized,
343+
{
344+
self.capture_exception_with(error, CaptureExceptionOptions::default())
345+
.await
346+
}
347+
348+
/// Capture a Rust error with optional context, sending it to PostHog
349+
/// Error Tracking.
350+
///
351+
/// Set [`CaptureExceptionOptions::distinct_id`] to associate the exception
352+
/// with a person; without it the exception is captured personlessly.
353+
///
354+
/// # Examples
355+
///
356+
/// ```no_run
357+
/// # async fn example() -> Result<(), posthog_rs::Error> {
358+
/// use posthog_rs::CaptureExceptionOptions;
359+
///
360+
/// let client = posthog_rs::client("phc_project_api_key").await;
361+
/// let error = std::io::Error::other("checkout failed");
362+
///
363+
/// client
364+
/// .capture_exception_with(
365+
/// &error,
366+
/// CaptureExceptionOptions::new()
367+
/// .distinct_id("user-123")
368+
/// .property("route", "/checkout")?,
369+
/// )
370+
/// .await?;
371+
/// # Ok(())
372+
/// # }
373+
/// ```
374+
#[cfg(feature = "error-tracking")]
375+
pub async fn capture_exception_with<E>(
376+
&self,
377+
error: &E,
378+
options: CaptureExceptionOptions,
379+
) -> Result<(), Error>
380+
where
381+
E: StdError + ?Sized,
382+
{
383+
if self.options.is_disabled() {
384+
trace!("Client is disabled, skipping exception capture");
385+
return Ok(());
386+
}
387+
388+
self.capture(build_exception_event(
389+
error,
390+
options,
391+
self.options.error_tracking(),
392+
)?)
393+
.await
394+
}
395+
307396
/// Capture a collection of events with a single request.
308397
///
309398
/// Events are sent to the `/batch/` endpoint.

0 commit comments

Comments
 (0)