Skip to content

Commit 8bc90ec

Browse files
anvansterclaude
andcommitted
feat(telemetry): engagement.machineProfile — triage the graph_load crash cohort
One-time activation event with bucketed/enum machine stats (no PII) so we can fingerprint which machines corrupt their graph.db. server.recovery showed the 0.18.5 redirect fires but the crash rate stayed flat (~13%) — corruption keeps being created. This narrows WHY: - data_dir_kind: local | cloud_onedrive | cloud_other | unc_network | unknown (cloud-sync / network profiles hold handles + rewrite RocksDB files → torn writes; classifies the resolved ~/.codegraph path, never logs it) - antivirus_kind: defender_only | third_party | none | unknown (Windows SecurityCenter2; third-party AV holding handles is the other suspect; coarse class only, never the product name) - machine_kind: physical | vm | wsl | container | unknown (VM via MAC OUI prefix — prefix tested, MAC never logged) - ramBucket from total RAM Detection lives in telemetry/machineProfile.ts; the AV probe is async with a 4s timeout, off the activation path. Every emitted field is a fixed enum or coarse bucket re-normalized in the reporter; raw path/MAC/AV-name never leave the detector. Same opt-out gate as all other events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2fe1902 commit 8bc90ec

5 files changed

Lines changed: 229 additions & 0 deletions

File tree

vscode/src/extension.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CodeGraphAIProvider } from './ai/contextProvider';
1717
import { CodeGraphToolManager } from './ai/toolManager';
1818
import { getServerPath } from './server';
1919
import { createReporter, setServerEdition, type Reporter } from './telemetry/reporter';
20+
import { detectMachineProfile } from './telemetry/machineProfile';
2021

2122
let client: LanguageClient;
2223
let aiProvider: CodeGraphAIProvider;
@@ -501,6 +502,16 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
501502
// Settings snapshot once per session — observe what defaults users override.
502503
reporter.engagementSettingsSnapshot();
503504

505+
// One-time machine fingerprint (bucketed/enum only, no PII) to triage the
506+
// graph_load crash cohort — does it skew toward cloud-synced data dirs,
507+
// third-party AV, VMs, or low RAM? Detection runs off the activation path
508+
// (the Windows AV probe is async) and never blocks startup.
509+
void detectMachineProfile()
510+
.then((profile) => reporter.engagementMachineProfile(profile))
511+
.catch(() => {
512+
/* never block on telemetry */
513+
});
514+
504515
// Check if workspace is indexed — prompt if not.
505516
// Delay the check briefly: the server loads the persisted graph and
506517
// rebuilds search indexes after the LSP handshake. A symbolSearch

