Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/release-id-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
cargo/posthog-rs: minor
---

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.
1 change: 1 addition & 0 deletions api/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<VALUE: core::convert::Into<alloc::string::String>>(&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
Expand Down
43 changes: 43 additions & 0 deletions src/client/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ 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 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
/// 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()),
);
}
}

pub(super) fn apply_before_send_hooks(hooks: &[BeforeSendHook], event: Event) -> Option<Event> {
Expand Down Expand Up @@ -348,6 +368,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<String, serde_json::Value>,
groups: HashMap<String, String>,
Expand Down
35 changes: 34 additions & 1 deletion src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Timeout in seconds for remote `/flags` requests. Defaults to `3`.
#[builder(default = "3")]
feature_flags_request_timeout_seconds: u64,
Expand Down Expand Up @@ -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<String>,
}

impl ClientOptions {
Expand All @@ -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(),
),
}
}

Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions src/client/v0_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -297,6 +299,7 @@ mod tests {
&CaptureDefaults {
disable_geoip: true,
is_server: true,
release_id: None,
},
&hooks,
)
Expand Down
2 changes: 2 additions & 0 deletions src/client/v1_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/release_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! 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 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;

/// The environment variable the release id is read from.
const RELEASE_ID_ENV: &str = "POSTHOG_RELEASE_ID";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a thought - we could fall back to attempting to derive a release id from the git HEAD (git rev-parse --verify 'HEAD^{commit}')

expecting cases where git is not found or we're not running in a .git repo


/// 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<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| normalize(std::env::var(RELEASE_ID_ENV).ok()))
.as_deref()
}
Comment on lines +19 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a proc macro? if we want to bake the env var into the binary at build time, this won't work


/// 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<String>) -> Option<String> {
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.
/// 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<String> {
normalize(config.map(str::to_string)).or_else(|| env.map(str::to_string))
}

#[cfg(test)]
mod tests {
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 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);
}

#[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")
);
}
}