diff --git a/AGENTS.md b/AGENTS.md index e3ff6a6..1a4c96d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -329,8 +329,9 @@ For changes under `src/`, `tests/` or `.github/`: table in the same change. - Changing an example under `examples/`? Doc snippets that start with `# examples/` must match the file exactly (`npm test` checks). -- Every request to the Vapi API sends `"User-Agent": userAgentGet()` from - `src/user-agent.ts`; `npm test` fails on a `fetch` without it. +- Every request to the Vapi API sends `"User-Agent": userAgentGet()` and + `"x-vapi-client": vapiClientGet()` from `src/user-agent.ts`; `npm test` fails + on a `fetch` without them (the GitHub status call sends only the User-Agent). - Commit messages follow Conventional Commits (`fix(pull): …`, `docs: …`). - When you hit engine friction ("this should be better"), add or update an entry in `improvements.md` in the same change. Upstream's log collects diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 0d08adf..5e1c969 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -153,7 +153,14 @@ command you ran (`apply`, `push`, `pull`, …; `cli` if it can't be told). It adds ` (ci)` when `GITHUB_ACTIONS=true` or `CI` is set to anything other than `false` or `0`. For example, `npm run apply` in a GitHub workflow sends `vapi-gitops-apply/1.0.0 (ci)`. Your own npm script names are never sent. -Vapi uses it to count how the tool is used. Nothing else is sent beyond the + +Each request also sends an `x-vapi-client` header with the same details plus a +run id: `gitops/; command=; run=[; ci]`. The run id is +a random UUID made once per command you run and shared by every process it +starts, so Vapi can group one run's requests. It identifies nothing about you +or your machine. + +Vapi uses these headers to count how the tool is used. Nothing else is sent beyond the requests themselves; there is no separate telemetry. ## Where things live diff --git a/src/api.ts b/src/api.ts index b2b7c7d..e114ad2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -8,7 +8,7 @@ import { } from "./config.ts"; import { ORG_PIN_FILE, OrgPinMismatchError, orgPinCheck } from "./org-pin.ts"; import type { VapiResponse } from "./types.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; import { INITIAL_DELAY_MS, MAX_RETRIES, @@ -48,6 +48,7 @@ export function orgPinGuard(): Promise { token: VAPI_TOKEN ?? "", baseUrl: VAPI_BASE_URL, userAgent: userAgentGet(), + vapiClient: vapiClientGet(), }, keySource: VAPI_TOKEN_SOURCE, }); @@ -149,6 +150,7 @@ export async function vapiRequest( "Content-Type": "application/json", Authorization: `Bearer ${VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, body: JSON.stringify(body), }); @@ -194,6 +196,7 @@ export async function vapiGet(endpoint: string): Promise { headers: { Authorization: `Bearer ${VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); @@ -244,6 +247,7 @@ export async function vapiDelete(endpoint: string): Promise { headers: { Authorization: `Bearer ${VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); diff --git a/src/call.ts b/src/call.ts index d3cfcda..21304cb 100644 --- a/src/call.ts +++ b/src/call.ts @@ -6,7 +6,7 @@ import { dirname, join, resolve } from "path"; import * as readline from "readline"; import { fileURLToPath } from "url"; import type { Environment, StateFile } from "./types.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; const require = createRequire(import.meta.url); @@ -366,6 +366,7 @@ async function createCall( "Content-Type": "application/json", Authorization: `Bearer ${config.token}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, body: JSON.stringify(body), }); diff --git a/src/check-cmd.ts b/src/check-cmd.ts index ddcb137..494194d 100644 --- a/src/check-cmd.ts +++ b/src/check-cmd.ts @@ -32,7 +32,7 @@ import { } from "./check-status.ts"; import type { OrgConnection } from "./org-connection.ts"; import { childRun, connectionLoad, tokensParse } from "./org-connection.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; import type { VapiConnection } from "./vapi-client.ts"; const USAGE = [ @@ -290,9 +290,10 @@ async function liveRun( process.once("SIGINT", abort); process.once("SIGTERM", abort); const userAgent = userAgentGet("check"); + const vapiClient = vapiClientGet("check"); const vapi = (job: CheckJob): VapiConnection => { const connection = connections.get(job.check.runOrg)!; - return { token: connection.token, baseUrl: connection.baseUrl!, userAgent }; + return { token: connection.token, baseUrl: connection.baseUrl!, userAgent, vapiClient }; }; const post = async (status: CommitStatus) => { if (statusEnv) await commitStatusPost(statusEnv, status); diff --git a/src/cleanup.ts b/src/cleanup.ts index 79bdbeb..c988b2a 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -12,7 +12,7 @@ import { FOLDER_MAP } from "./resource-parse.ts"; import { slugify } from "./slug-utils.ts"; import { loadState } from "./state.ts"; import type { ResourceType } from "./types.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; // ───────────────────────────────────────────────────────────────────────────── // Dangerous Sync - Delete everything NOT in state file @@ -35,6 +35,7 @@ async function vapiGet(endpoint: string, debug = false): Promise { headers: { Authorization: `Bearer ${VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); if (!response.ok) { @@ -77,6 +78,7 @@ async function vapiDelete(endpoint: string): Promise { headers: { Authorization: `Bearer ${VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); if (!response.ok && response.status !== 404) { diff --git a/src/interactive.ts b/src/interactive.ts index c5e09fa..67eb822 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -9,7 +9,7 @@ import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js"; // the launcher, which runs before any org/token is selected. import { isBackupCopyFile } from "./slug-utils.ts"; import type { StateFile } from "./types.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; // ───────────────────────────────────────────────────────────────────────────── // Constants @@ -229,6 +229,7 @@ async function apiGet( headers: { Authorization: `Bearer ${token}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); if (!response.ok) { diff --git a/src/promotion-gate.ts b/src/promotion-gate.ts index 09a1c8f..22e42c2 100644 --- a/src/promotion-gate.ts +++ b/src/promotion-gate.ts @@ -10,7 +10,7 @@ import type { CheckOutcome, CheckTargetResult } from "./check-run.ts"; import { checkRunAll, MAX_CONCURRENT, MIN_START_MS } from "./check-run.ts"; import type { OrgConnection } from "./org-connection.ts"; import type { PromotionConfig } from "./promotion.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; export interface PromotionGateResult { outcome: CheckOutcome; @@ -155,6 +155,7 @@ export async function promotionGateRun( baseUrl: connection.baseUrl ?? DEFAULT_BASE_URL, // Gate runs are counted apart from PR check runs. userAgent: userAgentGet("promote"), + vapiClient: vapiClientGet("promote"), }), deadline: gateDeadline(check, Date.now()), signal: controller.signal, diff --git a/src/push.ts b/src/push.ts index c6f443d..c22f41d 100644 --- a/src/push.ts +++ b/src/push.ts @@ -35,7 +35,7 @@ import { import { reconcileStateKeyForResource } from "./reconcile-state-key.ts"; import { writeSnapshot } from "./snapshot.ts"; import { mergeScoped } from "./state-merge.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; import { summarizeFindings, validateNoIgnoredReferences, @@ -358,6 +358,7 @@ async function upsertResourceWithStateRecovery(options: { headers: { Authorization: `Bearer ${process.env.VAPI_TOKEN}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }, ); diff --git a/src/rollback-cmd.ts b/src/rollback-cmd.ts index bcd0864..193c6f3 100644 --- a/src/rollback-cmd.ts +++ b/src/rollback-cmd.ts @@ -14,7 +14,7 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { ORG_PIN_FILE, OrgPinMismatchError, orgPinCheck } from "./org-pin.ts"; import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BASE_DIR = join(__dirname, ".."); @@ -147,7 +147,7 @@ async function main(): Promise { const outcome = await orgPinCheck({ rootDir: BASE_DIR, org: args.env, - connection: { token: cfg.token, baseUrl: cfg.baseUrl, userAgent: userAgentGet() }, + connection: { token: cfg.token, baseUrl: cfg.baseUrl, userAgent: userAgentGet(), vapiClient: vapiClientGet() }, keySource: cfg.source, }); if (outcome.kind === "unverified") @@ -211,6 +211,7 @@ async function main(): Promise { Authorization: `Bearer ${cfg.token}`, "Content-Type": "application/json", "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, body: JSON.stringify(entry.payload.platform), }); diff --git a/src/setup.ts b/src/setup.ts index 4c7a0e2..02dd4b5 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -20,7 +20,7 @@ import { SETUP_USAGE, } from "./setup-args.ts"; import { slugify } from "./slug-utils.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; // ───────────────────────────────────────────────────────────────────────────── // Constants @@ -98,6 +98,7 @@ async function apiGet(token: string, endpoint: string): Promise { headers: { Authorization: `Bearer ${token}`, "User-Agent": userAgentGet(), + "x-vapi-client": vapiClientGet(), }, }); diff --git a/src/sim.ts b/src/sim.ts index 12989b8..f376cd3 100644 --- a/src/sim.ts +++ b/src/sim.ts @@ -17,7 +17,7 @@ import { type SimRunVerdict, } from "./sim-result.ts"; import type { StateFile } from "./types.ts"; -import { userAgentGet } from "./user-agent.ts"; +import { userAgentGet, vapiClientGet } from "./user-agent.ts"; import { VapiApiError, vapiFetchJson, @@ -240,6 +240,7 @@ function connectionFor(cfg: SimEnv): VapiConnection { token: cfg.token, baseUrl: cfg.baseUrl, userAgent: userAgentGet("sim"), + vapiClient: vapiClientGet("sim"), }; } diff --git a/src/user-agent.ts b/src/user-agent.ts index 35c19b8..b3dbffb 100644 --- a/src/user-agent.ts +++ b/src/user-agent.ts @@ -15,9 +15,19 @@ // prefixes, so keep those prefixes stable; the version and the ` (ci)` suffix // may vary. // +// Every Vapi request also sends `x-vapi-client`, built by `vapiClientGet`: +// +// gitops/; command=; run=[; ci] +// +// It carries the same command, version and CI flag in the shape the platform +// parses for every first-party client, plus a run id shared by every request +// of one gitops run, so the platform can group them. The run id is pinned in +// VAPI_GITOPS_RUN_ID exactly as the command is pinned in VAPI_GITOPS_COMMAND. +// // Config-free on purpose (like api-key.ts): importing config.ts would parse // argv and exit, which breaks importing this from sim.ts and tests. +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -131,3 +141,28 @@ export function userAgentGet( const ci = ciRun(context.env) ? " (ci)" : ""; return `vapi-gitops-${command}/${PACKAGE_VERSION}${ci}`; } + +export const RUN_ID_ENV = "VAPI_GITOPS_RUN_ID"; + +// What the platform accepts as a run id; a UUID fits. An inherited value that +// doesn't match is replaced, so a stray variable can't drop the run id. +const RUN_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/; + +// Pin one run id for this process and every process it spawns. +if (!RUN_ID_PATTERN.test(process.env[RUN_ID_ENV] ?? "")) { + process.env[RUN_ID_ENV] = randomUUID(); +} + +export function vapiClientGet( + product?: "sim" | "check" | "promote", + context: UserAgentContext = { + env: process.env, + scriptPath: process.argv[1], + }, +): string { + const command = product ?? commandNameGet(context); + const runId = context.env[RUN_ID_ENV]; + const run = runId && RUN_ID_PATTERN.test(runId) ? `; run=${runId}` : ""; + const ci = ciRun(context.env) ? "; ci" : ""; + return `gitops/${PACKAGE_VERSION}; command=${command}${run}${ci}`; +} diff --git a/src/vapi-client.ts b/src/vapi-client.ts index 508bdff..f1e76be 100644 --- a/src/vapi-client.ts +++ b/src/vapi-client.ts @@ -10,6 +10,8 @@ export interface VapiConnection { token: string; baseUrl: string; userAgent: string; + // The `x-vapi-client` header, from vapiClientGet in user-agent.ts. + vapiClient: string; } // "transient": retry 429 and 5xx. "rate-limit-only": retry 429 only — for @@ -87,6 +89,7 @@ export async function vapiFetchJson( Authorization: `Bearer ${connection.token}`, "Content-Type": "application/json", "User-Agent": connection.userAgent, + "x-vapi-client": connection.vapiClient, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); diff --git a/tests/check-run.test.ts b/tests/check-run.test.ts index eb5e8f6..0186d57 100644 --- a/tests/check-run.test.ts +++ b/tests/check-run.test.ts @@ -229,6 +229,7 @@ function runArgs( token: "t", baseUrl: stub.baseUrl, userAgent: "vapi-gitops-check/test", + vapiClient: "gitops/test; command=check", }), deadline: DEADLINE_FAR(), pollIntervalMs: 1, diff --git a/tests/org-pin.test.ts b/tests/org-pin.test.ts index 3f67f82..c58ec41 100644 --- a/tests/org-pin.test.ts +++ b/tests/org-pin.test.ts @@ -34,7 +34,7 @@ import { VapiApiError } from "../src/vapi-client.ts"; delete process.env.VAPI_GITOPS_ORG_PIN; const REPO = fileURLToPath(new URL("..", import.meta.url)); -const CONNECTION = { token: "test-token-not-used", baseUrl: "http://stub", userAgent: "test" }; +const CONNECTION = { token: "test-token-not-used", baseUrl: "http://stub", userAgent: "test", vapiClient: "test" }; const SOURCE = "the VAPI_PRIVATE_API_KEY_ACME environment variable"; // A probe stub: endpoint (without the query) → response or error. diff --git a/tests/sim-run.test.ts b/tests/sim-run.test.ts index aedc215..71c858e 100644 --- a/tests/sim-run.test.ts +++ b/tests/sim-run.test.ts @@ -359,7 +359,7 @@ test("simRunItemsFetch: pages until totalItems and dedupes overlapping pages", a ], async (baseUrl) => { const items = await simRunItemsFetch( - { token: "t", baseUrl, userAgent: "test" }, + { token: "t", baseUrl, userAgent: "test", vapiClient: "test" }, "run-1", ); assert.equal(items.length, 1002); diff --git a/tests/user-agent-coverage.test.ts b/tests/user-agent-coverage.test.ts index c448b31..72ea69d 100644 --- a/tests/user-agent-coverage.test.ts +++ b/tests/user-agent-coverage.test.ts @@ -34,10 +34,31 @@ test("every fetch in src/ sets the User-Agent", () => { assert.deepEqual(missing, []); }); +// The Vapi API also gets x-vapi-client on every request. The GitHub status +// call is the one fetch that must not send it: it isn't a Vapi request. +test("every Vapi fetch in src/ sets x-vapi-client", () => { + const missing: string[] = []; + const files = readdirSync(SRC, { recursive: true, encoding: "utf8" }); + for (const file of files.filter((f) => f.endsWith(".ts"))) { + readFileSync(join(SRC, file), "utf8") + .split(/\bfetch\(/) + .slice(1) + .forEach((rest, i) => { + const call = rest.slice(0, rest.indexOf(");") + 2); + const github = call.includes('"X-GitHub-Api-Version"'); + if (!github && !call.includes('"x-vapi-client"')) + missing.push(`${file} (fetch #${i + 1})`); + }); + } + assert.deepEqual(missing, []); +}); + test("api.ts requests carry the command's User-Agent", async () => { const seen: Array = []; + const seenClient: Array = []; const server = createServer((req, res) => { seen.push(req.headers["user-agent"]); + seenClient.push(req.headers["x-vapi-client"]); res.writeHead(200, { "Content-Type": "application/json" }); res.end("[]"); }); @@ -53,10 +74,15 @@ test("api.ts requests carry the command's User-Agent", async () => { delete process.env.GITHUB_ACTIONS; try { const { vapiGet } = await import("../src/api.ts"); - const { userAgentGet } = await import("../src/user-agent.ts"); + const { userAgentGet, vapiClientGet } = await import("../src/user-agent.ts"); await vapiGet("/assistant"); assert.deepEqual(seen, [userAgentGet()]); assert.match(seen[0] ?? "", /^vapi-gitops-apply\/[^ ]+$/); + assert.deepEqual(seenClient, [vapiClientGet()]); + assert.match( + String(seenClient[0]), + /^gitops\/[^;]+; command=apply; run=[0-9a-f-]{36}$/, + ); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/tests/user-agent.test.ts b/tests/user-agent.test.ts index a1e43ae..d060e24 100644 --- a/tests/user-agent.test.ts +++ b/tests/user-agent.test.ts @@ -4,7 +4,7 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import test from "node:test"; import { runSimulation } from "../src/sim.ts"; -import { userAgentGet } from "../src/user-agent.ts"; +import { RUN_ID_ENV, userAgentGet, vapiClientGet } from "../src/user-agent.ts"; // The User-Agent is how gitops traffic is counted in the platform's request // logs and analytics (simulation runs by `user_agent` on the run-started @@ -86,11 +86,17 @@ test("userAgentGet: marks runs in CI", () => { }); test("runSimulation: sends the sim User-Agent on run create", async () => { - const seen: { method?: string; url?: string; userAgent?: string } = {}; + const seen: { + method?: string; + url?: string; + userAgent?: string; + vapiClient?: string | string[]; + } = {}; const server = createServer((req, res) => { seen.method = req.method; seen.url = req.url; seen.userAgent = req.headers["user-agent"]; + seen.vapiClient = req.headers["x-vapi-client"]; res.writeHead(201, { "Content-Type": "application/json" }); res.end(JSON.stringify({ id: "run-1", status: "queued" })); }); @@ -119,4 +125,38 @@ test("runSimulation: sends the sim User-Agent on run create", async () => { assert.equal(seen.method, "POST"); assert.equal(seen.url, "/eval/simulation/run"); assert.equal(seen.userAgent, userAgentGet("sim")); + assert.equal(seen.vapiClient, vapiClientGet("sim")); +}); + +// x-vapi-client is the shape the platform parses for every first-party client +// (`/; command=…; run=…[; ci]`), so its format is pinned too. +const RUN_ID = "3f2b6c1e-9d4a-4c7e-8a1b-0e5f6a7b8c9d"; + +test("vapiClientGet: names the command, run and CI flag", () => { + assert.deepEqual( + [ + vapiClientGet(undefined, { + env: { npm_lifecycle_event: "apply", [RUN_ID_ENV]: RUN_ID }, + scriptPath: "/repo/src/push.ts", + }), + vapiClientGet("sim", { + env: { [RUN_ID_ENV]: RUN_ID, GITHUB_ACTIONS: "true" }, + }), + ], + [ + `gitops/${packageVersion}; command=apply; run=${RUN_ID}`, + `gitops/${packageVersion}; command=sim; run=${RUN_ID}; ci`, + ], + ); +}); + +test("vapiClientGet: leaves out a run id the platform would reject", () => { + assert.equal( + vapiClientGet("check", { env: { [RUN_ID_ENV]: "bad id; ci" } }), + `gitops/${packageVersion}; command=check`, + ); +}); + +test("user-agent.ts pins one valid run id for the process", () => { + assert.match(process.env[RUN_ID_ENV] ?? "", /^[0-9a-f-]{36}$/); });