vscode/src/telemetry/allowlists.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,47 @@ export const EXIT_SIGNALS = [
310310
'SIGHUP',
311311
'other',
312312
] as const;
313+
/**
314+
* Machine-profile enums (engagement.machineProfile) — bucketed, never PII.
315+
* Used to fingerprint the graph_load crash cohort: which classes of machine
316+
* corrupt their graph.db. The DETECTOR may read a path / MAC / AV name to
317+
* classify, but only ever emits the enum here — never the raw value.
318+
*/
319+
320+
/** Where ~/.codegraph resolves — the top corruption suspect on Windows. */
321+
export const DATA_DIR_KINDS = [
322+
'local',
323+
'cloud_onedrive',
324+
'cloud_other',
325+
'unc_network',
326+
'unknown',
327+
] as const;
328+
export type DataDirKind = (typeof DATA_DIR_KINDS)[number];
329+
const DATA_DIR_KIND_SET = new Set<string>(DATA_DIR_KINDS);
330+
export function normalizeDataDirKind(s: string | undefined): DataDirKind {
331+
if (!s) return 'unknown';
332+
return (DATA_DIR_KIND_SET.has(s) ? s : 'unknown') as DataDirKind;
333+
}
334+
335+
/** Physical vs virtualized — affects disk/fsync durability semantics. */
336+
export const MACHINE_KINDS = ['physical', 'vm', 'wsl', 'container', 'unknown'] as const;
337+
export type MachineKind = (typeof MACHINE_KINDS)[number];
338+
const MACHINE_KIND_SET = new Set<string>(MACHINE_KINDS);
339+
export function normalizeMachineKind(s: string | undefined): MachineKind {
340+
if (!s) return 'unknown';
341+
return (MACHINE_KIND_SET.has(s) ? s : 'unknown') as MachineKind;
342+
}
343+
344+
/** AV class (Windows) — third-party AV holding file handles is a corruption
345+
* suspect. Coarse class only; never the product name. */
346+
export const ANTIVIRUS_KINDS = ['defender_only', 'third_party', 'none', 'unknown'] as const;
347+
export type AntivirusKind = (typeof ANTIVIRUS_KINDS)[number];
348+
const ANTIVIRUS_KIND_SET = new Set<string>(ANTIVIRUS_KINDS);
349+
export function normalizeAntivirusKind(s: string | undefined): AntivirusKind {
350+
if (!s) return 'unknown';
351+
return (ANTIVIRUS_KIND_SET.has(s) ? s : 'unknown') as AntivirusKind;
352+
}
353+
313354
export type ExitSignal = (typeof EXIT_SIGNALS)[number];
314355
const EXIT_SIGNAL_SET = new Set<string>(EXIT_SIGNALS);
315356
export function normalizeExitSignal(s: string | undefined): ExitSignal {

vscode/src/telemetry/buckets.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,14 @@ export function settingNumberBucket(n: number): string {
9797
if (n <= 100_000) return '10k-100k';
9898
return '>100k';
9999
}
100+
101+
/** Total physical RAM in GB → coarse bucket (never the exact size). */
102+
export function ramBucket(gb: number): string {
103+
if (!Number.isFinite(gb) || gb <= 0) return 'unknown';
104+
if (gb < 4) return '<4';
105+
if (gb < 8) return '4-8';
106+
if (gb < 16) return '8-16';
107+
if (gb < 32) return '16-32';
108+
if (gb < 64) return '32-64';
109+
return '>=64';
110+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2025-2026 Andrey Vasilevsky <anvanster@gmail.com>
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Machine-profile detection for `engagement.machineProfile`.
6+
*
7+
* Purpose: fingerprint the graph_load crash cohort — which classes of machine
8+
* corrupt their graph.db (cloud-synced data dir, third-party AV holding file
9+
* handles, VM/virtual-disk fsync semantics, low RAM). Each detector MAY read a
10+
* path, a MAC OUI, or an AV product name to CLASSIFY, but only ever returns a
11+
* fixed enum / coarse number. No path, MAC, hostname, product name, or exact
12+
* byte count ever leaves this module — the reporter buckets/allowlists the
13+
* output again before it is sent. Best-effort; never throws.
14+
*/
15+
16+
import * as os from 'os';
17+
import * as fs from 'fs';
18+
import * as path from 'path';
19+
import { execFile } from 'child_process';
20+
21+
export interface MachineProfile {
22+
/** local | cloud_onedrive | cloud_other | unc_network | unknown */
23+
dataDirKind: string;
24+
/** physical | vm | wsl | container | unknown */
25+
machineKind: string;
26+
/** total physical RAM in GB (reporter buckets it) */
27+
totalRamGb: number;
28+
/** defender_only | third_party | none | unknown (Windows only) */
29+
antivirusKind: string;
30+
}
31+
32+
/**
33+
* Classify where `~/.codegraph` lives WITHOUT logging the path. Cloud-sync
34+
* agents (OneDrive Known Folder Move, Dropbox, …) and network/UNC profiles
35+
* hold handles + rewrite files under RocksDB → torn-write corruption.
36+
*/
37+
function detectDataDirKind(): string {
38+
try {
39+
const dir = path.join(os.homedir(), '.codegraph');
40+
if (dir.startsWith('\\\\')) return 'unc_network';
41+
const lower = dir.toLowerCase();
42+
const oneDriveEnv = [
43+
process.env.OneDrive,
44+
process.env.OneDriveConsumer,
45+
process.env.OneDriveCommercial,
46+
]
47+
.filter((v): v is string => !!v)
48+
.map((v) => v.toLowerCase());
49+
if (oneDriveEnv.some((p) => lower.startsWith(p)) || lower.includes('onedrive')) {
50+
return 'cloud_onedrive';
51+
}
52+
if (/dropbox|google[ _]?drive|\bbox\b|pcloud|icloud|nextcloud|\bmega\b/.test(lower)) {
53+
return 'cloud_other';
54+
}
55+
return 'local';
56+
} catch {
57+
return 'unknown';
58+
}
59+
}
60+
61+
const VM_MAC_OUI_PREFIXES = [
62+
'00:05:69', '00:0c:29', '00:1c:14', '00:50:56', // VMware
63+
'08:00:27', '0a:00:27', // VirtualBox
64+
'00:15:5d', // Hyper-V
65+
'52:54:00', // QEMU/KVM
66+
'00:16:3e', // Xen
67+
];
68+
69+
/** Physical vs virtualized. MAC is read only to test its OUI prefix; the MAC
70+
* itself is never emitted. */
71+
function detectMachineKind(): string {
72+
try {
73+
if (process.platform === 'linux') {
74+
if (process.env.WSL_DISTRO_NAME) return 'wsl';
75+
try {
76+
const rel = os.release().toLowerCase();
77+
if (rel.includes('microsoft') || rel.includes('wsl')) return 'wsl';
78+
} catch {
79+
/* ignore */
80+
}
81+
try {
82+
if (fs.existsSync('/.dockerenv')) return 'container';
83+
const cg = fs.readFileSync('/proc/1/cgroup', 'utf8');
84+
if (/docker|containerd|kubepods|\blxc\b/.test(cg)) return 'container';
85+
} catch {
86+
/* ignore */
87+
}
88+
}
89+
const ifaces = os.networkInterfaces();
90+
for (const name of Object.keys(ifaces)) {
91+
for (const ni of ifaces[name] ?? []) {
92+
const mac = (ni.mac || '').toLowerCase();
93+
if (mac && mac !== '00:00:00:00:00:00' && VM_MAC_OUI_PREFIXES.some((p) => mac.startsWith(p))) {
94+
return 'vm';
95+
}
96+
}
97+
}
98+
return 'physical';
99+
} catch {
100+
return 'unknown';
101+
}
102+
}
103+
104+
/** Windows AV class via SecurityCenter2. Returns only the coarse class — never
105+
* the product name. Off the activation path (async, hard-timeout). */
106+
function detectAntivirusKind(): Promise<string> {
107+
if (process.platform !== 'win32') return Promise.resolve('unknown');
108+
return new Promise((resolve) => {
109+
const ps =
110+
'Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct ' +
111+
'| Select-Object -ExpandProperty displayName';
112+
try {
113+
execFile(
114+
'powershell.exe',
115+
['-NoProfile', '-NonInteractive', '-Command', ps],
116+
{ timeout: 4000, windowsHide: true },
117+
(err, stdout) => {
118+
if (err || !stdout) {
119+
resolve('unknown');
120+
return;
121+
}
122+
const names = stdout
123+
.split(/\r?\n/)
124+
.map((s) => s.trim().toLowerCase())
125+
.filter(Boolean);
126+
if (names.length === 0) {
127+
resolve('none');
128+
return;
129+
}
130+
const isDefender = (n: string) =>
131+
n.includes('defender') || n.includes('microsoft security');
132+
resolve(names.some((n) => !isDefender(n)) ? 'third_party' : 'defender_only');
133+
},
134+
);
135+
} catch {
136+
resolve('unknown');
137+
}
138+
});
139+
}
140+
141+
export async function detectMachineProfile(): Promise<MachineProfile> {
142+
return {
143+
dataDirKind: detectDataDirKind(),
144+
machineKind: detectMachineKind(),
145+
totalRamGb: os.totalmem() / 1024 ** 3,
146+
antivirusKind: await detectAntivirusKind(),
147+
};
148+
}

vscode/src/telemetry/reporter.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ import {
4242
normalizeCrashPhase,
4343
normalizeExitSignal,
4444
normalizeLanguage,
45+
normalizeDataDirKind,
46+
normalizeMachineKind,
47+
normalizeAntivirusKind,
4548
type ServerRestartReason,
4649
SETTINGS_SNAPSHOT_KEYS,
4750
type ToolName,
@@ -50,6 +53,7 @@ import {
5053
import {
5154
durationBucket,
5255
fileCountBucket,
56+
ramBucket,
5357
resultSizeBucket,
5458
settingNumberBucket,
5559
uptimeBucket,
@@ -127,6 +131,8 @@ export interface Reporter {
127131
engagementTreeViewOpened(view: TreeView): void;
128132
engagementGraphPanelOpened(panel: GraphPanel): void;
129133
engagementSettingsSnapshot(): void;
134+
/** One-time machine fingerprint (bucketed/enum only) to triage the graph_load crash cohort. */
135+
engagementMachineProfile(profile: { dataDirKind: string; machineKind: string; totalRamGb: number; antivirusKind: string }): void;
130136

131137
serverCrash(props: { uptimeSeconds: number; lastToolName?: string; restartCount: number; crashCause?: string; crashPhase?: string; exitCode?: number; exitSignal?: string }): void;
132138
/** Poison-recovery decision breadcrumb from the server (counts + enums only). */
@@ -399,6 +405,18 @@ export function createReporter(ctx: vscode.ExtensionContext): Reporter {
399405
}
400406
send('engagement.settingsSnapshot', props, false);
401407
},
408+
engagementMachineProfile(profile) {
409+
send(
410+
'engagement.machineProfile',
411+
{
412+
dataDirKind: normalizeDataDirKind(profile.dataDirKind),
413+
machineKind: normalizeMachineKind(profile.machineKind),
414+
ramBucket: ramBucket(profile.totalRamGb),
415+
antivirusKind: normalizeAntivirusKind(profile.antivirusKind),
416+
},
417+
false,
418+
);
419+
},
402420

403421
serverCrash(props) {
404422
send(

0 commit comments

Comments
 (0)