Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7efdf79
Report artifact GC in one JSON shape and count every rollback leg (#1…
mikolalysenko Oct 9, 2026
f7977fa
Print scan's GC dry-run preview in the shared gc shape
mikolalysenko Oct 9, 2026
a7d7c57
Lay the envelope foundation every --json command will share
mikolalysenko Oct 9, 2026
c6f8210
Print rollback --json as the unified envelope
mikolalysenko Oct 9, 2026
e28f4d7
Map manifest load failures to one error code on every envelope comman…
mikolalysenko Oct 9, 2026
ea70e1b
Report hosted-mode adoption and self-update in telemetry
mikolalysenko Oct 9, 2026
68dd8cb
Assert the rollback envelope in the rollback test suites
mikolalysenko Oct 9, 2026
020c607
Move every rollback --json assertion onto the envelope
mikolalysenko Oct 9, 2026
3883c65
Document the unified manifest-load codes and remove/repair event shapes
mikolalysenko Oct 9, 2026
ecbdd1e
Expect rollback_failed when every Bun global-store package is refused
mikolalysenko Oct 9, 2026
9714d57
Merge branch 'main' into fix/gc-report-json-1257
mikolalysenko Oct 9, 2026
6e47dd6
Document rollback's unified envelope in CLI_CONTRACT.md
mikolalysenko Oct 9, 2026
56a14e9
Move scan and get --json onto the shared envelope
mikolalysenko Oct 9, 2026
41ec21e
Merge v5/envelope-rollback into the envelope integration branch
mikolalysenko Oct 9, 2026
958265b
Record the envelope rollback keys in the Python backtest harnesses
mikolalysenko Oct 9, 2026
53fb830
Document the scan/get envelope and add shape and parity tests
mikolalysenko Oct 9, 2026
b803c8d
Update scan and get tests for the envelope shape
mikolalysenko Oct 9, 2026
8b343ea
Merge v5/envelope-scanget into the envelope integration branch
mikolalysenko Oct 9, 2026
7a00157
Report requested and wired patch modes in patch_scanned
mikolalysenko Oct 9, 2026
7f842ce
Drop an unrelated rustfmt change to upstream/mod.rs
mikolalysenko Oct 9, 2026
5792a86
Silence an unused binding in the yarn berry hosted test
mikolalysenko Oct 9, 2026
603b03b
Merge #1273's branch (with main) into the envelope integration branch
mikolalysenko Oct 9, 2026
48c63c6
Merge main into the envelope integration branch
mikolalysenko Oct 9, 2026
1e69b30
Merge branch 'main' into fix/gc-report-json-1257
claude Oct 9, 2026
bc9c9be
Restore per-caller bun.lockb rollback status
claude Oct 9, 2026
81dece4
Merge main into fix/gc-report-json-1257
mikolalysenko Oct 10, 2026
91bf3ea
Read scan's unified dry-run GC key in the #1062 prune preview test
mikolalysenko Oct 10, 2026
2f5a7ae
Merge fix/gc-report-json-1257 (with main) into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
5ba2c9a
Read the v5 envelope in e2e suites, compat harnesses and the scan bench
mikolalysenko Oct 10, 2026
572b4dc
Merge fix/gc-report-json-1257 into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
37f3d65
Send hosted failure telemetry before the JSON error prints
mikolalysenko Oct 10, 2026
c049dfd
Normalize CRLF in the contract tests; count every rollback leg in bun…
mikolalysenko Oct 10, 2026
70f0902
Merge fix/gc-report-json-1257 into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
731b51b
Carry the vendor engine's error into the scan/get envelope
mikolalysenko Oct 10, 2026
d9ab173
Merge main into fix/gc-report-json-1257
mikolalysenko Oct 10, 2026
c08bae1
Merge fix/gc-report-json-1257 into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
6c2b2d3
Merge remote-tracking branch 'origin/main' into HEAD
mikolalysenko Oct 10, 2026
86d1ba2
Read the unified dry-run GC key in main's Cargo shared-cache prune test
mikolalysenko Oct 10, 2026
515c0bc
Merge fix/gc-report-json-1257 into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
8ab1478
Read the cargo-cache keep under the one GC shape's preview key
mikolalysenko Oct 10, 2026
ff9704a
Merge remote-tracking branch 'origin/fix/gc-report-json-1257' into HEAD
mikolalysenko Oct 10, 2026
d583874
Merge fix/gc-report-json-1257 (ff9704a21) into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
234a1ae
Merge main into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
168d3cc
Merge main into v5/json-envelope-unify
mikolalysenko Oct 10, 2026
fd5987c
Merge v5/json-envelope-unify into fix/telemetry-depscan-compat
mikolalysenko Oct 10, 2026
1df3963
Count hosted applied events in the hosted telemetry e2e
mikolalysenko Oct 10, 2026
1ca9640
Merge origin/main into fix/telemetry-depscan-compat
mikolalysenko Oct 10, 2026
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
16 changes: 16 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,22 @@ The notice is preceded by one blank line (it follows the command's own output, o

State lives at `$XDG_CACHE_HOME`|`~/.cache` (Unix/macOS) or `%LOCALAPPDATA%` (Windows) + `/socket-patch/update-check.json` (camelCase JSON: `schemaVersion`, `lastCheckAt`, `latestSeen`, `lastNotifiedAt`; unix seconds). A completed `--update` refreshes `latestSeen`, so the notifier never nags about a version the user just installed.

## Telemetry events

Each command POSTs its outcome as one JSON event (`{event_sender_created_at, event_type, context: {version, platform, arch, command}, session_id, metadata?, error?: {type, message}}`; no `event_kind`, so the backend stores it as a required event) to `<api>/v0/orgs/<org>/telemetry` with the bearer when the run has a token and an org, else anonymously to `<proxy>/patch/telemetry`. `--no-telemetry`/`SOCKET_TELEMETRY_DISABLED`, `SOCKET_OFFLINE`/`--offline` and `VITEST=true` send nothing. Every event's `context.version` is the client version. Error messages have the home directory replaced by `~`. Every `event_type` must be in depscan's patch event map (`telemetry-discriminators.ts`), or it is stored as `external` and drops out of the patch dashboards; `telemetry.rs` pins that list in a test.

| Command | Success | Failure | Metadata |
|---|---|---|---|
| `scan` | `patch_scanned` | `patch_scan_failed` | `packages_scanned`, `free_patches`, `paid_patches`, `can_access_paid`, `ecosystems` (found by the crawl), `fallback_to_proxy`, `mode` (requested: `hosted` / `vendored` / `agent`, or `report` with none), `project_modes` (already wired: `hosted` lockfile pins, `vendored` ledger, `agent` manifest) |
| `scan`/`get --mode hosted` (non-empty selection) | `patch_applied` | `patch_apply_failed` | `mode: "hosted"`, `patches_count` (pins confirmed), `files_count` (files rewritten), `dry_run` |
| `apply` (and the nested apply of `scan`/`get --mode agent`) | `patch_applied` | `patch_apply_failed` | `mode: "agent"`, `patches_count`, `dry_run` |
| `vendor` / `scan`/`get --mode vendored` | `patch_vendored` | `patch_vendor_failed` | `patches_count`, `dry_run` |
| `get` | `patch_fetched` | `patch_fetch_failed` | `uuid`, `tier`, `ecosystem`, `download_mode`, `fallback_to_proxy` |
| `remove` / `rollback` | `patch_removed` / `patch_rolled_back` | `patch_remove_failed` / `patch_rollback_failed` | `removed_count` / `rolled_back_count` |
| `list` / `repair` | `patch_listed` / `patch_repaired` | — / `patch_repair_failed` | `patches_count` / `blobs_added`, `blobs_removed`, `bytes_freed` |
| `vex` | `vex_generated` | `vex_failed` | `advisories_count`, `format`, `output_kind` |
| `--update` (an install, not a no-op or `--dry-run`) | `cli_updated` | `cli_update_failed` | `from_version`, `to_version`, `channel`, `pinned`, `forced` / `error_code` (the envelope's) |

## Environment variables

Public configuration uses the `SOCKET_*` names below. The three deprecated v3/v4 environment aliases and `SOCKET_FORCE` were removed in v5; see [Removed env vars](#removed-env-vars).
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2210,6 +2210,7 @@ async fn run_get_hosted(
None,
// `get` is explicit intent: the rollout cap never applies.
None,
"get",
)
.await
}
Expand Down
152 changes: 139 additions & 13 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,22 @@ fn emit_json_error(
code: &str,
message: &str,
) {
emit_scan(&json_error_envelope(common, scan_result, code, message));
}

/// The envelope [`emit_json_error`] prints, built without printing it, so
/// [`run_redirect_selected`] can send its failure telemetry first.
fn json_error_envelope(
common: &crate::args::GlobalArgs,
scan_result: Option<Envelope>,
code: &str,
message: &str,
) -> Envelope {
let mut env = scan_result.unwrap_or_else(|| scan_envelope(common));
env.mark_error(EnvelopeError::new(code, message));
// The rollout block describes a successful run only.
env.extra.remove("rollout");
emit_scan(&env);
env
}

/// The hosted run's `redirect` payload: `{mode: "hosted", rewrittenFiles}`
Expand Down Expand Up @@ -160,10 +171,16 @@ fn refuse(
common: &crate::args::GlobalArgs,
scan_result: Option<Envelope>,
refusal: &socket_patch_core::hosted::engine::Refusal,
json_error: &mut Option<Envelope>,
) -> i32 {
eprintln!("Error ({}): {}", refusal.code, refusal.message);
if common.json {
emit_json_error(common, scan_result, &refusal.code, &refusal.message);
*json_error = Some(json_error_envelope(
common,
scan_result,
&refusal.code,
&refusal.message,
));
}
1
}
Expand All @@ -182,6 +199,7 @@ fn refuse(
fn acquire_hosted_lock(
common: &crate::args::GlobalArgs,
scan_result: &mut Option<Envelope>,
json_error: &mut Option<Envelope>,
) -> Result<LockGuard, i32> {
let socket_dir = common.socket_dir();
let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0));
Expand All @@ -196,7 +214,12 @@ fn acquire_hosted_lock(
crate::commands::lock_cli::format_lock_error(&socket_dir, &err, timeout)
);
if common.json {
emit_json_error(common, scan_result.take(), code, &message);
*json_error = Some(json_error_envelope(
common,
scan_result.take(),
code,
&message,
));
}
Err(1)
}
Expand Down Expand Up @@ -662,6 +685,7 @@ pub(super) async fn run_redirect(
scan_result,
npm_prior,
Some(super::rollout::Gate::new(stage, rows).with_prior(prior)),
"scan",
)
.await
}
Expand Down Expand Up @@ -751,8 +775,71 @@ fn discovery_after_writes<'d>(
/// would get a bare scan envelope without its own payload).
/// `prune_requested` only feeds the `redirect_prune_ignored` warning —
/// `get` passes `false`.
///
/// Telemetry: a run with a non-empty selection reports `patch_applied`
/// (`mode: "hosted"`, sent before the result prints) or, on a non-zero
/// exit before that point, `patch_apply_failed`. `command` names the
/// caller (`scan` / `get`) in the event's context.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_redirect_selected(
common: &crate::args::GlobalArgs,
vex: &crate::commands::vex::VexEmbedArgs,
prune_requested: bool,
api_client: &socket_patch_core::api::client::ApiClient,
selected: &[(String, String)],
scan_result: Option<Envelope>,
npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>,
rollout: Option<super::rollout::Gate<'_>>,
command: &'static str,
) -> i32 {
let mut telemetry = HostedTelemetry {
command,
auth: socket_patch_core::telemetry::TelemetryAuth::for_client(api_client),
sent: false,
json_error: None,
};
let code = run_redirect_selected_untracked(
common,
vex,
prune_requested,
api_client,
selected,
scan_result,
npm_prior,
rollout,
&mut telemetry,
)
.await;
if code != 0 && !telemetry.sent && !selected.is_empty() {
socket_patch_core::telemetry::track_patch_hosted_failed(
command,
"hosted redirect failed",
common.dry_run,
&telemetry.auth,
)
.await;
}
Comment thread
mikolalysenko marked this conversation as resolved.
// The `--json` error envelope prints only after the failure event went
// out, so a consumer that closes stdout on it cannot drop the event.
if let Some(envelope) = telemetry.json_error.take() {
emit_scan(&envelope);
}
code
}

