Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>` 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
Expand Down
9 changes: 8 additions & 1 deletion docs/guides/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>; command=<command>; run=<run id>[; 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
Expand Down
6 changes: 5 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +48,7 @@ export function orgPinGuard(): Promise<void> {
token: VAPI_TOKEN ?? "",
baseUrl: VAPI_BASE_URL,
userAgent: userAgentGet(),
vapiClient: vapiClientGet(),
},
keySource: VAPI_TOKEN_SOURCE,
});
Expand Down Expand Up @@ -149,6 +150,7 @@ export async function vapiRequest<T = VapiResponse>(
"Content-Type": "application/json",
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
body: JSON.stringify(body),
});
Expand Down Expand Up @@ -194,6 +196,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});

Expand Down Expand Up @@ -244,6 +247,7 @@ export async function vapiDelete(endpoint: string): Promise<void> {
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});

Expand Down
3 changes: 2 additions & 1 deletion src/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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),
});
Expand Down
5 changes: 3 additions & 2 deletions src/check-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +35,7 @@ async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});
if (!response.ok) {
Expand Down Expand Up @@ -77,6 +78,7 @@ async function vapiDelete(endpoint: string): Promise<void> {
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});
if (!response.ok && response.status !== 404) {
Expand Down
3 changes: 2 additions & 1 deletion src/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -229,6 +229,7 @@ async function apiGet(
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});
if (!response.ok) {
Expand Down
3 changes: 2 additions & 1 deletion src/promotion-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -358,6 +358,7 @@ async function upsertResourceWithStateRecovery(options: {
headers: {
Authorization: `Bearer ${process.env.VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
},
);
Expand Down
5 changes: 3 additions & 2 deletions src/rollback-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down Expand Up @@ -147,7 +147,7 @@ async function main(): Promise<void> {
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")
Expand Down Expand Up @@ -211,6 +211,7 @@ async function main(): Promise<void> {
Authorization: `Bearer ${cfg.token}`,
"Content-Type": "application/json",
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
body: JSON.stringify(entry.payload.platform),
});
Expand Down
3 changes: 2 additions & 1 deletion src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,6 +98,7 @@ async function apiGet(token: string, endpoint: string): Promise<unknown> {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
"x-vapi-client": vapiClientGet(),
},
});

Expand Down
3 changes: 2 additions & 1 deletion src/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +240,7 @@ function connectionFor(cfg: SimEnv): VapiConnection {
token: cfg.token,
baseUrl: cfg.baseUrl,
userAgent: userAgentGet("sim"),
vapiClient: vapiClientGet("sim"),
};
}

Expand Down
35 changes: 35 additions & 0 deletions src/user-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package version>; command=<command>; run=<run id>[; 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";
Expand Down Expand Up @@ -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}`;
}
3 changes: 3 additions & 0 deletions src/vapi-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,6 +89,7 @@ export async function vapiFetchJson<T>(
Authorization: `Bearer ${connection.token}`,
"Content-Type": "application/json",
"User-Agent": connection.userAgent,
"x-vapi-client": connection.vapiClient,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
Expand Down
1 change: 1 addition & 0 deletions tests/check-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/org-pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/sim-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
28 changes: 27 additions & 1 deletion tests/user-agent-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> = [];
const seenClient: Array<string | string[] | undefined> = [];
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("[]");
});
Expand All @@ -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<void>((resolve) => server.close(() => resolve()));
}
Expand Down
Loading
Loading