mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(amr): forward installationId to the vela CLI for analytics correlation (#3634)
* feat(amr): forward installationId to the vela CLI for analytics correlation When the daemon spawns the vela (amr) CLI, pass this installation's id as OD_INSTALLATION_ID once the user has consented to telemetry. vela attaches it to its analytics events so CLI activity can be correlated back to the open-design installation that launched it. Resolution mirrors the /api/analytics/config handler: consent = telemetry.metrics === true; channel-root installation.json wins over the legacy app-config.json field. Reads are synchronous and best-effort — any missing file / parse error / withheld consent simply omits the id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(amr): bump bundled @powerformer/vela-cli to 0.0.11 0.0.11 is the first vela CLI release that consumes OD_INSTALLATION_ID (the env forwarded by this PR's daemon change), so bundle it to ship the installationId analytics correlation end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(amr): mirror readAppConfig defaults in installationId forwarding Address review: the previous helper bailed out entirely when app-config.json was missing or corrupt, so vela correlation was skipped even though the web analytics config (via readAppConfig) would still emit events with the same installationId. Now match readAppConfig / applyTelemetryDefaults semantics: - a missing/corrupt app-config.json is treated as an empty config, not a hard failure — we still consult the channel-root installation.json for the id; - telemetry defaults to on (opt-out model); the id is withheld only when the user has explicitly set telemetry.metrics to a non-true value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(nix): refresh pnpm-deps hashes for vela-cli 0.0.11 bump The bundled @powerformer/vela-cli bump changed pnpm-lock.yaml, so the fixed-output pnpm-deps derivation hashes no longer matched. Apply the CI-generated hash refresh (nix-hash-refresh artifact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(amr): resolve installationId via shared readAppConfigSync Address review: instead of hand-replicating readAppConfig's parsing in the spawn-env helper (which kept diverging on edge cases like invalid telemetry shapes), reuse the real logic. Add a synchronous mirror of readAppConfig: - installation.ts: extract parseInstallationFile + add readInstallationFileSync - app-config.ts: add exported readAppConfigSync (same filterAllowedKeys / validateTelemetry / applyTelemetryDefaults pipeline as the async path; only skips the best-effort migration write) - env.ts: amrAnalyticsIdentityEnv now calls readAppConfigSync and gates on telemetry.metrics === true (matching analytics.ts), so vela correlation can no longer drift from the web analytics config for any config shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(nix): refresh pnpm-deps hashes after main merge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5bcd7d2c26
commit
817885bc34
@@ -12,6 +12,7 @@
|
||||
// for Codex). Those values are local-only and should not be logged or
|
||||
// returned outside this machine.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
@@ -19,6 +20,7 @@ import { expandHomePrefix } from './home-expansion.js';
|
||||
|
||||
import {
|
||||
readInstallationFile,
|
||||
readInstallationFileSync,
|
||||
resolveInstallationDir,
|
||||
writeInstallationFile,
|
||||
type InstallationFilePatch,
|
||||
@@ -463,6 +465,46 @@ export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
|
||||
return applyTelemetryDefaults(base);
|
||||
}
|
||||
|
||||
// Synchronous mirror of readAppConfig for callers that cannot await — e.g.
|
||||
// building the spawn env for the vela CLI inside the synchronous
|
||||
// spawnEnvForAgent. It reuses the exact same parsing, validation and telemetry
|
||||
// defaulting as the async path, so the consent decision and installationId can
|
||||
// never drift from what the rest of the daemon (and the web analytics config)
|
||||
// sees. The only intentional difference is that it skips the best-effort
|
||||
// legacy→channel-root migration *write*, which is a side effect rather than
|
||||
// part of the read result.
|
||||
export function readAppConfigSync(dataDir: string): AppConfigPrefs {
|
||||
const base = readAppConfigFileOnlySync(dataDir);
|
||||
const installation = readInstallationFileSync(resolveInstallationDir(dataDir));
|
||||
if (
|
||||
typeof installation.installationId === 'string' &&
|
||||
installation.installationId.length > 0
|
||||
) {
|
||||
return applyTelemetryDefaults({
|
||||
...base,
|
||||
installationId: installation.installationId,
|
||||
});
|
||||
}
|
||||
return applyTelemetryDefaults(base);
|
||||
}
|
||||
|
||||
function readAppConfigFileOnlySync(dataDir: string): AppConfigPrefs {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
readFileSync(configFile(dataDir), 'utf8'),
|
||||
);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return filterAllowedKeys(parsed as Record<string, unknown>);
|
||||
}
|
||||
return {};
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string; name?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
if (e.name === 'SyntaxError') return {};
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAppConfigFileOnly(dataDir: string): Promise<AppConfigPrefs> {
|
||||
try {
|
||||
const raw = await readFile(configFile(dataDir), 'utf8');
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// (where it sits next to app-config.json and behaves like the legacy
|
||||
// path — fine for dev because dev doesn't have namespace churn).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -58,31 +59,47 @@ function installationFilePath(installationDir: string): string {
|
||||
return join(installationDir, 'installation.json');
|
||||
}
|
||||
|
||||
function parseInstallationFile(raw: string): InstallationFile {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const out: InstallationFile = {};
|
||||
if (typeof obj.installationId === 'string' && obj.installationId.length > 0) {
|
||||
out.installationId = obj.installationId;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function readInstallationFile(
|
||||
installationDir: string,
|
||||
): Promise<InstallationFile> {
|
||||
try {
|
||||
const raw = await readFile(installationFilePath(installationDir), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const out: InstallationFile = {};
|
||||
if (typeof obj.installationId === 'string' && obj.installationId.length > 0) {
|
||||
out.installationId = obj.installationId;
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; name?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
if (e.name === 'SyntaxError') return {};
|
||||
// Anything else (permission denied, EIO) — treat as empty so the
|
||||
return parseInstallationFile(
|
||||
await readFile(installationFilePath(installationDir), 'utf8'),
|
||||
);
|
||||
} catch {
|
||||
// ENOENT, malformed JSON, permission denied, EIO — treat as empty so the
|
||||
// fallback path through app-config.json keeps the daemon alive.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous mirror of readInstallationFile for callers on a sync path (e.g.
|
||||
// building the spawn env for the vela CLI). Same parse + same fail-soft.
|
||||
export function readInstallationFileSync(
|
||||
installationDir: string,
|
||||
): InstallationFile {
|
||||
try {
|
||||
return parseInstallationFile(
|
||||
readFileSync(installationFilePath(installationDir), 'utf8'),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize writes to the same installationDir so concurrent persists
|
||||
// can't truncate each other. Mirrors the writeAppConfig lock strategy.
|
||||
const writeLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
|
||||
import { readAppConfigSync } from '../app-config.js';
|
||||
import { resolveProjectRelativePath } from '../home-expansion.js';
|
||||
import { expandConfiguredEnv } from './paths.js';
|
||||
import { resolveAmrOpenCodeExecutable } from './executables.js';
|
||||
@@ -49,6 +50,35 @@ const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||
// object loses Node's case-insensitive accessor — `Anthropic_Api_Key`
|
||||
// would survive a literal `delete env.ANTHROPIC_API_KEY` and still reach
|
||||
// the child. Iterate keys and compare case-insensitively to close that.
|
||||
// When the daemon launches the vela (amr) CLI, forward this installation's id
|
||||
// so vela's analytics can be correlated back to it. spawnEnvForAgent is
|
||||
// synchronous, so this uses readAppConfigSync — the synchronous mirror of
|
||||
// readAppConfig — to resolve consent and the installationId through the exact
|
||||
// same parsing/validation/defaulting the daemon and web analytics config use.
|
||||
// That keeps vela's correlation in lockstep with what the web side already
|
||||
// emits: telemetry defaults to on (opt-out), the id is withheld only when the
|
||||
// user has explicitly opted out (metrics !== true) or no id exists, and an
|
||||
// unreadable config simply omits the env (vela reports without it).
|
||||
function amrAnalyticsIdentityEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Record<string, string> {
|
||||
const dataDir = env.OD_DATA_DIR?.trim();
|
||||
if (!dataDir) return {};
|
||||
let cfg: { telemetry?: { metrics?: boolean }; installationId?: string | null };
|
||||
try {
|
||||
cfg = readAppConfigSync(dataDir);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
// Matches the analytics gate in analytics.ts (`telemetry?.metrics !== true`).
|
||||
if (cfg.telemetry?.metrics !== true) return {};
|
||||
const installationId = cfg.installationId;
|
||||
if (typeof installationId !== 'string' || installationId.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return { OD_INSTALLATION_ID: installationId };
|
||||
}
|
||||
|
||||
export function spawnEnvForAgent(
|
||||
agentId: string,
|
||||
baseEnv: RuntimeEnvMap,
|
||||
@@ -65,6 +95,7 @@ export function spawnEnvForAgent(
|
||||
);
|
||||
if (agentId === 'amr') {
|
||||
Object.assign(env, amrVelaProfileEnv(env));
|
||||
Object.assign(env, amrAnalyticsIdentityEnv(env));
|
||||
if (!env.OPENCODE_TEST_HOME?.trim() && env.OD_DATA_DIR?.trim()) {
|
||||
env.OPENCODE_TEST_HOME = path.join(
|
||||
env.OD_DATA_DIR.trim(),
|
||||
|
||||
+2
-2
@@ -9,6 +9,6 @@
|
||||
# 1. Temporarily set the consuming `hash = lib.fakeHash;`
|
||||
# 2. Run the relevant nix build/flake check
|
||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||
daemonHash = "sha256-j/JjPITwavF9LOp6g+OZ9GVLD1c7fQki4D5XVXgdlNs=";
|
||||
webHash = "sha256-V1Ua3YEUmfJcnKGLK3y7OdFdcu6A6D1+mRvggm6Jrh4=";
|
||||
daemonHash = "sha256-kOnL4Bhcs6QWPInO9hyoSbBjgfwu6tskLecUDCkMTe4=";
|
||||
webHash = "sha256-rP2UOBuqwIGJpABSdAWJd+hlvdjFNdCkKE7CzesnojQ=";
|
||||
}
|
||||
|
||||
Generated
+25
-25
@@ -678,8 +678,8 @@ importers:
|
||||
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli':
|
||||
specifier: 0.0.10
|
||||
version: 0.0.10
|
||||
specifier: 0.0.11
|
||||
version: 0.0.11
|
||||
|
||||
tools/serve:
|
||||
dependencies:
|
||||
@@ -1817,33 +1817,33 @@ packages:
|
||||
'@posthog/types@1.374.2':
|
||||
resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.10':
|
||||
resolution: {integrity: sha512-/lRuSEJT9mXNIcfhr7qizMUWgAkALT0HEIznsAm6YSkZN2Cd1XjHt9uR/yajfJatSrvyzR2fI6lisDhdi0AxIQ==}
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.11':
|
||||
resolution: {integrity: sha512-F4QinKhfQZi6hcsXfJz3tq6X5VzKIrDnStOCCWl6h8VLO5AUXN7GIPbIPC337MoJ0rOiaHU9awGk5yI9ivx5oA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.10':
|
||||
resolution: {integrity: sha512-XCertlBb5aAt8CXsdLu0Y1HlA7EUvQOKV/2gKSzEUdtviLs9P/If2162gklD8FEc+YCD9nBZCM277UjKakgZkw==}
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.11':
|
||||
resolution: {integrity: sha512-Wkpv9MroS/PIhLfgtk4cIJv4jRULx+q7encGm2kG09d9Y6mfNmeYUIKbt7lf58ArlGbq9Ue9N2WnXICwv16dlg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.10':
|
||||
resolution: {integrity: sha512-uHiKCLeOWPu76OjOu919fj2QNTqyNeVWb6y8aeRLcrGLm6sFw4kZYXysdZMahhondUtl49YoH3VhS6EIhpdEUQ==}
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.11':
|
||||
resolution: {integrity: sha512-Cl/YcFObTOfKw8EViEHQYzpIGq7WO3zpxeHJA493AwDXSsj40rjRMB7mNCofsvslXxD0NVhTSFqE7IFzvpuAzg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@powerformer/vela-cli-linux-x64@0.0.10':
|
||||
resolution: {integrity: sha512-OWD33zlKW6RiOFwCC9oLtAVFgK/6b+YLSQm8bWLByRMC9k2Yfe2cQnH7KqaQl1LKyjhLC3+AnXwYvtW/f4edSA==}
|
||||
'@powerformer/vela-cli-linux-x64@0.0.11':
|
||||
resolution: {integrity: sha512-xanh1LmPpscCdfiV95XQNUJx2/nZWcn+bzrXBJgi1hfPhN6vSClP5J4YrNCLTKrpBH3J6TdIfWNe5Zqa8V0XeQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@powerformer/vela-cli-win32-x64@0.0.10':
|
||||
resolution: {integrity: sha512-x5RCaMjrPcKHRiu+0jB7Zlm5IFqZawoSkVQ2tX8zBYuELkaKe4xCjo2oSikGP+kEKbArxAeM1TXKbv366SWygw==}
|
||||
'@powerformer/vela-cli-win32-x64@0.0.11':
|
||||
resolution: {integrity: sha512-Zp4Swj9t1N5RnoLxl/NVULa2vz8jFJSEZxrXUtC4jGlG6ArqobNPeVOZvIKgDOzbfvH4zvWUc22cAfiZ5zbEjw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@powerformer/vela-cli@0.0.10':
|
||||
resolution: {integrity: sha512-nK5lgFHSYyJTsiQ1A/qdmRvwLJrVFG/RkVxhGk7AZ88986XCZIytJ0km59PvkS2E9Lz57hqOIuhzbNu559xxvg==}
|
||||
'@powerformer/vela-cli@0.0.11':
|
||||
resolution: {integrity: sha512-yS+eC/pMZNjg2/2t4Uh6JefhP+KTU+pvQRJm8wtqUOENlumV3wuaRO87UmGBcPM/T/wyepc1c4Mr3miWDnL+sg==}
|
||||
hasBin: true
|
||||
|
||||
'@preact/signals-core@1.14.2':
|
||||
@@ -6532,28 +6532,28 @@ snapshots:
|
||||
|
||||
'@posthog/types@1.374.2': {}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.10':
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.11':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.10':
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.11':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.10':
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.11':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-linux-x64@0.0.10':
|
||||
'@powerformer/vela-cli-linux-x64@0.0.11':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-win32-x64@0.0.10':
|
||||
'@powerformer/vela-cli-win32-x64@0.0.11':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli@0.0.10':
|
||||
'@powerformer/vela-cli@0.0.11':
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.10
|
||||
'@powerformer/vela-cli-darwin-x64': 0.0.10
|
||||
'@powerformer/vela-cli-linux-arm64': 0.0.10
|
||||
'@powerformer/vela-cli-linux-x64': 0.0.10
|
||||
'@powerformer/vela-cli-win32-x64': 0.0.10
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.11
|
||||
'@powerformer/vela-cli-darwin-x64': 0.0.11
|
||||
'@powerformer/vela-cli-linux-arm64': 0.0.11
|
||||
'@powerformer/vela-cli-linux-x64': 0.0.11
|
||||
'@powerformer/vela-cli-win32-x64': 0.0.11
|
||||
optional: true
|
||||
|
||||
'@preact/signals-core@1.14.2': {}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"resedit": "1.7.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@powerformer/vela-cli": "0.0.10"
|
||||
"@powerformer/vela-cli": "0.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
|
||||
Reference in New Issue
Block a user