/// Where [`run_redirect_selected`]'s outcome event goes, and whether the
/// success event already went out.
struct HostedTelemetry {
command: &'static str,
auth: socket_patch_core::telemetry::TelemetryAuth,
sent: bool,
/// A `--json` error envelope a failure path built, printed by
/// [`run_redirect_selected`] after the failure event is sent.
json_error: Option<Envelope>,
}

#[allow(clippy::too_many_arguments)]
async fn run_redirect_selected_untracked(
common: &crate::args::GlobalArgs,
vex: &crate::commands::vex::VexEmbedArgs,
prune_requested: bool,
Expand All @@ -763,6 +850,7 @@ pub(crate) async fn run_redirect_selected(
// `scan`'s rollout gate: NEW rows past the budget are deferred after
// every write-free eligibility check below (§5.2). `get` passes `None`.
mut rollout: Option<super::rollout::Gate<'_>>,
telemetry: &mut HostedTelemetry,
) -> i32 {
use socket_patch_core::hosted::engine::{
self, Candidate, CandidateFiles, RewriteOptions, SkippedPatch,
Expand Down Expand Up @@ -813,12 +901,12 @@ pub(crate) async fn run_redirect_selected(
format_error_line(&message)
);
if common.json {
emit_json_error(
telemetry.json_error = Some(json_error_envelope(
common,
scan_result.take(),
"reference_resolve_failed",
&message,
);
));
}
return 1;
}
Expand Down Expand Up @@ -853,6 +941,7 @@ pub(crate) async fn run_redirect_selected(
common,
scan_result.take(),
&engine::bun_lockb_symlink_refusal(),
&mut telemetry.json_error,
);
}
// A workspace member whose lock lives in an ancestor directory (pnpm
Expand All @@ -867,7 +956,12 @@ pub(crate) async fn run_redirect_selected(
)
.await
{
return refuse(common, scan_result.take(), &refusal);
return refuse(
common,
scan_result.take(),
&refusal,
&mut telemetry.json_error,
);
}

// vlt artifact preflight: before any takeover or rewrite (dry runs
Expand Down Expand Up @@ -928,7 +1022,7 @@ pub(crate) async fn run_redirect_selected(
})
});
let mut lock: Option<LockGuard> = if !common.dry_run && !candidates.is_empty() && may_write {
match acquire_hosted_lock(common, &mut scan_result) {
match acquire_hosted_lock(common, &mut scan_result, &mut telemetry.json_error) {
Ok(guard) => Some(guard),
Err(code) => return code,
}
Expand Down Expand Up @@ -972,7 +1066,14 @@ pub(crate) async fn run_redirect_selected(
.await
{
Ok(t) => t,
Err(refusal) => return refuse(common, scan_result.take(), &refusal),
Err(refusal) => {
return refuse(
common,
scan_result.take(),
&refusal,
&mut telemetry.json_error,
)
}
};
takeover
.stage(common, &group, &mut candidates, &mut skipped)
Expand Down Expand Up @@ -1305,7 +1406,7 @@ pub(crate) async fn run_redirect_selected(
} = takeover.finish(common.dry_run, &mut vendor_state, &done.rewrite.warnings);
// A takeover writes the vendored ledger: hold the lock for it too.
if lock.is_none() && !common.dry_run && !takeover_migrated.is_empty() {
match acquire_hosted_lock(common, &mut scan_result) {
match acquire_hosted_lock(common, &mut scan_result, &mut telemetry.json_error) {
Ok(guard) => lock = Some(guard),
Err(code) => return code,
}
Expand All @@ -1328,13 +1429,23 @@ pub(crate) async fn run_redirect_selected(
// hosted → upstream restore's staged flush) already refuses linked
// files, so the write side must too.
if let Some(refusal) = engine::guard(&view, &done, &candidates) {
return refuse(common, scan_result.take(), &refusal);
return refuse(
common,
scan_result.take(),
&refusal,
&mut telemetry.json_error,
);
}
// Defense in depth for the Gradle planner: a settings file it plans to
// CREATE (it never read one) must not already be on disk, or the
// atomic write would replace the user's settings with the apply line.
if let Some(refusal) = created_settings_over_existing(&common.cwd, &done) {
return refuse(common, scan_result.take(), &refusal);
return refuse(
common,
scan_result.take(),
&refusal,
&mut telemetry.json_error,
);
}

if !common.dry_run {
Expand Down Expand Up @@ -1416,12 +1527,12 @@ pub(crate) async fn run_redirect_selected(
{
eprintln!("{}", format_error_line(&message));
if common.json {
emit_json_error(
telemetry.json_error = Some(json_error_envelope(
common,
scan_result.take(),
"lockfile_write_failed",
&message,
);
));
}
return 1;
}
Expand Down Expand Up @@ -1724,6 +1835,19 @@ pub(crate) async fn run_redirect_selected(
&confirmed,
&skipped,
);
// Sent before the result prints: a consumer that closes stdout early
// (SIGPIPE on the first write) must not lose it.
if !selected.is_empty() {
socket_patch_core::telemetry::track_patch_hosted(
telemetry.command,
confirmed.len(),
done.rewritten.len(),
common.dry_run,
&telemetry.auth,
)
.await;
}
telemetry.sent = true;
if common.json {
// Record the redirect outcome into the caller's envelope (scan's
// discovery payload, or get's narrowing events): per-patch events
Expand Down Expand Up @@ -2346,6 +2470,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>(
scan_result: Option<Envelope>,
npm_prior: Option<&'a crate::ecosystem_dispatch::NpmCrawlSnapshot>,
rollout: Option<super::rollout::Gate<'a>>,
command: &'static str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = i32> + 'a>> {
Box::pin(run_redirect_selected(
common,
Expand All @@ -2356,6 +2481,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>(
scan_result,
npm_prior,
rollout,
command,
))
}

Expand Down
Loading
Loading