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:
lefarcen
2026-06-05 09:44:07 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5bcd7d2c26
commit 817885bc34
6 changed files with 134 additions and 44 deletions
+42
View File
@@ -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 -16
View File
@@ -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>>();
+31
View File
@@ -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
View File
@@ -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=";
}
+25 -25
View File
@@ -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': {}
+1 -1
View File
@@ -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",