Skip to content

Commit 7a00157

Browse files
mikolalysenkoclaude
andcommitted
Report requested and wired patch modes in patch_scanned
`patch_scanned` now carries `mode`, the mode the run was asked for (`hosted` / `vendored` / `agent`, or `report` with none), and `project_modes`, the modes the project is already wired for: hosted lockfile pins, a vendor ledger, an agent manifest (several during a migration). With the client version every event already carries in `context.version`, this gives per-version mode adoption from the one event every scan sends, including runs that change nothing. Fields on the existing event: always stored (no event_kind), works for anonymous proxy traffic, no depscan change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ea70e1b commit 7a00157

5 files changed

Lines changed: 162 additions & 10 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,11 +1050,11 @@ State lives at `$XDG_CACHE_HOME`|`~/.cache` (Unix/macOS) or `%LOCALAPPDATA%` (Wi
10501050

10511051
## Telemetry events
10521052

1053-
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. 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.
1053+
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.
10541054

10551055
| Command | Success | Failure | Metadata |
10561056
|---|---|---|---|
1057-
| `scan` | `patch_scanned` | `patch_scan_failed` | `packages_scanned`, `free_patches`, `paid_patches`, `can_access_paid`, `ecosystems` (found by the crawl), `fallback_to_proxy` |
1057+
| `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) |
10581058
| `scan`/`get --mode hosted` (non-empty selection) | `patch_applied` | `patch_apply_failed` | `mode: "hosted"`, `patches_count` (pins confirmed), `files_count` (files rewritten), `dry_run` |
10591059
| `apply` (and the nested apply of `scan`/`get --mode agent`) | `patch_applied` | `patch_apply_failed` | `mode: "agent"`, `patches_count`, `dry_run` |
10601060
| `vendor` / `scan`/`get --mode vendored` | `patch_vendored` | `patch_vendor_failed` | `patches_count`, `dry_run` |

‎crates/socket-patch-cli/src/commands/scan/mod.rs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1963,6 +1963,23 @@ async fn run_scan(
19631963
read_set: read_set.as_ref(),
19641964
})
19651965
};
1966+
// Telemetry: the mode this run was asked for, and the modes the
1967+
// project is already wired for (it may hold more than one mid-migration).
1968+
let requested_mode = args.mode.map_or("report", ScanMode::cli_name);
1969+
let project_modes: Vec<&str> = [
1970+
(
1971+
!hosted_pin_list.is_empty() || !hosted_unlocked_pins.is_empty(),
1972+
"hosted",
1973+
),
1974+
(!vendored_purls.is_empty(), "vendored"),
1975+
(
1976+
existing_manifest.is_some_and(|m| !m.patches.is_empty()),
1977+
"agent",
1978+
),
1979+
]
1980+
.into_iter()
1981+
.filter_map(|(present, mode)| present.then_some(mode))
1982+
.collect();
19661983
let hosted_state = (!args.common.is_global())
19671984
.then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list));
19681985
let redirect_state = hosted_state.as_ref();
@@ -2140,7 +2157,18 @@ async fn run_scan(
21402157
}
21412158
}
21422159
// Telemetry: empty-scan still counts as a successful scan.
2143-
spawn_patch_scanned(telemetry, 0, 0, 0, false, &[], false, &telemetry_auth);
2160+
spawn_patch_scanned(
2161+
telemetry,
2162+
0,
2163+
0,
2164+
0,
2165+
false,
2166+
&[],
2167+
false,
2168+
requested_mode,
2169+
&project_modes,
2170+
&telemetry_auth,
2171+
);
21442172
// The result prints right away: nothing to overlap the send with.
21452173
telemetry.flush().await;
21462174
if args.common.json {
@@ -2458,6 +2486,8 @@ async fn run_scan(
24582486
can_access_paid_patches,
24592487
&scanned_ecosystems(&all_purls),
24602488
fallback_to_proxy,
2489+
requested_mode,
2490+
&project_modes,
24612491
&telemetry_auth,
24622492
);
24632493

‎crates/socket-patch-cli/tests/cli/telemetry_e2e.rs‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1077,7 +1077,7 @@ async fn scan_delivers_telemetry_before_writing_to_a_closed_stderr() {
10771077
const HOSTED_NAME: &str = "telemetry-hosted";
10781078
const HOSTED_PURL: &str = "pkg:npm/telemetry-hosted@1.0.0";
10791079
const HOSTED_UUID: &str = "11111111-1111-4111-8111-111111111111";
1080-
const HOSTED_URL: &str = "http://patch.test/patch/npm/telemetry-hosted/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/telemetry-hosted-1.0.0.tgz";
1080+
const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/telemetry-hosted/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/telemetry-hosted-1.0.0.tgz";
10811081

10821082
/// An npm project whose lockfile resolves [`HOSTED_NAME`] upstream (the
10831083
/// `covgap_commands_scan_hosted.rs` `write_npm_project` shape).
@@ -1225,6 +1225,9 @@ async fn hosted_scan_reports_patch_applied_with_hosted_mode() {
12251225
scanned["metadata"]["ecosystems"],
12261226
serde_json::json!(["npm"])
12271227
);
1228+
// Default mode, on a project nothing is wired into yet.
1229+
assert_eq!(scanned["metadata"]["mode"], "hosted");
1230+
assert_eq!(scanned["metadata"]["project_modes"], serde_json::json!([]));
12281231
assert_eq!(scanned["metadata"]["free_patches"], 1);
12291232

12301233
let applied = &bodies[1];
@@ -1245,6 +1248,80 @@ async fn hosted_scan_reports_patch_applied_with_hosted_mode() {
12451248
applied["session_id"], scanned["session_id"],
12461249
"one run, one session"
12471250
);
1251+
1252+
// A re-scan sees the hosted pins the first run wrote.
1253+
let (code, stdout, stderr) = run_cmd(tmp.path(), &mock.uri(), "scan", &[], &[]);
1254+
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
1255+
let bodies = telemetry_bodies(&mock).await;
1256+
let rescanned = bodies
1257+
.iter()
1258+
.filter(|b| b["event_type"] == "patch_scanned")
1259+
.nth(1)
1260+
.expect("the re-scan's patch_scanned");
1261+
assert_eq!(
1262+
rescanned["metadata"]["project_modes"],
1263+
serde_json::json!(["hosted"])
1264+
);
1265+
assert_ne!(rescanned["session_id"], scanned["session_id"]);
1266+
}
1267+
1268+
/// `patch_scanned` carries the requested mode and the modes the project is
1269+
/// already wired for: here an agent manifest, scanned with `--mode agent`.
1270+
#[tokio::test]
1271+
async fn scan_reports_requested_and_project_modes() {
1272+
let mock = setup_mock(
1273+
serde_json::json!({ "packages": [], "canAccessPaidPatches": false }),
1274+
None,
1275+
)
1276+
.await;
1277+
let tmp = tempfile::tempdir().expect("tempdir");
1278+
write_root_package_json(tmp.path());
1279+
write_npm_package(tmp.path(), "minimist", "1.2.2");
1280+
let socket = tmp.path().join(".socket");
1281+
std::fs::create_dir_all(&socket).unwrap();
1282+
std::fs::write(
1283+
socket.join("manifest.json"),
1284+
serde_json::json!({
1285+
"patches": {
1286+
"pkg:npm/minimist@1.2.2": {
1287+
"uuid": "33333333-3333-4333-8333-333333333333",
1288+
"exportedAt": "2024-01-01T00:00:00Z",
1289+
"files": {},
1290+
"vulnerabilities": {},
1291+
"description": "x",
1292+
"license": "MIT",
1293+
"tier": "free"
1294+
}
1295+
}
1296+
})
1297+
.to_string(),
1298+
)
1299+
.unwrap();
1300+
1301+
let (code, stdout, stderr) = run_cmd(
1302+
tmp.path(),
1303+
&mock.uri(),
1304+
"scan",
1305+
&["--mode", "agent", "--dry-run"],
1306+
&[],
1307+
);
1308+
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
1309+
1310+
let bodies = telemetry_bodies(&mock).await;
1311+
let scanned = bodies
1312+
.iter()
1313+
.find(|b| b["event_type"] == "patch_scanned")
1314+
.unwrap_or_else(|| panic!("no patch_scanned: {bodies:#?}"));
1315+
assert_eq!(scanned["metadata"]["mode"], "agent");
1316+
assert_eq!(
1317+
scanned["metadata"]["project_modes"],
1318+
serde_json::json!(["agent"])
1319+
);
1320+
assert_eq!(
1321+
scanned["context"]["version"],
1322+
env!("CARGO_PKG_VERSION"),
1323+
"every event carries the client version"
1324+
);
12481325
}
12491326

12501327
/// A hosted run that fails after selecting patches reports

‎crates/socket-patch-core/src/patch/redirect/upstream/mod.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,10 @@ mod tests {
917917
fn bun_lock_remedies_name_the_forced_reinstall() {
918918
for file in ["bun.lockb", "bun.lock", "packages/app/bun.lockb"] {
919919
let remedy = checkout_remedy(&[file.to_string()]);
920-
assert!(remedy.contains(&format!("`git checkout -- {file}`")), "{remedy}");
920+
assert!(
921+
remedy.contains(&format!("`git checkout -- {file}`")),
922+
"{remedy}"
923+
);
921924
assert!(remedy.ends_with(
922925
", then run `bun install --force` (a plain `bun install` keeps the patched copy)"
923926
), "{remedy}");

‎crates/socket-patch-core/src/telemetry.rs‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -654,9 +654,12 @@ pub async fn track_patch_rollback_failed(error: impl std::fmt::Display, auth: &T
654654
// ---------------------------------------------------------------------------
655655

656656
/// The whole `patch_scanned` event — type, command and metadata (per-tier
657-
/// patch counts and whether the call was downgraded to the public proxy
658-
/// after an auth-endpoint 401/403). Both wrappers below build their event
659-
/// here, so the inline and background paths can never drift apart.
657+
/// patch counts, whether the call was downgraded to the public proxy
658+
/// after an auth-endpoint 401/403, the mode the run was asked for —
659+
/// `hosted` / `vendored` / `agent`, or `report` with none — and the modes
660+
/// the project is already wired for, from its hosted lockfile pins, vendor
661+
/// ledger and agent manifest). Both wrappers below build their event here,
662+
/// so the inline and background paths can never drift apart.
660663
#[allow(clippy::too_many_arguments)]
661664
fn prepare_patch_scanned(
662665
packages_scanned: usize,
@@ -665,6 +668,8 @@ fn prepare_patch_scanned(
665668
can_access_paid: bool,
666669
ecosystems: &[String],
667670
fallback_to_proxy: bool,
671+
mode: &str,
672+
project_modes: &[&str],
668673
auth: &TelemetryAuth,
669674
) -> Option<PreparedSend> {
670675
prepare(
@@ -677,6 +682,8 @@ fn prepare_patch_scanned(
677682
"can_access_paid": can_access_paid,
678683
"ecosystems": ecosystems,
679684
"fallback_to_proxy": fallback_to_proxy,
685+
"mode": mode,
686+
"project_modes": project_modes,
680687
}),
681688
None::<&str>,
682689
auth,
@@ -704,6 +711,8 @@ pub async fn track_patch_scanned(
704711
can_access_paid: bool,
705712
ecosystems: &[String],
706713
fallback_to_proxy: bool,
714+
mode: &str,
715+
project_modes: &[&str],
707716
auth: &TelemetryAuth,
708717
) {
709718
fire_prepared(prepare_patch_scanned(
@@ -713,6 +722,8 @@ pub async fn track_patch_scanned(
713722
can_access_paid,
714723
ecosystems,
715724
fallback_to_proxy,
725+
mode,
726+
project_modes,
716727
auth,
717728
))
718729
.await;
@@ -730,6 +741,8 @@ pub fn spawn_patch_scanned(
730741
can_access_paid: bool,
731742
ecosystems: &[String],
732743
fallback_to_proxy: bool,
744+
mode: &str,
745+
project_modes: &[&str],
733746
auth: &TelemetryAuth,
734747
) {
735748
pending.spawn_prepared(prepare_patch_scanned(
@@ -739,6 +752,8 @@ pub fn spawn_patch_scanned(
739752
can_access_paid,
740753
ecosystems,
741754
fallback_to_proxy,
755+
mode,
756+
project_modes,
742757
auth,
743758
));
744759
}
@@ -1121,8 +1136,30 @@ mod tests {
11211136
let ecosystems = vec!["npm".to_string(), "pypi".to_string()];
11221137
let auth = TelemetryAuth::from_credentials(None, None);
11231138
let mut pending = PendingTelemetry::new();
1124-
track_patch_scanned(5, 3, 2, true, &ecosystems, true, &auth).await;
1125-
spawn_patch_scanned(&mut pending, 5, 3, 2, true, &ecosystems, true, &auth);
1139+
track_patch_scanned(
1140+
5,
1141+
3,
1142+
2,
1143+
true,
1144+
&ecosystems,
1145+
true,
1146+
"hosted",
1147+
&["vendored"],
1148+
&auth,
1149+
)
1150+
.await;
1151+
spawn_patch_scanned(
1152+
&mut pending,
1153+
5,
1154+
3,
1155+
2,
1156+
true,
1157+
&ecosystems,
1158+
true,
1159+
"hosted",
1160+
&["vendored"],
1161+
&auth,
1162+
);
11261163
pending.flush().await;
11271164
track_patch_scan_failed("all batches failed", true, &auth).await;
11281165
spawn_patch_scan_failed(&mut pending, "all batches failed", true, &auth);
@@ -1151,6 +1188,11 @@ mod tests {
11511188
assert_eq!(bodies[0]["event_type"], "patch_scanned");
11521189
assert_eq!(bodies[0]["context"]["command"], "scan");
11531190
assert_eq!(bodies[0]["metadata"]["free_patches"], 3);
1191+
assert_eq!(bodies[0]["metadata"]["mode"], "hosted");
1192+
assert_eq!(
1193+
bodies[0]["metadata"]["project_modes"],
1194+
serde_json::json!(["vendored"])
1195+
);
11541196
assert_eq!(bodies[2]["event_type"], "patch_scan_failed");
11551197
assert_eq!(bodies[2]["context"]["command"], "scan");
11561198
assert_eq!(bodies[2]["error"]["message"], "all batches failed");

0 commit comments

Comments
 (0)