From 2b3fb595f2d18957ccea1b4f945ca6942552cf3c Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 24 Aug 2026 10:49:14 +0200 Subject: [PATCH 1/3] feat(cli): add event release mode to the hermes commands A Hermes chunk id is derived from bundle content, so two releases that ship the same JavaScript land on one symbol set. In symbol-set mode the release lives on that symbol set, so the second release either collides or reports the first one's release. `--release-mode event` on `hermes clone` and `hermes upload` leaves the maps release-independent. The release row is still created, so the server can resolve an event's $app_namespace / $app_version / $app_build onto it, the way it already does for iOS dSYMs and Android mappings. Nothing has to be injected into the app in exchange. `hermes inject` no longer rejects event mode. It injects content-addressed chunk ids and embeds no release id, because a Hermes bytecode bundle has nothing that reads the global back out. Co-Authored-By: Claude Opus 5 (1M context) --- .../changesets/hermes-event-release-mode.md | 5 ++ cli/src/sourcemaps/args.rs | 11 +-- cli/src/sourcemaps/hermes/clone.rs | 69 ++++++++++++++++-- cli/src/sourcemaps/hermes/inject.rs | 19 ++--- cli/src/sourcemaps/hermes/mod.rs | 70 +++++++++++++++++++ cli/src/sourcemaps/hermes/upload.rs | 60 ++++++++++++++-- cli/src/sourcemaps/inject.rs | 35 ++++++++-- cli/src/sourcemaps/plain/inject.rs | 9 ++- 8 files changed, 240 insertions(+), 38 deletions(-) create mode 100644 cli/.sampo/changesets/hermes-event-release-mode.md diff --git a/cli/.sampo/changesets/hermes-event-release-mode.md b/cli/.sampo/changesets/hermes-event-release-mode.md new file mode 100644 index 000000000000..a8a4e6317a72 --- /dev/null +++ b/cli/.sampo/changesets/hermes-event-release-mode.md @@ -0,0 +1,5 @@ +--- +cargo/posthog-cli: minor +--- + +Add `--release-mode` to `hermes clone` and `hermes upload`. `event` leaves the uploaded Hermes source maps release-independent, so a React Native build that ships unchanged JavaScript across two releases keeps one symbol set instead of colliding on the release the first upload stamped on it. Each exception resolves its own release from the `$app_namespace` / `$app_version` / `$app_build` the SDK already sends, so pass `--release-name`, `--release-version` and `--build` matching the app's bundle identifier or applicationId, version and build number. `symbol-set` stays the default. `hermes inject --release-mode=event` no longer errors: it injects content-addressed chunk ids and, unlike a web build, embeds no release id, because a Hermes bytecode bundle has nothing to read one back out. diff --git a/cli/src/sourcemaps/args.rs b/cli/src/sourcemaps/args.rs index 72e0380f883c..5c21678cedc9 100644 --- a/cli/src/sourcemaps/args.rs +++ b/cli/src/sourcemaps/args.rs @@ -156,11 +156,12 @@ impl UploadConflictArgs { /// Resolve what to do about changed content, given the release mode. /// /// Event mode always overwrites, and neither flag changes that. A chunk's id and its uploaded - /// bytes move independently there: the id is derived from the pristine minified source and so - /// survives a new release, while the injected snippet inside the payload carries the release id - /// and changes with every release. Every chunk therefore conflicts on every release after the - /// first, so honoring `--skip-on-conflict` would skip all of them and leave the server serving - /// the previous release's id forever. + /// bytes move independently there. The id comes from content that survives a new release, + /// while the release id travels inside the payload. Every change to that release id makes the + /// chunk conflict under an unchanged id. A web bundle carries it in the injected snippet, so + /// it conflicts on every release. A Hermes map conflicts once, on the build that changes mode. + /// To honor `--skip-on-conflict` would keep the stored payload, and the release bound to it, + /// for every exception. pub fn resolve(&self, release_mode: ReleaseMode) -> ConflictBehavior { match release_mode { ReleaseMode::Event => ConflictBehavior { diff --git a/cli/src/sourcemaps/hermes/clone.rs b/cli/src/sourcemaps/hermes/clone.rs index 5db648155680..d7de5f43fed6 100644 --- a/cli/src/sourcemaps/hermes/clone.rs +++ b/cli/src/sourcemaps/hermes/clone.rs @@ -5,7 +5,11 @@ use tracing::info; use crate::{ invocation_context::context, - sourcemaps::{args::ReleaseArgs, content::SourceMapFile, inject::get_release_for_maps}, + sourcemaps::{ + args::{ReleaseArgs, ReleaseMode}, + content::SourceMapFile, + inject::get_release_for_maps, + }, }; #[derive(clap::Args)] @@ -20,6 +24,20 @@ pub struct CloneArgs { #[clap(flatten)] pub release: ReleaseArgs, + + /// How the release is associated with exceptions. `symbol-set` is the default. It stamps the + /// release id into the source maps, so the upload binds the symbol set to that release. + /// EXPERIMENTAL `event` stamps nothing and leaves the maps release-independent. Each event + /// then resolves its own release from the app version and namespace the SDK already sends. + /// The coordinates you pass to `hermes upload` must match the app's. Also settable via + /// `POSTHOG_RELEASE_MODE`. + #[arg( + long, + env = "POSTHOG_RELEASE_MODE", + value_enum, + default_value = "symbol-set" + )] + pub release_mode: ReleaseMode, } pub fn clone(args: &CloneArgs) -> Result<()> { @@ -29,6 +47,7 @@ pub fn clone(args: &CloneArgs) -> Result<()> { minified_map_path, composed_map_path, release, + release_mode, } = args; let mut minified_map = SourceMapFile::load(minified_map_path).map_err(|e| { @@ -47,8 +66,17 @@ pub fn clone(args: &CloneArgs) -> Result<()> { ) })?; - let release_id = get_release_for_maps(minified_map_path, release.clone(), [&minified_map])? - .map(|r| r.id.to_string()); + // Event mode resolves no release here. `hermes upload` creates the release row that the + // server resolves an event's app metadata onto. The maps must stay without a release id. + // A map id comes from the map's own content, so one symbol set serves every release. + // Without this, a later release collides with the release that uploaded the map first. + let release_id = match release_mode { + ReleaseMode::Event => None, + ReleaseMode::SymbolSet => { + get_release_for_maps(minified_map_path, release.clone(), [&minified_map])? + .map(|r| r.id.to_string()) + } + }; // The flow here differs from plain sourcemap injection a bit - here, we don't ever // overwrite the chunk ID, because at this point in the build process, we no longer @@ -58,7 +86,7 @@ pub fn clone(args: &CloneArgs) -> Result<()> { // tries to run `clone` twice, changing release but not posthog env, we'll error out. The // correct way to upload the same set of artefacts to the same posthog env as part of // two different releases is, 1, not to, but failing that, 2, to re-run the bundling process - if !minified_map.has_release_id() || minified_map.get_release_id() != release_id { + if minified_map.get_release_id() != release_id { minified_map.set_release_id(release_id.clone()); minified_map.save()?; } @@ -84,9 +112,10 @@ pub fn clone_metadata(minified_map: &SourceMapFile, composed_map: &mut SourceMap composed_map.set_chunk_id(Some(chunk_id)); } - if let Some(release_id) = minified_map.get_release_id() { - composed_map.set_release_id(Some(release_id)); - } + // Copy the id even when there is none. A build that changes to event release mode then + // clears the id a previous run stamped. Without this, the composed map uploads still bound + // to that release. + composed_map.set_release_id(minified_map.get_release_id()); } #[cfg(test)] @@ -137,4 +166,30 @@ mod test { Some("11111111-2222-4333-8444-555555555555") ); } + + #[test] + fn clone_clears_a_release_id_the_composed_map_still_carries() { + // A build that changes to event release mode still has the previous run's release id on + // the composed map. To copy only the ids that exist would upload it bound to that + // release. That is the collision event mode prevents. + let minified = map_from(serde_json::json!({ + "version": 3, + "mappings": "AAAA", + "sources": ["App.js"], + "names": [], + "debugId": "c96bfa94-ca84-4f98-8d5e-f15adba692ca", + })); + let mut composed = map_from(serde_json::json!({ + "version": 3, + "mappings": "AAAA", + "sources": ["App.js"], + "names": [], + "release_id": "11111111-2222-4333-8444-555555555555", + "x_hermes_function_offsets": {}, + })); + + clone_metadata(&minified, &mut composed); + + assert_eq!(composed.get_release_id(), None); + } } diff --git a/cli/src/sourcemaps/hermes/inject.rs b/cli/src/sourcemaps/hermes/inject.rs index 9578a2257f6a..71e876b1405a 100644 --- a/cli/src/sourcemaps/hermes/inject.rs +++ b/cli/src/sourcemaps/hermes/inject.rs @@ -2,28 +2,21 @@ // It's intended as an escape hatch for people rolling their own build pipeline - we expect most users to be // using the metro plugin for injecting, and then calling clone -use anyhow::{bail, Result}; +use anyhow::Result; use walkdir::DirEntry; use crate::{ invocation_context::context, - sourcemaps::{ - args::ReleaseMode, - inject::{inject_impl, InjectArgs}, - }, + sourcemaps::inject::{inject_impl, EventReleaseSource, InjectArgs}, }; pub fn inject(args: &InjectArgs) -> Result<()> { context().capture_command_invoked("hermes_inject"); args.validate()?; - // The rest of the Hermes pipeline (clone, upload, the RN SDK) has no event-mode support, - // so accepting the flag would inject a global nothing reads while upload re-binds the - // release anyway. Rejecting also catches a POSTHOG_RELEASE_MODE=event env var set for a - // web build leaking into a React Native build in the same environment. - if args.release_mode == ReleaseMode::Event { - bail!("--release-mode=event is not supported for Hermes bundles. Remove the flag (or unset POSTHOG_RELEASE_MODE) and inject again."); - } - inject_impl(args, is_metro_bundle, None) + // A React Native app reports its release from the app metadata it already sends. Event mode + // therefore injects chunk ids and nothing else here. A release id in the chunk would do + // nothing, because the bundle compiles to Hermes bytecode and no SDK reads the global. + inject_impl(args, is_metro_bundle, None, EventReleaseSource::AppMetadata) } pub fn is_metro_bundle(entry: &DirEntry) -> bool { diff --git a/cli/src/sourcemaps/hermes/mod.rs b/cli/src/sourcemaps/hermes/mod.rs index 7d45cd5e0a47..1bd0d6ccdf55 100644 --- a/cli/src/sourcemaps/hermes/mod.rs +++ b/cli/src/sourcemaps/hermes/mod.rs @@ -54,3 +54,73 @@ pub fn get_composed_map(pair: &SourcePair) -> Result> { format!("reading composed map at {composed_path:?}"), )?)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sourcemaps::args::ReleaseMode; + use clap::Parser; + + #[derive(Parser)] + struct HermesCli { + #[command(subcommand)] + command: HermesSubcommand, + } + + fn parse(argv: &[&str]) -> HermesSubcommand { + HermesCli::parse_from(argv).command + } + + #[test] + fn every_hermes_command_defaults_to_binding_the_release() { + // Every existing React Native build omits the flag. Those builds must keep uploading + // maps bound to their release. A different default here unbinds all of them, and + // nothing in the build output says so. + let HermesSubcommand::Clone(clone) = parse(&[ + "hermes", + "clone", + "--minified-map-path", + "main.jsbundle.map", + "--composed-map-path", + "main.jsbundle.hbc.composed.map", + ]) else { + panic!("expected the clone subcommand"); + }; + assert_eq!(clone.release_mode, ReleaseMode::SymbolSet); + + let HermesSubcommand::Upload(upload) = parse(&["hermes", "upload", "--directory", "dist"]) + else { + panic!("expected the upload subcommand"); + }; + assert_eq!(upload.release_mode, ReleaseMode::SymbolSet); + } + + #[test] + fn every_hermes_command_accepts_event_release_mode() { + let HermesSubcommand::Clone(clone) = parse(&[ + "hermes", + "clone", + "--minified-map-path", + "main.jsbundle.map", + "--composed-map-path", + "main.jsbundle.hbc.composed.map", + "--release-mode", + "event", + ]) else { + panic!("expected the clone subcommand"); + }; + assert_eq!(clone.release_mode, ReleaseMode::Event); + + let HermesSubcommand::Upload(upload) = parse(&[ + "hermes", + "upload", + "--directory", + "dist", + "--release-mode", + "event", + ]) else { + panic!("expected the upload subcommand"); + }; + assert_eq!(upload.release_mode, ReleaseMode::Event); + } +} diff --git a/cli/src/sourcemaps/hermes/upload.rs b/cli/src/sourcemaps/hermes/upload.rs index 9a194740139a..fe3277af3334 100644 --- a/cli/src/sourcemaps/hermes/upload.rs +++ b/cli/src/sourcemaps/hermes/upload.rs @@ -7,7 +7,7 @@ use walkdir::WalkDir; use crate::api::symbol_sets::{self, SymbolSetUpload}; use crate::invocation_context::context; -use crate::sourcemaps::args::{ReleaseArgs, UploadConflictArgs}; +use crate::sourcemaps::args::{ReleaseArgs, ReleaseMode, UploadConflictArgs}; use crate::sourcemaps::content::SourceMapFile; use crate::sourcemaps::inject::get_release_for_maps; @@ -26,6 +26,20 @@ pub struct Args { #[clap(flatten)] pub conflict: UploadConflictArgs, + + /// How the release is associated with exceptions. `symbol-set` is the default. It stamps the + /// release id onto the uploaded maps. An exception then takes the release of the maps its + /// frames resolved against. EXPERIMENTAL `event` leaves the maps release-independent. Each + /// event then resolves its own release from the app version and namespace the SDK already + /// sends, so the release coordinates must match the app's. Both modes create the release. + /// 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<()> { @@ -35,8 +49,31 @@ pub fn upload(args: &Args) -> Result<()> { release, batch_size, conflict, + release_mode, } = args; + if conflict.skip_on_conflict_ignored(*release_mode) { + warn!( + "--skip-on-conflict is ignored with --release-mode=event. Skipping a conflict would \ + leave the previously uploaded map, and the release bound to it, in place. \ + Overwriting instead." + ); + } + + // Event mode leaves nothing on the symbol set for the server to use. An exception then + // resolves its release only from the app metadata on the event. Coordinates that come from + // git instead of explicit flags do not match that metadata. The exception then reports no + // release, and nothing in the output says so. + if *release_mode == ReleaseMode::Event && (release.name.is_none() || release.version.is_none()) + { + warn!( + "--release-mode=event resolves each exception's release from the app's namespace and \ + version. Pass --release-name, --release-version and --build matching the app's bundle \ + identifier or applicationId, its version and its build number, or exceptions will \ + report no release." + ); + } + let directory = directory.canonicalize().map_err(|e| { anyhow!( "Directory '{}' not found or inaccessible: {}", @@ -68,9 +105,18 @@ pub fn upload(args: &Args) -> Result<()> { continue; } - // Override release_id if we created/fetched one - if let Some(ref release_id) = created_release_id { - map.set_release_id(Some(release_id.clone())); + // Both modes create the release, so the server has a row to resolve an event's + // `$app_namespace` / `$app_version` / `$app_build` onto. Event mode only skips the + // binding. A chunk id comes from the bundle's own content, so one symbol set serves + // every release. Without this, a later release reports the release that uploaded first. + match release_mode { + ReleaseMode::Event => map.set_release_id(None), + ReleaseMode::SymbolSet => { + // Override release_id if we created/fetched one + if let Some(ref release_id) = created_release_id { + map.set_release_id(Some(release_id.clone())); + } + } } uploads.push(map.try_into()?); @@ -101,6 +147,12 @@ pub fn upload(args: &Args) -> Result<()> { ], ); + // The release id lives in the map's own bytes. The first upload after a project changes + // release mode therefore finds the stored symbol set with different content. To skip that + // conflict would keep the old release-bound map, and every exception would keep reporting + // its release. Event mode overwrites instead. + let conflict = conflict.resolve(*release_mode); + let started_at = Instant::now(); let (summary, upload_result) = symbol_sets::upload_with_retry( uploads, diff --git a/cli/src/sourcemaps/inject.rs b/cli/src/sourcemaps/inject.rs index d5435d0980bf..491e4b4808c2 100644 --- a/cli/src/sourcemaps/inject.rs +++ b/cli/src/sourcemaps/inject.rs @@ -49,10 +49,24 @@ impl InjectArgs { } } +/// Where an event-mode build's release comes from at runtime. +/// +/// Web and Node bundles carry it in the chunk. The injected snippet sets `_posthogReleaseId`, +/// and the SDK emits it on every exception. React Native cannot do this. The injected JS +/// compiles to Hermes bytecode, and no SDK reads the global out of it. There the server +/// rebuilds the release from the `$app_namespace` / `$app_version` / `$app_build` that every +/// event already carries. This is what it does for iOS dSYMs and Android mappings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventReleaseSource { + EmbeddedInChunk, + AppMetadata, +} + pub fn inject_impl( args: &InjectArgs, matcher: impl Fn(&DirEntry) -> bool + 'static, existing_release: Option<&Release>, + event_release_source: EventReleaseSource, ) -> Result<()> { let InjectArgs { file_selection, @@ -77,13 +91,20 @@ pub fn inject_impl( ReleaseMode::Event => { // The release id travels inside each chunk for the SDK to emit, rather than being // stamped into the sourcemap, so the release exists but nothing binds a symbol set - // to it. - let release_id = resolve_release_id(release.clone(), existing_release)?; - if release_id.is_none() { - warn!( - "no release could be resolved, injecting chunk ids only — events will carry no release" - ); - } + // to it. When the SDK reads the release from the app instead, only the chunk ids go + // in. The upload then creates the release row that the server resolves onto. + let release_id = match event_release_source { + EventReleaseSource::EmbeddedInChunk => { + let release_id = resolve_release_id(release.clone(), existing_release)?; + if release_id.is_none() { + warn!( + "no release could be resolved, injecting chunk ids only — events will carry no release" + ); + } + release_id + } + EventReleaseSource::AppMetadata => None, + }; pairs = inject_pairs(pairs, release_id.as_deref())?; } ReleaseMode::SymbolSet => { diff --git a/cli/src/sourcemaps/plain/inject.rs b/cli/src/sourcemaps/plain/inject.rs index a1c86b52f53e..4ad32ba7f9d1 100644 --- a/cli/src/sourcemaps/plain/inject.rs +++ b/cli/src/sourcemaps/plain/inject.rs @@ -5,13 +5,18 @@ use walkdir::DirEntry; use crate::{ api::releases::Release, invocation_context::context, - sourcemaps::inject::{inject_impl, InjectArgs}, + sourcemaps::inject::{inject_impl, EventReleaseSource, InjectArgs}, }; pub fn inject(args: &InjectArgs, existing_release: Option<&Release>) -> Result<()> { context().capture_command_invoked("sourcemap_inject"); args.validate()?; - inject_impl(args, is_javascript_file, existing_release) + inject_impl( + args, + is_javascript_file, + existing_release, + EventReleaseSource::EmbeddedInChunk, + ) } pub fn is_javascript_file(entry: &DirEntry) -> bool { From 125b6df66df8822ecadf84255bad41cf78bee7ab Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 24 Aug 2026 12:00:02 +0200 Subject: [PATCH 2/3] chore(cli): correct why hermes event mode overwrites a conflict The comment claimed that skipping the conflict would keep the old release bound and make exceptions report it. That is wrong. A symbol set's release lives in posthog_errortrackingsymbolset.release_id, which cymbal joins on, and event mode sends no release id, so the server never touches that column. Neither force nor skip changes the binding. The real reason is narrower. A hermes chunk id comes from the bundle content and the release id sits inside the uploaded map, so the build that changes release mode sends the same id with different bytes and the server refuses it. Overwriting is what lets that build pass. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/sourcemaps/args.rs | 3 +-- cli/src/sourcemaps/hermes/upload.rs | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cli/src/sourcemaps/args.rs b/cli/src/sourcemaps/args.rs index 5c21678cedc9..6d3087c7611e 100644 --- a/cli/src/sourcemaps/args.rs +++ b/cli/src/sourcemaps/args.rs @@ -160,8 +160,7 @@ impl UploadConflictArgs { /// while the release id travels inside the payload. Every change to that release id makes the /// chunk conflict under an unchanged id. A web bundle carries it in the injected snippet, so /// it conflicts on every release. A Hermes map conflicts once, on the build that changes mode. - /// To honor `--skip-on-conflict` would keep the stored payload, and the release bound to it, - /// for every exception. + /// To honor `--skip-on-conflict` would keep the stored payload, so the newer one never lands. pub fn resolve(&self, release_mode: ReleaseMode) -> ConflictBehavior { match release_mode { ReleaseMode::Event => ConflictBehavior { diff --git a/cli/src/sourcemaps/hermes/upload.rs b/cli/src/sourcemaps/hermes/upload.rs index fe3277af3334..90f11f7dd7ec 100644 --- a/cli/src/sourcemaps/hermes/upload.rs +++ b/cli/src/sourcemaps/hermes/upload.rs @@ -55,8 +55,8 @@ pub fn upload(args: &Args) -> Result<()> { if conflict.skip_on_conflict_ignored(*release_mode) { warn!( "--skip-on-conflict is ignored with --release-mode=event. Skipping a conflict would \ - leave the previously uploaded map, and the release bound to it, in place. \ - Overwriting instead." + keep the previously uploaded map, so the build that changes release mode would \ + fail to replace it. Overwriting instead." ); } @@ -147,10 +147,9 @@ pub fn upload(args: &Args) -> Result<()> { ], ); - // The release id lives in the map's own bytes. The first upload after a project changes - // release mode therefore finds the stored symbol set with different content. To skip that - // conflict would keep the old release-bound map, and every exception would keep reporting - // its release. Event mode overwrites instead. + // A hermes chunk id comes from the bundle content, and the release id sits inside the + // uploaded map. The build that changes release mode therefore sends the same id with + // different bytes, and the server refuses it. Event mode overwrites so that build passes. let conflict = conflict.resolve(*release_mode); let started_at = Instant::now(); From c3bea543230a7157f8eccb194b6157c23d8b4ce6 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Mon, 24 Aug 2026 20:52:59 +0200 Subject: [PATCH 3/3] fix(cli): warn when hermes event mode has no build number The server packs the build number into the version it keys a release on, so a release created without --build matches no event carrying $app_build, and the exception reports no release. The warning already named --build; the condition did not check it. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/sourcemaps/hermes/upload.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/src/sourcemaps/hermes/upload.rs b/cli/src/sourcemaps/hermes/upload.rs index 90f11f7dd7ec..ef3ff9c07c65 100644 --- a/cli/src/sourcemaps/hermes/upload.rs +++ b/cli/src/sourcemaps/hermes/upload.rs @@ -63,8 +63,11 @@ pub fn upload(args: &Args) -> Result<()> { // Event mode leaves nothing on the symbol set for the server to use. An exception then // resolves its release only from the app metadata on the event. Coordinates that come from // git instead of explicit flags do not match that metadata. The exception then reports no - // release, and nothing in the output says so. - if *release_mode == ReleaseMode::Event && (release.name.is_none() || release.version.is_none()) + // release, and nothing in the output says so. The build number counts as a coordinate: the + // server packs it into the version it keys on, so a release without one matches no event that + // carries `$app_build`. + if *release_mode == ReleaseMode::Event + && (release.name.is_none() || release.version.is_none() || release.build.is_none()) { warn!( "--release-mode=event resolves each exception's release from the app's namespace and \