Skip to content
Merged
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 cli/.sampo/changesets/hermes-event-release-mode.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions cli/src/sourcemaps/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ 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, so the newer one never lands.
pub fn resolve(&self, release_mode: ReleaseMode) -> ConflictBehavior {
match release_mode {
ReleaseMode::Event => ConflictBehavior {
Expand Down
69 changes: 62 additions & 7 deletions cli/src/sourcemaps/hermes/clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
Comment thread
ablaszkiewicz marked this conversation as resolved.
}

pub fn clone(args: &CloneArgs) -> Result<()> {
Expand All @@ -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| {
Expand All @@ -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
Expand All @@ -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()?;
}
Expand All @@ -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)]
Expand Down Expand Up @@ -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);
}
}
19 changes: 6 additions & 13 deletions cli/src/sourcemaps/hermes/inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions cli/src/sourcemaps/hermes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,73 @@ pub fn get_composed_map(pair: &SourcePair) -> Result<Option<SourceMapFile>> {
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);
}
}
62 changes: 58 additions & 4 deletions cli/src/sourcemaps/hermes/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<()> {
Expand All @@ -35,8 +49,34 @@ 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 \
keep the previously uploaded map, so the build that changes release mode would \
fail to replace it. 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. 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 \
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: {}",
Expand Down Expand Up @@ -68,9 +108,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()?);
Expand Down Expand Up @@ -101,6 +150,11 @@ pub fn upload(args: &Args) -> Result<()> {
],
);

// 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();
let (summary, upload_result) = symbol_sets::upload_with_retry(
uploads,
Expand Down
35 changes: 28 additions & 7 deletions cli/src/sourcemaps/inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 => {
Expand Down
Loading
Loading