Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ 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.
- 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
11 changes: 11 additions & 0 deletions docs/guides/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,17 @@ Tracks resource ID ↔ Vapi UUID mappings per org:

Every resource type has a section. Keys are sorted, so diffs stay readable.

## What Vapi sees

Every API request uses the org's private key and identifies the tool with a
User-Agent: `vapi-gitops-<command>/<version>`, where `<command>` is the gitops
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
requests themselves; there is no separate telemetry.

## Where things live

| Path | What it is |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"rollback": "tsx src/rollback-cmd.ts",
"promote": "tsx src/promote-cmd.ts",
"build": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts"
"test": "node --import tsx --import ./tests/no-vapi-api.ts --test tests/*.test.ts"
},
"devDependencies": {
"@types/node": "^22.0.0",
Expand Down
4 changes: 4 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
import type { VapiResponse } from "./types.ts";
import { userAgentGet } from "./user-agent.ts";
import {
INITIAL_DELAY_MS,
MAX_RETRIES,
Expand Down Expand Up @@ -97,6 +98,7 @@ export async function vapiRequest<T = VapiResponse>(
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
body: JSON.stringify(body),
});
Expand Down Expand Up @@ -140,6 +142,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
method: "GET",
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});

Expand Down Expand Up @@ -188,6 +191,7 @@ export async function vapiDelete(endpoint: string): Promise<void> {
method: "DELETE",
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});

Expand Down
2 changes: 2 additions & 0 deletions src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { execSync } from "child_process";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { assertStateMigrated } from "./migrate-hash-store.ts";
// Pins this command's User-Agent label for the pull and push it spawns.
import "./user-agent.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Apply: Pull → Merge → Push (safe bidirectional sync)
Expand Down
2 changes: 2 additions & 0 deletions src/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +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";

const require = createRequire(import.meta.url);

