From 7ece8c74b823d43e2f186ae042a67f36c69cf0c7 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 09:00:48 +0200 Subject: [PATCH 1/4] feat: report $release_id from POSTHOG_RELEASE_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report a `$release_id` on every event when `POSTHOG_RELEASE_ID` is set in the environment. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool runs `posthog-cli release resolve` to create the release and print its id, launches the app with that id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on every event, so the server resolves the exception's release by a direct id lookup — no release name or version has to match anything the app reports. The value is read once (cached), an unset or blank value changes nothing, and it is set in `apply_capture_defaults` before before_send, so a hook can still drop the property. No binary patching and no code signing, unlike the marker-injection alternative. Co-Authored-By: Claude Opus 4.8 --- .sampo/changesets/release-id-env.md | 5 +++ src/client/common.rs | 8 +++++ src/lib.rs | 1 + src/release_env.rs | 50 +++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 .sampo/changesets/release-id-env.md create mode 100644 src/release_env.rs diff --git a/.sampo/changesets/release-id-env.md b/.sampo/changesets/release-id-env.md new file mode 100644 index 00000000..7c948c70 --- /dev/null +++ b/.sampo/changesets/release-id-env.md @@ -0,0 +1,5 @@ +--- +cargo/posthog-rs: minor +--- + +Report a `$release_id` on every event when `POSTHOG_RELEASE_ID` is set. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool runs `posthog-cli release resolve` to create the release and print its id, launches the app with that id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on every event so the server resolves the exception's release by a direct id lookup. The variable is read once; an unset or blank value changes nothing, and a `before_send` hook can still drop the property. diff --git a/src/client/common.rs b/src/client/common.rs index 06059fac..7e505ffe 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -55,6 +55,14 @@ pub(super) fn apply_capture_defaults(event: &mut Event, defaults: &CaptureDefaul if defaults.is_server { event.insert_prop_default("$is_server", serde_json::Value::Bool(true)); } + // The release id the app was launched with (`POSTHOG_RELEASE_ID`, printed by + // `posthog-cli release resolve`), if any. Set before before_send so a hook can still drop it. + if let Some(release_id) = crate::release_env::release_id() { + event.insert_prop_default( + "$release_id", + serde_json::Value::String(release_id.to_string()), + ); + } } pub(super) fn apply_before_send_hooks(hooks: &[BeforeSendHook], event: Event) -> Option { diff --git a/src/lib.rs b/src/lib.rs index 8e898a42..f684f5ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,6 +100,7 @@ mod feature_flag_evaluations; mod feature_flags; mod global; mod local_evaluation; +mod release_env; // Public interface - any change to this is breaking! // Client diff --git a/src/release_env.rs b/src/release_env.rs new file mode 100644 index 00000000..2b220450 --- /dev/null +++ b/src/release_env.rs @@ -0,0 +1,50 @@ +//! The release id the SDK reports as `$release_id`, read from the environment at runtime. +//! +//! This is the deploy-time counterpart to injecting `$release_id` into a web bundle. A native build +//! has no bundle to inject, so the CLI's `release resolve` prints the created release's id, and the +//! app is launched with that id in `POSTHOG_RELEASE_ID`. The SDK reads it here and stamps it on +//! every event, so the server resolves the exception's release by a direct id lookup — no release +//! name or version has to match anything the app reports. + +use std::sync::OnceLock; + +/// The environment variable the release id is read from. +const RELEASE_ID_ENV: &str = "POSTHOG_RELEASE_ID"; + +/// The release id from `POSTHOG_RELEASE_ID`, read once. `None` when the variable is unset or blank. +pub(crate) fn release_id() -> Option<&'static str> { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| normalize(std::env::var(RELEASE_ID_ENV).ok())) + .as_deref() +} + +/// Trim the raw value and treat a blank string as unset, so `POSTHOG_RELEASE_ID=` (or whitespace) +/// does not send an empty `$release_id`. +fn normalize(raw: Option) -> Option { + raw.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::normalize; + + #[test] + fn an_unset_variable_is_none() { + assert_eq!(normalize(None), None); + } + + #[test] + fn a_blank_value_is_none() { + // `POSTHOG_RELEASE_ID=` or an all-whitespace value must not send an empty release id. + assert_eq!(normalize(Some(" ".to_string())), None); + } + + #[test] + fn a_value_is_trimmed() { + assert_eq!( + normalize(Some(" 01a03d94-7dd8-0000-e1cb-2a269e5ea0b5 ".to_string())).as_deref(), + Some("01a03d94-7dd8-0000-e1cb-2a269e5ea0b5") + ); + } +} From 367960b8166c49f8d0776af2129f4ebdb1e9d221 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 09:30:11 +0200 Subject: [PATCH 2/4] feat: report $release_id only on $exception events Scope the injected `$release_id` to `$exception` events. That is the only event where the server resolves a release from `$release_id`, so a pageview or a custom event does not need it. The insertion is split into a small `apply_release_id` helper gated on the event name, so the rule is unit-tested without the process-global `POSTHOG_RELEASE_ID` read. Co-Authored-By: Claude Opus 4.8 --- .sampo/changesets/release-id-env.md | 2 +- src/client/common.rs | 36 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.sampo/changesets/release-id-env.md b/.sampo/changesets/release-id-env.md index 7c948c70..da98771a 100644 --- a/.sampo/changesets/release-id-env.md +++ b/.sampo/changesets/release-id-env.md @@ -2,4 +2,4 @@ cargo/posthog-rs: minor --- -Report a `$release_id` on every event when `POSTHOG_RELEASE_ID` is set. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool runs `posthog-cli release resolve` to create the release and print its id, launches the app with that id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on every event so the server resolves the exception's release by a direct id lookup. The variable is read once; an unset or blank value changes nothing, and a `before_send` hook can still drop the property. +Report a `$release_id` on `$exception` events when `POSTHOG_RELEASE_ID` is set. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool runs `posthog-cli release resolve` to create the release and print its id, launches the app with that id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on exceptions so the server resolves each one's release by a direct id lookup. Only exception events carry it — that is where a release is resolved. The variable is read once; an unset or blank value changes nothing, and a `before_send` hook can still drop the property. diff --git a/src/client/common.rs b/src/client/common.rs index 7e505ffe..343bb01d 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -57,7 +57,18 @@ pub(super) fn apply_capture_defaults(event: &mut Event, defaults: &CaptureDefaul } // The release id the app was launched with (`POSTHOG_RELEASE_ID`, printed by // `posthog-cli release resolve`), if any. Set before before_send so a hook can still drop it. - if let Some(release_id) = crate::release_env::release_id() { + apply_release_id(event, crate::release_env::release_id()); +} + +/// Stamp `$release_id` on an `$exception` event when a release id is set. Only exception events +/// carry it, because that is where the server resolves a release from `$release_id` — a pageview or +/// a custom event has no release to resolve. Split out so the event-name gate is unit-tested +/// without the process-global `POSTHOG_RELEASE_ID` read. +fn apply_release_id(event: &mut Event, release_id: Option<&str>) { + if event.event_name() != "$exception" { + return; + } + if let Some(release_id) = release_id { event.insert_prop_default( "$release_id", serde_json::Value::String(release_id.to_string()), @@ -356,6 +367,29 @@ mod tests { .collect() } + #[test] + fn release_id_is_added_only_to_exception_events() { + let id = Some("01a03d94-7dd8-0000-e1cb-2a269e5ea0b5"); + + // The server resolves a release from `$release_id` on `$exception` events, so it belongs + // there. + let mut exception = Event::new_anon("$exception"); + apply_release_id(&mut exception, id); + assert!(exception.properties().contains_key("$release_id")); + + // A pageview or any other event has no release to resolve, so it must not carry it. + let mut pageview = Event::new_anon("$pageview"); + apply_release_id(&mut pageview, id); + assert!(!pageview.properties().contains_key("$release_id")); + + // No `POSTHOG_RELEASE_ID` set: even an exception carries nothing. + let mut exception_without_id = Event::new_anon("$exception"); + apply_release_id(&mut exception_without_id, None); + assert!(!exception_without_id + .properties() + .contains_key("$release_id")); + } + fn flag_params( properties: HashMap, groups: HashMap, From e92b578c87fa530f2132f18a679321558abae177 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 17:07:58 +0200 Subject: [PATCH 3/4] feat: add release_id client option (build-time), env var stays the fallback Report $release_id from an explicit `release_id` client option in addition to the POSTHOG_RELEASE_ID environment variable, so the release can be baked into the binary at build time. - New `ClientOptionsBuilder::release_id(...)`. Set it to `option_env!("POSTHOG_RELEASE_ID")` to bake the id in at build time, so a shipped binary self-identifies with nothing to set at runtime. - The runtime env var remains the fallback when the option is unset, so a deploy can still supply the release without a rebuild. - Precedence: an explicit option wins over the environment (release_env::resolve_release_id, unit-tested). - Resolved once per capture into CaptureDefaults; still stamped only on $exception events; a before_send hook can still drop it. Updates the public-API snapshot and the changeset. Co-Authored-By: Claude Opus 4.8 --- .sampo/changesets/release-id-env.md | 2 +- api/public-api.txt | 1 + src/client/common.rs | 7 +++-- src/client/mod.rs | 35 +++++++++++++++++++++- src/client/v0_capture.rs | 3 ++ src/client/v1_capture.rs | 2 ++ src/release_env.rs | 46 ++++++++++++++++++++++++----- 7 files changed, 84 insertions(+), 12 deletions(-) diff --git a/.sampo/changesets/release-id-env.md b/.sampo/changesets/release-id-env.md index da98771a..b47fa7eb 100644 --- a/.sampo/changesets/release-id-env.md +++ b/.sampo/changesets/release-id-env.md @@ -2,4 +2,4 @@ cargo/posthog-rs: minor --- -Report a `$release_id` on `$exception` events when `POSTHOG_RELEASE_ID` is set. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool runs `posthog-cli release resolve` to create the release and print its id, launches the app with that id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on exceptions so the server resolves each one's release by a direct id lookup. Only exception events carry it — that is where a release is resolved. The variable is read once; an unset or blank value changes nothing, and a `before_send` hook can still drop the property. +Report a `$release_id` on `$exception` events, from an explicit `release_id` client option or the `POSTHOG_RELEASE_ID` environment variable. This is the native counterpart to injecting `$release_id` into a web bundle: `posthog-cli release resolve` creates the release and prints its id, and that id reaches the SDK one of two ways. Set the `release_id` option — typically `option_env!("POSTHOG_RELEASE_ID")` — to bake it into the binary at build time, so a shipped binary self-identifies with nothing to set at runtime. Or leave it unset and let the SDK read `POSTHOG_RELEASE_ID` from the environment at runtime, so a deploy supplies it without a rebuild. An explicit option wins over the environment. Either way the SDK stamps it only on exceptions — that is where the server resolves a release — the value is read once, an unset or blank value changes nothing, and a `before_send` hook can still drop the property. diff --git a/api/public-api.txt b/api/public-api.txt index 981b2c9d..bf94b394 100644 --- a/api/public-api.txt +++ b/api/public-api.txt @@ -169,6 +169,7 @@ pub fn posthog_rs::ClientOptionsBuilder::max_batch_size(&mut self, usize) -> &mu pub fn posthog_rs::ClientOptionsBuilder::max_capture_attempts(&mut self, u32) -> &mut Self pub fn posthog_rs::ClientOptionsBuilder::max_queue_size(&mut self, usize) -> &mut Self pub fn posthog_rs::ClientOptionsBuilder::poll_interval_seconds(&mut self, u64) -> &mut Self +pub fn posthog_rs::ClientOptionsBuilder::release_id>(&mut self, VALUE) -> &mut Self pub fn posthog_rs::ClientOptionsBuilder::request_timeout_seconds(&mut self, u64) -> &mut Self pub fn posthog_rs::ClientOptionsBuilder::retry_initial_backoff_ms(&mut self, u64) -> &mut Self pub fn posthog_rs::ClientOptionsBuilder::retry_max_backoff_ms(&mut self, u64) -> &mut Self diff --git a/src/client/common.rs b/src/client/common.rs index 343bb01d..e3a0c36c 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -55,9 +55,10 @@ pub(super) fn apply_capture_defaults(event: &mut Event, defaults: &CaptureDefaul if defaults.is_server { event.insert_prop_default("$is_server", serde_json::Value::Bool(true)); } - // The release id the app was launched with (`POSTHOG_RELEASE_ID`, printed by - // `posthog-cli release resolve`), if any. Set before before_send so a hook can still drop it. - apply_release_id(event, crate::release_env::release_id()); + // The release id for this build (the explicit `release_id` option, else the + // `POSTHOG_RELEASE_ID` environment fallback), resolved once in `capture_defaults`. Set before + // before_send so a hook can still drop it. + apply_release_id(event, defaults.release_id.as_deref()); } /// Stamp `$release_id` on an `$exception` event when a release id is set. Only exception events diff --git a/src/client/mod.rs b/src/client/mod.rs index c1f376a8..649d93e8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -172,6 +172,16 @@ pub struct ClientOptions { #[builder(default = "true")] is_server: bool, + /// The release the build belongs to, reported as `$release_id` on `$exception` + /// events so the server resolves the release by a direct id lookup. Set it to + /// bake the release into the binary at build time — e.g. + /// `option_env!("POSTHOG_RELEASE_ID")`, whose value the CLI's `release resolve` + /// prints. When left unset, the SDK falls back to reading `POSTHOG_RELEASE_ID` + /// from the environment at runtime, so a deploy can still supply it without a + /// rebuild. An explicit value here wins over the environment. + #[builder(setter(into, strip_option), default)] + release_id: Option, + /// Timeout in seconds for remote `/flags` requests. Defaults to `3`. #[builder(default = "3")] feature_flags_request_timeout_seconds: u64, @@ -278,10 +288,13 @@ pub struct ClientOptions { /// paths (V0 capture, V0 flag-called host, V1 capture) so each default is /// applied in exactly one place with caller-wins (`entry().or_insert`) /// semantics. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct CaptureDefaults { pub(crate) disable_geoip: bool, pub(crate) is_server: bool, + /// The resolved release id, if any: the explicit `release_id` option, else the + /// `POSTHOG_RELEASE_ID` environment fallback. Built once per capture, not per event. + pub(crate) release_id: Option, } impl ClientOptions { @@ -290,6 +303,10 @@ impl ClientOptions { CaptureDefaults { disable_geoip: self.disable_geoip, is_server: self.is_server, + release_id: crate::release_env::resolve_release_id( + self.release_id.as_deref(), + crate::release_env::release_id(), + ), } } @@ -461,6 +478,22 @@ mod tests { assert_eq!(options.endpoints().api_host(), EU_INGESTION_ENDPOINT); } + #[test] + fn an_explicit_release_id_option_reaches_the_capture_defaults() { + // The build-time path: a caller (typically via `option_env!("POSTHOG_RELEASE_ID")`) sets + // the release id in code, and it must flow through to the resolved capture defaults. + let options = ClientOptionsBuilder::default() + .api_key("test-api-key".to_string()) + .release_id("01a04367-c799-0000-dbe9-a7b5d6121d6b") + .build() + .unwrap(); + + assert_eq!( + options.capture_defaults().release_id.as_deref(), + Some("01a04367-c799-0000-dbe9-a7b5d6121d6b") + ); + } + #[test] #[allow(deprecated)] fn personal_api_key_forwards_to_secret_key_last_call_wins() { diff --git a/src/client/v0_capture.rs b/src/client/v0_capture.rs index 81df38f0..15998341 100644 --- a/src/client/v0_capture.rs +++ b/src/client/v0_capture.rs @@ -205,6 +205,7 @@ mod tests { &CaptureDefaults { disable_geoip: true, is_server: true, + release_id: None, }, ); // Mirrors the no-hooks case in `build_batch_payload`, where the trim runs @@ -259,6 +260,7 @@ mod tests { &CaptureDefaults { disable_geoip: true, is_server: true, + release_id: None, }, ); // No minimization marker -> the full shape is preserved, including the @@ -297,6 +299,7 @@ mod tests { &CaptureDefaults { disable_geoip: true, is_server: true, + release_id: None, }, &hooks, ) diff --git a/src/client/v1_capture.rs b/src/client/v1_capture.rs index 3b4efffb..85b02a13 100644 --- a/src/client/v1_capture.rs +++ b/src/client/v1_capture.rs @@ -395,6 +395,7 @@ mod tests { let defaults = CaptureDefaults { disable_geoip: true, is_server: true, + release_id: None, }; let built = build_events_at(&[event], &defaults, Utc::now()); let map = built[0].properties.as_object().unwrap(); @@ -432,6 +433,7 @@ mod tests { let defaults = CaptureDefaults { disable_geoip: false, is_server: false, + release_id: None, }; let built = build_events_at(&[event], &defaults, Utc::now()); let map = built[0].properties.as_object().unwrap(); diff --git a/src/release_env.rs b/src/release_env.rs index 2b220450..f07d2120 100644 --- a/src/release_env.rs +++ b/src/release_env.rs @@ -1,10 +1,14 @@ -//! The release id the SDK reports as `$release_id`, read from the environment at runtime. +//! The release id the SDK reports as `$release_id`, from an explicit `release_id` option or the +//! `POSTHOG_RELEASE_ID` environment variable. //! -//! This is the deploy-time counterpart to injecting `$release_id` into a web bundle. A native build -//! has no bundle to inject, so the CLI's `release resolve` prints the created release's id, and the -//! app is launched with that id in `POSTHOG_RELEASE_ID`. The SDK reads it here and stamps it on -//! every event, so the server resolves the exception's release by a direct id lookup — no release -//! name or version has to match anything the app reports. +//! This is the native counterpart to injecting `$release_id` into a web bundle. A native build has +//! no bundle to inject, so the CLI's `release resolve` prints the created release's id and it +//! reaches the app one of two ways: baked in at build time (set the `release_id` option to +//! `option_env!("POSTHOG_RELEASE_ID")`, so a shipped binary self-identifies with nothing to set at +//! runtime), or read from `POSTHOG_RELEASE_ID` in the environment at runtime (so a deploy can +//! supply it without a rebuild). An explicit option wins over the environment. Either way the SDK +//! stamps it on `$exception` events, so the server resolves the release by a direct id lookup — no +//! release name or version has to match anything the app reports. use std::sync::OnceLock; @@ -25,9 +29,37 @@ fn normalize(raw: Option) -> Option { raw.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) } +/// Resolve the release id from the two sources, explicit option first, environment fallback second. +/// A `config` value set in code (typically a build-time `option_env!("POSTHOG_RELEASE_ID")`) wins +/// over `env` (the runtime `POSTHOG_RELEASE_ID`), so a deploy-time override is opt-in, not implicit. +pub(crate) fn resolve_release_id(config: Option<&str>, env: Option<&str>) -> Option { + config.or(env).map(str::to_string) +} + #[cfg(test)] mod tests { - use super::normalize; + use super::{normalize, resolve_release_id}; + + #[test] + fn an_explicit_config_id_wins_over_the_environment() { + assert_eq!( + resolve_release_id(Some("from-config"), Some("from-env")).as_deref(), + Some("from-config") + ); + } + + #[test] + fn the_environment_is_used_when_no_config_id_is_set() { + assert_eq!( + resolve_release_id(None, Some("from-env")).as_deref(), + Some("from-env") + ); + } + + #[test] + fn no_config_and_no_environment_is_none() { + assert_eq!(resolve_release_id(None, None), None); + } #[test] fn an_unset_variable_is_none() { From dd6df5f48e2d40fc75a6e9bfccc8ba88135c4352 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 17:17:15 +0200 Subject: [PATCH 4/4] fix: normalize the release_id option so a blank value falls back to the env resolve_release_id now trims the explicit option and treats a blank string as unset, matching the environment path. This makes the ergonomic build-time pattern `option_env!("POSTHOG_RELEASE_ID").unwrap_or_default()` safe: a build that never set the variable passes an empty string, which now falls back to the runtime POSTHOG_RELEASE_ID instead of stamping an empty $release_id. Co-Authored-By: Claude Opus 4.8 --- src/release_env.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/release_env.rs b/src/release_env.rs index f07d2120..b1915a45 100644 --- a/src/release_env.rs +++ b/src/release_env.rs @@ -32,8 +32,11 @@ fn normalize(raw: Option) -> Option { /// Resolve the release id from the two sources, explicit option first, environment fallback second. /// A `config` value set in code (typically a build-time `option_env!("POSTHOG_RELEASE_ID")`) wins /// over `env` (the runtime `POSTHOG_RELEASE_ID`), so a deploy-time override is opt-in, not implicit. +/// The option is normalized like the environment value, so a blank `release_id("")` — e.g. +/// `option_env!("POSTHOG_RELEASE_ID").unwrap_or_default()` in a build that never set it — falls back +/// to the environment instead of sending an empty id. pub(crate) fn resolve_release_id(config: Option<&str>, env: Option<&str>) -> Option { - config.or(env).map(str::to_string) + normalize(config.map(str::to_string)).or_else(|| env.map(str::to_string)) } #[cfg(test)] @@ -56,6 +59,16 @@ mod tests { ); } + #[test] + fn a_blank_config_id_falls_back_to_the_environment() { + // A build that never set POSTHOG_RELEASE_ID can pass an empty option (e.g. + // `option_env!(...).unwrap_or_default()`); it must not shadow the runtime env value. + assert_eq!( + resolve_release_id(Some(" "), Some("from-env")).as_deref(), + Some("from-env") + ); + } + #[test] fn no_config_and_no_environment_is_none() { assert_eq!(resolve_release_id(None, None), None);