From be7e514c9749db27d6fc81c24e515875107e0755 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 09:07:05 +0200 Subject: [PATCH 1/5] docs(cli): document reporting the release from a native app via POSTHOG_RELEASE_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the runtime env-var workflow for native apps: `release resolve` prints the release id, you export it as `POSTHOG_RELEASE_ID`, and the SDK reports it as `$release_id` on every event. This is the deploy-time counterpart to the web `$release_id`, and it needs no new CLI code — `release resolve` already ships. posthog-rs reads the variable (PostHog/posthog-rs#239). Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cli/README.md b/cli/README.md index b5c5254d0158..623f8e4e7cc5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -114,6 +114,19 @@ The CLI resolves exact Xcode build-setting references such as `$(APP_VERSION)` a Missing, unresolved, or compound values fall back to `PRODUCT_BUNDLE_IDENTIFIER`, `MARKETING_VERSION`, and `CURRENT_PROJECT_VERSION`. Explicit `--release-name`, `--release-version`, and `--build` values take precedence. +### Reporting the release from a native app at runtime + +A web build injects `$release_id` into its bundle. A compiled binary has no bundle, so it reports the release from an environment variable instead. Resolve the release, put its id in `POSTHOG_RELEASE_ID`, and run the app with that variable set: + +```bash +export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name my-app --release-version 1.4.0) +./my-app +``` + +The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on every event, so the server resolves each exception's release by a direct id lookup. The release name and version do not have to match anything the app reports. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. + +Upload the debug symbols the usual way, with `symbol-sets upload` and no extra flag, so native frames still symbolicate. The symbols carry no release, so an unchanged binary keeps one symbol set across releases. + ## Skipping uploads (dry run) Pass `--dry-run` before the subcommand (`posthog-cli --dry-run hermes upload ...`), or set `POSTHOG_CLI_DRY_RUN=true`, to turn the upload commands — `sourcemap`, `dsym`, `hermes`, and `proguard` — into a no-op. From 0819529b56598298a09c8a2b7e300259fc2120fb Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 09:31:02 +0200 Subject: [PATCH 2/5] docs(cli): scope reported $release_id to exception events posthog-rs reports the release id only on `$exception` events, so match that wording: the SDK stamps `$release_id` on each exception, not on every event. Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/README.md b/cli/README.md index 623f8e4e7cc5..2d3acf9c1c40 100644 --- a/cli/README.md +++ b/cli/README.md @@ -123,7 +123,7 @@ export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name my-app -- ./my-app ``` -The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on every event, so the server resolves each exception's release by a direct id lookup. The release name and version do not have to match anything the app reports. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. +The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on each exception, so the server resolves that exception's release by a direct id lookup. The release name and version do not have to match anything the app reports. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. Upload the debug symbols the usual way, with `symbol-sets upload` and no extra flag, so native frames still symbolicate. The symbols carry no release, so an unchanged binary keeps one symbol set across releases. From 6f2a4b0ae40842cec93ded028c1d4c4a803149c9 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 10:51:32 +0200 Subject: [PATCH 3/5] feat(cli): add release-independent event mode to symbol-sets upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--release-mode` to `symbol-sets upload` (env `POSTHOG_RELEASE_MODE`). The default `symbol-set` mode keeps binding the release to every uploaded symbol set. `event` uploads the symbol sets release-independent — bound to no release — so one symbol set serves every release of an unchanged binary, and the upload no longer needs `--release-name`/`--release-version`. In event mode the release rides the event as `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+, PostHog/posthog-rs#239); the release is named with `posthog-cli release resolve`, whose id you pass to the app. No binary patching and no code signing, unlike the injected `--release-mode=event` variant. Co-Authored-By: Claude Opus 4.8 --- .../changesets/release-mode-event-native.md | 5 + cli/README.md | 12 +- cli/src/debug_symbols/upload.rs | 103 ++++++++++++++---- 3 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 cli/.sampo/changesets/release-mode-event-native.md diff --git a/cli/.sampo/changesets/release-mode-event-native.md b/cli/.sampo/changesets/release-mode-event-native.md new file mode 100644 index 000000000000..1e1ee983fd42 --- /dev/null +++ b/cli/.sampo/changesets/release-mode-event-native.md @@ -0,0 +1,5 @@ +--- +cargo/posthog-cli: minor +--- + +Add `--release-mode` to `symbol-sets upload` (also `POSTHOG_RELEASE_MODE`). `symbol-set`, the default, keeps binding the release to every uploaded symbol set. `event` uploads the symbol sets release-independent — bound to no release — so one symbol set serves every release of an unchanged binary and the upload needs no `--release-name`/`--release-version`. In event mode the release rides the event as `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+); the release itself is named with `posthog-cli release resolve`, whose id you pass to the app. diff --git a/cli/README.md b/cli/README.md index 2d3acf9c1c40..db9a9daf9604 100644 --- a/cli/README.md +++ b/cli/README.md @@ -116,16 +116,22 @@ Explicit `--release-name`, `--release-version`, and `--build` values take preced ### Reporting the release from a native app at runtime -A web build injects `$release_id` into its bundle. A compiled binary has no bundle, so it reports the release from an environment variable instead. Resolve the release, put its id in `POSTHOG_RELEASE_ID`, and run the app with that variable set: +A web build injects `$release_id` into its bundle. A compiled binary has no bundle, so it reports the release from an environment variable instead. + +Upload the debug symbols with `--release-mode=event`, so the symbol sets upload **release-independent** — bound to no release. Then resolve the release, put its id in `POSTHOG_RELEASE_ID`, and run the app with that variable set: ```bash +# Symbols, release-independent — no --release-name/--release-version needed here. +posthog-cli symbol-sets upload --directory target/release --release-mode=event + +# The release is named here, and its id goes to the app. export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name my-app --release-version 1.4.0) ./my-app ``` -The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on each exception, so the server resolves that exception's release by a direct id lookup. The release name and version do not have to match anything the app reports. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. +The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on each exception, so the server resolves that exception's release by a direct id lookup. Because the id is the key, the release name and version do not have to match anything the app reports, and one symbol set serves every release of an unchanged binary. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. -Upload the debug symbols the usual way, with `symbol-sets upload` and no extra flag, so native frames still symbolicate. The symbols carry no release, so an unchanged binary keeps one symbol set across releases. +This is the same `--release-mode=event` as for a distributed binary, minus the binary injection: the release still rides the event, but the SDK reads the id from the environment rather than from bytes patched into the build. ## Skipping uploads (dry run) diff --git a/cli/src/debug_symbols/upload.rs b/cli/src/debug_symbols/upload.rs index 73d71353afbf..9315bce2d237 100644 --- a/cli/src/debug_symbols/upload.rs +++ b/cli/src/debug_symbols/upload.rs @@ -10,7 +10,7 @@ use crate::{ symbol_sets::{dedup_uploads_by_chunk_id, SymbolSetUpload, MAX_FILE_SIZE}, }, debug_symbols::{discover, package_dsym_bundles, report_problems}, - sourcemaps::args::{pack_version, ReleaseArgs, UploadConflictArgs}, + sourcemaps::args::{pack_version, ReleaseArgs, ReleaseMode, UploadConflictArgs}, utils::git::get_git_info, }; @@ -36,6 +36,22 @@ pub struct Args { /// Implies --force unless --skip-on-conflict is set. #[arg(long, default_value_t = false)] pub include_source: bool, + + /// How the release is associated with exceptions. `symbol-set`, the default, resolves a release + /// (from the flags above or git) and binds it to every uploaded symbol set, so an exception + /// takes the release of the symbol sets its frames resolved against. EXPERIMENTAL `event` + /// instead uploads the symbol sets release-independent: the release rides the event as + /// `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+). So one + /// symbol set serves every release of an unchanged binary, and the release flags are not needed + /// here — name the release with `posthog-cli release resolve` instead. Also settable via + /// `POSTHOG_RELEASE_MODE`. + #[arg( + long, + env = "POSTHOG_RELEASE_MODE", + value_enum, + default_value = "symbol-set" + )] + pub release_mode: ReleaseMode, } pub fn upload(args: &Args) -> Result<()> { @@ -44,6 +60,7 @@ pub fn upload(args: &Args) -> Result<()> { release, conflict, include_source, + release_mode, } = args; let release_args = release.resolve_info_plist()?; @@ -95,27 +112,41 @@ pub fn upload(args: &Args) -> Result<()> { ); } - // Now that there's something to upload, set up the release (explicit flags - // win, git info is metadata/fallback) and stamp it on every set. - let mut release_builder = ReleaseBuilder::default(); - if let Ok(Some(git_info)) = get_git_info(Some(directory.clone())) { - release_builder.with_git(git_info); - } - if let Some(ref release_name) = release_args.name { - release_builder.with_name(release_name); - } - if let Some(version) = pack_version(&release_args.version, &release_args.build) { - release_builder.with_version(&version); - } + match release_mode { + // Resolve a release (explicit flags win, git info is metadata/fallback) and stamp it on + // every set, so an exception takes the release of the symbol sets it resolves against. + ReleaseMode::SymbolSet => { + let mut release_builder = ReleaseBuilder::default(); + if let Ok(Some(git_info)) = get_git_info(Some(directory.clone())) { + release_builder.with_git(git_info); + } + if let Some(ref release_name) = release_args.name { + release_builder.with_name(release_name); + } + if let Some(version) = pack_version(&release_args.version, &release_args.build) { + release_builder.with_version(&version); + } - let created_release = release_builder - .can_create() - .then(|| release_builder.fetch_or_create()) - .transpose()?; - if let Some(release) = created_release { - let release_id = release.id.to_string(); - for upload in &mut uploads { - upload.release_id = Some(release_id.clone()); + let created_release = release_builder + .can_create() + .then(|| release_builder.fetch_or_create()) + .transpose()?; + if let Some(release) = created_release { + let release_id = release.id.to_string(); + for upload in &mut uploads { + upload.release_id = Some(release_id.clone()); + } + } + } + // Upload the symbol sets release-independent (bound to no release). The release rides the + // event instead: the SDK reports it as `$release_id` (from `POSTHOG_RELEASE_ID`), and the + // server resolves each exception by that id. One symbol set then serves every release of an + // unchanged binary, so there is nothing to resolve or bind here. + ReleaseMode::Event => { + info!( + "--release-mode=event: uploading symbol sets release-independent; the release is \ + carried on each event as $release_id (POSTHOG_RELEASE_ID)" + ); } } @@ -159,6 +190,36 @@ fn merge_uploads_prefer_dsym( #[cfg(test)] mod tests { use super::*; + use clap::Parser; + + #[derive(Parser)] + struct SymbolSetsCli { + #[command(subcommand)] + command: crate::download::SymbolSetsSubcommand, + } + + fn parse_upload(extra: &[&str]) -> Args { + let mut argv = vec!["symbol-sets", "upload", "--directory", "target/release"]; + argv.extend_from_slice(extra); + match SymbolSetsCli::parse_from(argv).command { + crate::download::SymbolSetsSubcommand::Upload(args) => args, + _ => panic!("expected the upload subcommand"), + } + } + + #[test] + fn defaults_to_binding_the_release_to_the_symbol_sets() { + // Every existing caller omits the flag and must keep binding symbol sets to their release. + assert_eq!(parse_upload(&[]).release_mode, ReleaseMode::SymbolSet); + } + + #[test] + fn accepts_event_release_mode() { + assert_eq!( + parse_upload(&["--release-mode", "event"]).release_mode, + ReleaseMode::Event + ); + } #[test] fn merge_uploads_prefers_dsym_over_matching_macho() { From f22acc17619c75f393522f1c9a3aa11ecd997387 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:34:49 +0000 Subject: [PATCH 4/5] docs(cli): gate release-id env docs to posthog-rs 0.26+ The runtime release-id section claimed posthog-rs and "other native SDKs" read POSTHOG_RELEASE_ID, with no version gate. Only posthog-rs 0.26+ reads it (PostHog/posthog-rs#239), and no other native SDK does today. State the version gate the CLI help text and changeset already carry, and drop the unverified multi-SDK claim. Generated-By: PostHog Desktop Task-Id: 81d53914-e1ab-4ecf-9740-da990f29d7dd --- cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/README.md b/cli/README.md index db9a9daf9604..d4e45b567fb2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -129,7 +129,7 @@ export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name my-app -- ./my-app ``` -The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on each exception, so the server resolves that exception's release by a direct id lookup. Because the id is the key, the release name and version do not have to match anything the app reports, and one symbol set serves every release of an unchanged binary. posthog-rs reads this variable (PostHog/posthog-rs#239); other native SDKs read the same one. +The SDK reads `POSTHOG_RELEASE_ID` at runtime and reports it as `$release_id` on each exception, so the server resolves that exception's release by a direct id lookup. Because the id is the key, the release name and version do not have to match anything the app reports, and one symbol set serves every release of an unchanged binary. posthog-rs reads this variable in 0.26+ (PostHog/posthog-rs#239). This is the same `--release-mode=event` as for a distributed binary, minus the binary injection: the release still rides the event, but the SDK reads the id from the environment rather than from bytes patched into the build. From 9a436e5b7098e29f86032d4e0be4fe6caf70302d Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Thu, 27 Aug 2026 16:06:04 +0200 Subject: [PATCH 5/5] fix(cli): resolve Info.plist only in symbol-set mode; align event-mode docs Address review feedback on the release-independent event mode: - resolve_info_plist() ran unconditionally, so a bad --info-plist aborted an event-mode upload that never reads release metadata. Move it into the symbol-set arm. - The runtime log and field help said "each event"; $release_id is reported only on exceptions. Say "each exception". - The shared ReleaseMode / UploadConflictArgs help described the sourcemap (injection) event mode, which contradicts the native symbol-sets path. Make the shared wording mode-neutral. - README: assign then export, so a failing `release resolve` is not masked by export's own exit status. - Document that event mode only creates new symbol sets unbound and leaves an existing binding intact (README, help, changeset). Co-Authored-By: Claude Opus 4.8 --- cli/.sampo/changesets/release-mode-event-native.md | 2 +- cli/README.md | 9 ++++++--- cli/src/debug_symbols/upload.rs | 13 ++++++++----- cli/src/sourcemaps/args.rs | 7 +++---- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/cli/.sampo/changesets/release-mode-event-native.md b/cli/.sampo/changesets/release-mode-event-native.md index 1e1ee983fd42..1e16da83d636 100644 --- a/cli/.sampo/changesets/release-mode-event-native.md +++ b/cli/.sampo/changesets/release-mode-event-native.md @@ -2,4 +2,4 @@ cargo/posthog-cli: minor --- -Add `--release-mode` to `symbol-sets upload` (also `POSTHOG_RELEASE_MODE`). `symbol-set`, the default, keeps binding the release to every uploaded symbol set. `event` uploads the symbol sets release-independent — bound to no release — so one symbol set serves every release of an unchanged binary and the upload needs no `--release-name`/`--release-version`. In event mode the release rides the event as `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+); the release itself is named with `posthog-cli release resolve`, whose id you pass to the app. +Add `--release-mode` to `symbol-sets upload` (also `POSTHOG_RELEASE_MODE`). `symbol-set`, the default, keeps binding the release to every uploaded symbol set. `event` uploads the symbol sets release-independent — bound to no release — so one symbol set serves every release of an unchanged binary and the upload needs no `--release-name`/`--release-version`. In event mode the release rides the event as `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+); the release itself is named with `posthog-cli release resolve`, whose id you pass to the app. Event mode applies to newly created symbol sets: a symbol set already bound to a release keeps that binding — event mode does not detach it — so existing builds keep resolving. diff --git a/cli/README.md b/cli/README.md index d4e45b567fb2..02344d74b4d2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -118,14 +118,17 @@ Explicit `--release-name`, `--release-version`, and `--build` values take preced A web build injects `$release_id` into its bundle. A compiled binary has no bundle, so it reports the release from an environment variable instead. -Upload the debug symbols with `--release-mode=event`, so the symbol sets upload **release-independent** — bound to no release. Then resolve the release, put its id in `POSTHOG_RELEASE_ID`, and run the app with that variable set: +Upload the debug symbols with `--release-mode=event`, so newly created symbol sets upload **release-independent** — bound to no release. (An already-bound symbol set keeps its release: event mode does not detach existing bindings, so older builds keep resolving.) Then resolve the release, put its id in `POSTHOG_RELEASE_ID`, and run the app with that variable set: ```bash # Symbols, release-independent — no --release-name/--release-version needed here. posthog-cli symbol-sets upload --directory target/release --release-mode=event -# The release is named here, and its id goes to the app. -export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name my-app --release-version 1.4.0) +# The release is named here, and its id goes to the app. Assign first, then export: `export +# X=$(cmd)` takes export's own exit status, so a failing release resolve would slip through and +# launch the app with an empty id. +RELEASE_ID=$(posthog-cli release resolve --release-name my-app --release-version 1.4.0) +export POSTHOG_RELEASE_ID=$RELEASE_ID ./my-app ``` diff --git a/cli/src/debug_symbols/upload.rs b/cli/src/debug_symbols/upload.rs index 9315bce2d237..6b68d159a701 100644 --- a/cli/src/debug_symbols/upload.rs +++ b/cli/src/debug_symbols/upload.rs @@ -40,8 +40,9 @@ pub struct Args { /// How the release is associated with exceptions. `symbol-set`, the default, resolves a release /// (from the flags above or git) and binds it to every uploaded symbol set, so an exception /// takes the release of the symbol sets its frames resolved against. EXPERIMENTAL `event` - /// instead uploads the symbol sets release-independent: the release rides the event as - /// `$release_id`, which the SDK reports from `POSTHOG_RELEASE_ID` (posthog-rs 0.26+). So one + /// instead uploads new symbol sets release-independent (an already-bound symbol set keeps its + /// release): each exception carries the release as `$release_id`, which the SDK reports from + /// `POSTHOG_RELEASE_ID` (posthog-rs 0.26+). So one /// symbol set serves every release of an unchanged binary, and the release flags are not needed /// here — name the release with `posthog-cli release resolve` instead. Also settable via /// `POSTHOG_RELEASE_MODE`. @@ -62,7 +63,6 @@ pub fn upload(args: &Args) -> Result<()> { include_source, release_mode, } = args; - let release_args = release.resolve_info_plist()?; let directory = directory.canonicalize().map_err(|e| { anyhow!( @@ -116,6 +116,9 @@ pub fn upload(args: &Args) -> Result<()> { // Resolve a release (explicit flags win, git info is metadata/fallback) and stamp it on // every set, so an exception takes the release of the symbol sets it resolves against. ReleaseMode::SymbolSet => { + // Only this mode reads release metadata, so resolve the Info.plist here rather than up + // front — an event-mode upload never uses it and must not abort on a bad --info-plist. + let release_args = release.resolve_info_plist()?; let mut release_builder = ReleaseBuilder::default(); if let Ok(Some(git_info)) = get_git_info(Some(directory.clone())) { release_builder.with_git(git_info); @@ -145,7 +148,7 @@ pub fn upload(args: &Args) -> Result<()> { ReleaseMode::Event => { info!( "--release-mode=event: uploading symbol sets release-independent; the release is \ - carried on each event as $release_id (POSTHOG_RELEASE_ID)" + carried on each exception as $release_id (POSTHOG_RELEASE_ID)" ); } } @@ -157,7 +160,7 @@ pub fn upload(args: &Args) -> Result<()> { let (_summary, upload_result) = api::symbol_sets::upload_with_retry( uploads, 10, - release_args.skip_release_on_fail, + release.skip_release_on_fail, effective_force, conflict.skip_on_conflict, ); diff --git a/cli/src/sourcemaps/args.rs b/cli/src/sourcemaps/args.rs index c2708fcf4450..7aa03a6bf40f 100644 --- a/cli/src/sourcemaps/args.rs +++ b/cli/src/sourcemaps/args.rs @@ -98,7 +98,7 @@ impl FileSelectionArgs { pub enum ReleaseMode { /// Bind the release to the uploaded symbol sets (the previous behavior) SymbolSet, - /// EXPERIMENTAL: resolve the release per event from an id injected into each chunk + /// EXPERIMENTAL: resolve the release per event, keeping the uploaded chunks release-independent Event, } @@ -135,13 +135,12 @@ pub struct ReleaseArgs { #[derive(clap::Args, Clone, Default)] pub struct UploadConflictArgs { - /// Allow overwriting an existing symbol set whose content has changed. Always on with - /// `--release-mode=event`. [default: false] + /// Allow overwriting an existing symbol set whose content has changed. [default: false] #[arg(long, default_value_t = false, conflicts_with = "skip_on_conflict")] pub force: bool, /// Skip symbol sets that already exist with different content instead of failing. - /// Existing symbol sets are left unchanged. Ignored with `--release-mode=event`. [default: false] + /// Existing symbol sets are left unchanged. [default: false] #[arg(long, default_value_t = false, conflicts_with = "force")] pub skip_on_conflict: bool, }