Expand Down Expand Up @@ -362,6 +363,7 @@ async function createCall(
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.token}`,
"User-Agent": userAgentGet(),
},
body: JSON.stringify(body),
});
Expand Down
4 changes: 4 additions & 0 deletions src/check-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// `Vapi Evals` (a stable, documented name) is what branch protection
// requires: per-target statuses only exist on PRs that touch a check.

import { userAgentGet } from "./user-agent.ts";

export const AGGREGATE_CONTEXT = "Vapi Evals";

export type CommitState = "pending" | "success" | "failure" | "error";
Expand Down Expand Up @@ -84,6 +86,8 @@ export async function commitStatusPost(
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
// GitHub asks API clients to name themselves.
"User-Agent": userAgentGet("check"),
},
body: JSON.stringify({
context: status.context,
Expand Down
11 changes: 9 additions & 2 deletions src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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";

// ─────────────────────────────────────────────────────────────────────────────
// Dangerous Sync - Delete everything NOT in state file
Expand All @@ -29,7 +30,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok) {
throw new Error(`GET ${endpoint} failed: ${response.status}`);
Expand Down Expand Up @@ -67,7 +71,10 @@ async function vapiDelete(endpoint: string): Promise<void> {
await sleep(REQUEST_DELAY_MS);
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
headers: {
Authorization: `Bearer ${VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`DELETE ${endpoint} failed: ${response.status}`);
Expand Down
6 changes: 5 additions & 1 deletion src/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +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";

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -223,7 +224,10 @@ async function apiGet(
): Promise<unknown> {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
},
});
if (!response.ok) {
const text = await response.text();
Expand Down
3 changes: 2 additions & 1 deletion src/promotion-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ export async function promotionGateRun(
connectionFor: () => ({
token: connection.token,
baseUrl: connection.baseUrl ?? DEFAULT_BASE_URL,
userAgent: userAgentGet("check"),
// Gate runs are counted apart from PR check runs.
userAgent: userAgentGet("promote"),
}),
deadline: gateDeadline(check, Date.now()),
signal: controller.signal,
Expand Down
2 changes: 2 additions & 0 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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 {
summarizeFindings,
validateNoIgnoredReferences,
Expand Down Expand Up @@ -303,6 +304,7 @@ async function upsertResourceWithStateRecovery(options: {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VAPI_TOKEN}`,
"User-Agent": userAgentGet(),
},
},
);
Expand Down
2 changes: 2 additions & 0 deletions src/rollback-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts";
import { userAgentGet } from "./user-agent.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE_DIR = join(__dirname, "..");
Expand Down Expand Up @@ -186,6 +187,7 @@ async function main(): Promise<void> {
headers: {
Authorization: `Bearer ${cfg.token}`,
"Content-Type": "application/json",
"User-Agent": userAgentGet(),
},
body: JSON.stringify(entry.payload.platform),
});
Expand Down
6 changes: 5 additions & 1 deletion src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
SETUP_USAGE,
} from "./setup-args.ts";
import { slugify } from "./slug-utils.ts";
import { userAgentGet } from "./user-agent.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -94,7 +95,10 @@ const c = {
async function apiGet(token: string, endpoint: string): Promise<unknown> {
const response = await fetch(`${vapiBaseUrl}${endpoint}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": userAgentGet(),
},
});

if (!response.ok) {
Expand Down
104 changes: 99 additions & 5 deletions src/user-agent.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
// User-Agent for the API requests this tool makes, so simulation runs started
// from gitops can be told apart in the platform's analytics.
// User-Agent for every API request this tool makes, so gitops traffic can be
// told apart in the platform's request logs and analytics:
//
// vapi-gitops-<command>/<package version>[ (ci)]
//
// `<command>` is the gitops command that started the run, from a fixed list
// (`cli` when it can't be told), so the label set stays bounded and never
// carries a name a user chose, such as a fork's own npm script. The first
// gitops process pins it in VAPI_GITOPS_COMMAND, which every process it
// spawns inherits: `npm run apply` labels the pull and push it runs as
// `apply`, and the PR check's bindings pull is labelled `check`.
//
// The `sim`, `check` and `promote` labels are fixed by their callers. Analytics
// counts simulation runs by the `vapi-gitops-sim/` and `vapi-gitops-check/`
// prefixes, so keep those prefixes stable; the version and the ` (ci)` suffix
// may vary.
//
// 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 { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const PACKAGE_JSON_PATH = join(
Expand All @@ -14,6 +28,12 @@ const PACKAGE_JSON_PATH = join(
"package.json",
);

export interface UserAgentContext {
env: NodeJS.ProcessEnv;
// The entry script, process.argv[1].
scriptPath?: string;
}

function packageVersionRead(): string {
try {
const parsed: unknown = JSON.parse(
Expand All @@ -34,6 +54,80 @@ function packageVersionRead(): string {
return "unknown";
}

export function userAgentGet(product: "sim" | "check"): string {
return `vapi-gitops-${product}/${packageVersionRead()}`;
const PACKAGE_VERSION = packageVersionRead();

// A User-Agent product token allows few characters; keep to a safe subset.
function tokenClean(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}

// The commands a label can name: this repo's npm scripts. Hard-coded, not
// read from package.json, because forks add their own scripts there.
const COMMANDS = new Set([
"setup",
"apply",
"push",
"pull",
"migrate",
"call",
"cleanup",
"validate",
"audit",
"sim",
"check",
"rollback",
"promote",
]);

export const COMMAND_ENV = "VAPI_GITOPS_COMMAND";

function commandKnown(value: string | undefined): string | undefined {
const token = tokenClean(value ?? "");
return COMMANDS.has(token) ? token : undefined;
}

// The pinned label, else the npm script, else the entry script, else `cli`.
// `npx tsx src/push-cmd.ts` sets npm_lifecycle_event=npx, which isn't a
// command, so it falls through to the entry script: `push`.
function commandNameGet(context: UserAgentContext): string {
Comment thread
scott-lowe-vapi marked this conversation as resolved.
const script = context.scriptPath
? basename(context.scriptPath)
.replace(/\.[cm]?[jt]s$/, "")
.replace(/-cmd$/, "")
: undefined;
return (
commandKnown(context.env[COMMAND_ENV]) ??
commandKnown(context.env.npm_lifecycle_event) ??
commandKnown(script) ??
"cli"
);
}

// Pin this process's label for every process it spawns.
process.env[COMMAND_ENV] ??= commandNameGet({
env: process.env,
scriptPath: process.argv[1],
});

function ciRun(env: NodeJS.ProcessEnv): boolean {
const ci = env.CI?.toLowerCase();
return (
env.GITHUB_ACTIONS === "true" ||
(ci !== undefined && ci !== "" && ci !== "false" && ci !== "0")
);
}

export function userAgentGet(
product?: "sim" | "check" | "promote",
context: UserAgentContext = {
env: process.env,
scriptPath: process.argv[1],
},
): string {
const command = product ?? commandNameGet(context);
const ci = ciRun(context.env) ? " (ci)" : "";
return `vapi-gitops-${command}/${PACKAGE_VERSION}${ci}`;
}
7 changes: 6 additions & 1 deletion tests/cleanup-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ function runCleanup(
["--import", "tsx", "src/cleanup.ts", "test-cleanup-org", ...args],
{
cwd,
env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" },
env: {
...process.env,
VAPI_TOKEN: "fake-token-not-used",
Comment thread
scott-lowe-vapi marked this conversation as resolved.
// Nothing listens here: tests must never reach the real API.
VAPI_BASE_URL: "http://127.0.0.1:9",
},
encoding: "utf-8",
timeout: 20_000,
},
Expand Down
7 changes: 6 additions & 1 deletion tests/new-file-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,12 @@ function runPush(
["--import", "tsx", "src/push.ts", fx.env, ...extraArgs],
{
cwd: fx.dir,
env: { ...process.env, VAPI_TOKEN: "fake-token-not-used" },
env: {
...process.env,
VAPI_TOKEN: "fake-token-not-used",
// Nothing listens here: tests must never reach the real API.
VAPI_BASE_URL: "http://127.0.0.1:9",
},
encoding: "utf-8",
timeout: 30_000,
},
Expand Down
6 changes: 6 additions & 0 deletions tests/no-vapi-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Loaded before every test file (package.json's test script), and inherited
// by every CLI a test spawns: tests must never reach the real Vapi API or use
// a developer's real key. A test that needs a key sets a fake one itself.
process.env.VAPI_BASE_URL = "http://127.0.0.1:9";
delete process.env.VAPI_PRIVATE_API_KEY;
delete process.env.VAPI_TOKEN;
Loading
Loading