Skip to content

Commit 674d8a1

Browse files
feat: identify every gitops API request with a User-Agent
Only `npm run sim` and `npm run check` identified themselves. Setup, pull, push, apply, promote, cleanup, rollback, call and audit sent Node's default `node` User-Agent, about a sixth of all api.vapi.ai traffic, so gitops usage beyond simulations couldn't be counted. - src/user-agent.ts: `vapi-gitops-<command>/<version>`, plus ` (ci)` when CI or GITHUB_ACTIONS is set. The command is the npm script that started the process (so `npm run apply` labels the pull and push it runs as apply, and a promotion's applies as promote), else the entry script's name. sim and check keep their fixed labels, which analytics already counts simulation runs by. - Every fetch to the Vapi API sends it. tests/user-agent-coverage.test.ts fails on a fetch without it and checks api.ts against a local server. - cleanup-safety and new-file-gate tests sent about a dozen requests to the real api.vapi.ai per `npm test` (fake key, 401s), which the new User-Agent made visible in the request logs, and which would have counted every fork's CI run as usage. They now point at a dead local address. - how-it-works.md says what the API sees; AGENTS.md says every request sends the header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1f982af commit 674d8a1

14 files changed

Lines changed: 239 additions & 24 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,8 @@ For changes under `src/`, `tests/` or `.github/`:
319319
table in the same change.
320320
- Changing an example under `examples/`? Doc snippets that start with
321321
`# examples/<path>` must match the file exactly (`npm test` checks).
322+
- Every request to the Vapi API sends `"User-Agent": userAgentGet()` from
323+
`src/user-agent.ts`; `npm test` fails on a `fetch` without it.
322324
- Commit messages follow Conventional Commits (`fix(pull): …`, `docs: …`).
323325
- When you hit engine friction ("this should be better"), add or update an
324326
entry in `improvements.md` in the same change. Upstream's log collects

‎docs/guides/how-it-works.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ Tracks resource ID ↔ Vapi UUID mappings per org:
145145

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

148+
## What Vapi sees
149+
150+
Every API request uses the org's private key and identifies the tool with a
151+
User-Agent: `vapi-gitops-<command>/<version>`, plus ` (ci)` when the `CI` or
152+
`GITHUB_ACTIONS` variable is set. For example, `npm run apply` in a GitHub
153+
workflow sends `vapi-gitops-apply/1.0.0 (ci)`. Vapi uses it to count how
154+
the tool is used. Nothing else is sent beyond the requests themselves; there
155+
is no separate telemetry.
156+
148157
## Where things live
149158

150159
| Path | What it is |

‎src/api.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DRY_RUN, VAPI_BASE_URL, VAPI_TOKEN } from "./config.ts";
22
import type { VapiResponse } from "./types.ts";
3+
import { userAgentGet } from "./user-agent.ts";
34
import {
45
INITIAL_DELAY_MS,
56
MAX_RETRIES,
@@ -97,6 +98,7 @@ export async function vapiRequest<T = VapiResponse>(
9798
headers: {
9899
"Content-Type": "application/json",
99100
Authorization: `Bearer ${VAPI_TOKEN}`,
101+
"User-Agent": userAgentGet(),
100102
},
101103
body: JSON.stringify(body),
102104
});
@@ -140,6 +142,7 @@ export async function vapiGet<T = unknown>(endpoint: string): Promise<T> {
140142
method: "GET",
141143
headers: {
142144
Authorization: `Bearer ${VAPI_TOKEN}`,
145+
"User-Agent": userAgentGet(),
143146
},
144147
});
145148

@@ -188,6 +191,7 @@ export async function vapiDelete(endpoint: string): Promise<void> {
188191
method: "DELETE",
189192
headers: {
190193
Authorization: `Bearer ${VAPI_TOKEN}`,
194+
"User-Agent": userAgentGet(),
191195
},
192196
});
193197

‎src/call.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { dirname, join, resolve } from "path";
66
import * as readline from "readline";
77
import { fileURLToPath } from "url";
88
import type { Environment, StateFile } from "./types.ts";
9+
import { userAgentGet } from "./user-agent.ts";
910

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

@@ -362,6 +363,7 @@ async function createCall(
362363
headers: {
363364
"Content-Type": "application/json",
364365
Authorization: `Bearer ${config.token}`,
366+
"User-Agent": userAgentGet(),
365367
},
366368
body: JSON.stringify(body),
367369
});
@@ -1046,7 +1048,9 @@ function createMicrophoneStream(onData: (data: Buffer) => void): {
10461048
} catch (error) {
10471049
const msg = error instanceof Error ? error.message : String(error);
10481050
if (msg.includes("Cannot find module")) {
1049-
console.warn("⚠️ 'mic' module not installed. Microphone input disabled.");
1051+
console.warn(
1052+
"⚠️ 'mic' module not installed. Microphone input disabled.",
1053+
);
10501054
console.warn(" Install with: npm install mic");
10511055
} else if (msg.includes("sox") || msg.includes("rec")) {
10521056
console.warn("⚠️ sox/rec not found. Required for microphone input.");

‎src/cleanup.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FOLDER_MAP } from "./resource-parse.ts";
1111
import { slugify } from "./slug-utils.ts";
1212
import { loadState } from "./state.ts";
1313
import type { ResourceType } from "./types.ts";
14+
import { userAgentGet } from "./user-agent.ts";
1415

1516
// ─────────────────────────────────────────────────────────────────────────────
1617
// Dangerous Sync - Delete everything NOT in state file
@@ -29,7 +30,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
2930
async function vapiGet<T>(endpoint: string, debug = false): Promise<T> {
3031
await sleep(REQUEST_DELAY_MS);
3132
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
32-
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
33+
headers: {
34+
Authorization: `Bearer ${VAPI_TOKEN}`,
35+
"User-Agent": userAgentGet(),
36+
},
3337
});
3438
if (!response.ok) {
3539
throw new Error(`GET ${endpoint} failed: ${response.status}`);
@@ -67,7 +71,10 @@ async function vapiDelete(endpoint: string): Promise<void> {
6771
await sleep(REQUEST_DELAY_MS);
6872
const response = await fetch(`${VAPI_BASE_URL}${endpoint}`, {
6973
method: "DELETE",
70-
headers: { Authorization: `Bearer ${VAPI_TOKEN}` },
74+
headers: {
75+
Authorization: `Bearer ${VAPI_TOKEN}`,
76+
"User-Agent": userAgentGet(),
77+
},
7178
});
7279
if (!response.ok && response.status !== 404) {
7380
throw new Error(`DELETE ${endpoint} failed: ${response.status}`);

‎src/interactive.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import searchableCheckbox, { BACK_SENTINEL } from "./searchableCheckbox.js";
99
// the launcher, which runs before any org/token is selected.
1010
import { isBackupCopyFile } from "./slug-utils.ts";
1111
import type { StateFile } from "./types.ts";
12+
import { userAgentGet } from "./user-agent.ts";
1213

1314
// ─────────────────────────────────────────────────────────────────────────────
1415
// Constants
@@ -223,7 +224,10 @@ async function apiGet(
223224
): Promise<unknown> {
224225
const response = await fetch(`${baseUrl}${endpoint}`, {
225226
method: "GET",
226-
headers: { Authorization: `Bearer ${token}` },
227+
headers: {
228+
Authorization: `Bearer ${token}`,
229+
"User-Agent": userAgentGet(),
230+
},
227231
});
228232
if (!response.ok) {
229233
const text = await response.text();

‎src/push.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { reconcileStateKeyForResource } from "./reconcile-state-key.ts";
3535
import { writeSnapshot } from "./snapshot.ts";
3636
import { mergeScoped } from "./state-merge.ts";
37+
import { userAgentGet } from "./user-agent.ts";
3738
import {
3839
summarizeFindings,
3940
validateNoIgnoredReferences,
@@ -303,6 +304,7 @@ async function upsertResourceWithStateRecovery(options: {
303304
method: "GET",
304305
headers: {
305306
Authorization: `Bearer ${process.env.VAPI_TOKEN}`,
307+
"User-Agent": userAgentGet(),
306308
},
307309
},
308310
);

‎src/rollback-cmd.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "fs";
1313
import { dirname, join } from "path";
1414
import { fileURLToPath } from "url";
1515
import { listSnapshotTimestamps, loadSnapshot } from "./snapshot.ts";
16+
import { userAgentGet } from "./user-agent.ts";
1617

1718
const __dirname = dirname(fileURLToPath(import.meta.url));
1819
const BASE_DIR = join(__dirname, "..");
@@ -186,6 +187,7 @@ async function main(): Promise<void> {
186187
headers: {
187188
Authorization: `Bearer ${cfg.token}`,
188189
"Content-Type": "application/json",
190+
"User-Agent": userAgentGet(),
189191
},
190192
body: JSON.stringify(entry.payload.platform),
191193
});

‎src/setup.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SETUP_USAGE,
2121
} from "./setup-args.ts";
2222
import { slugify } from "./slug-utils.ts";
23+
import { userAgentGet } from "./user-agent.ts";
2324

2425
// ─────────────────────────────────────────────────────────────────────────────
2526
// Constants
@@ -94,7 +95,10 @@ const c = {
9495
async function apiGet(token: string, endpoint: string): Promise<unknown> {
9596
const response = await fetch(`${vapiBaseUrl}${endpoint}`, {
9697
method: "GET",
97-
headers: { Authorization: `Bearer ${token}` },
98+
headers: {
99+
Authorization: `Bearer ${token}`,
100+
"User-Agent": userAgentGet(),
101+
},
98102
});
99103

100104
if (!response.ok) {
@@ -367,7 +371,9 @@ async function runDirectSetup(options: DirectSetupOptions): Promise<void> {
367371
const resourceDir = join(BASE_DIR, "resources", slug);
368372
const stateFile = join(BASE_DIR, `.vapi-state.${slug}.json`);
369373

370-
console.log(c.bold(`\n Vapi GitOps — non-interactive setup for "${slug}"\n`));
374+
console.log(
375+
c.bold(`\n Vapi GitOps — non-interactive setup for "${slug}"\n`),
376+
);
371377

372378
// Never clobber an org that already has local state. Re-running setup is
373379
// a destructive operation in the wizard (it deletes and re-pulls), and an
@@ -517,7 +523,9 @@ async function main(): Promise<void> {
517523
// so explain the non-interactive path instead.
518524
if (!process.stdin.isTTY) {
519525
console.error(
520-
c.red("\n ✗ The setup wizard needs an interactive terminal (stdin is not a TTY).\n"),
526+
c.red(
527+
"\n ✗ The setup wizard needs an interactive terminal (stdin is not a TTY).\n",
528+
),
521529
);
522530
console.error(SETUP_USAGE);
523531
process.exit(1);

‎src/user-agent.ts‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
// User-Agent for the API requests this tool makes, so simulation runs started
2-
// from gitops can be told apart in the platform's analytics.
1+
// User-Agent for every API request this tool makes, so gitops traffic can be
2+
// told apart in the platform's request logs and analytics:
3+
//
4+
// vapi-gitops-<command>/<package version>[ (ci)]
5+
//
6+
// `<command>` is the npm script that started the process (`npm run apply`
7+
// labels the pull and push it runs as `apply`), or the entry script's name
8+
// when it was run directly, as the PR check workflow does. The `sim` and
9+
// `check` labels are fixed by their callers, because analytics already counts
10+
// simulation runs by those prefixes; keep them stable.
311
//
412
// Config-free on purpose (like api-key.ts): importing config.ts would parse
513
// argv and exit, which breaks importing this from sim.ts and tests.
614

715
import { readFileSync } from "node:fs";
8-
import { dirname, join } from "node:path";
16+
import { basename, dirname, join } from "node:path";
917
import { fileURLToPath } from "node:url";
1018

1119
const PACKAGE_JSON_PATH = join(
@@ -14,6 +22,12 @@ const PACKAGE_JSON_PATH = join(
1422
"package.json",
1523
);
1624

25+
export interface UserAgentContext {
26+
env: NodeJS.ProcessEnv;
27+
// The entry script, process.argv[1].
28+
scriptPath?: string;
29+
}
30+
1731
function packageVersionRead(): string {
1832
try {
1933
const parsed: unknown = JSON.parse(
@@ -34,6 +48,41 @@ function packageVersionRead(): string {
3448
return "unknown";
3549
}
3650

37-
export function userAgentGet(product: "sim" | "check"): string {
38-
return `vapi-gitops-${product}/${packageVersionRead()}`;
51+
const PACKAGE_VERSION = packageVersionRead();
52+
53+
// A User-Agent product token allows few characters; keep to a safe subset.
54+
function tokenClean(value: string): string {
55+
return value
56+
.toLowerCase()
57+
.replace(/[^a-z0-9-]+/g, "-")
58+
.replace(/^-+|-+$/g, "");
59+
}
60+
61+
function commandNameGet(context: UserAgentContext): string {
62+
const npmScript = context.env.npm_lifecycle_event;
63+
if (npmScript && tokenClean(npmScript)) return tokenClean(npmScript);
64+
const script = context.scriptPath
65+
? basename(context.scriptPath).replace(/\.[cm]?[jt]s$/, "")
66+
: "";
67+
return tokenClean(script.replace(/-cmd$/, "")) || "cli";
68+
}
69+
70+
function ciRun(env: NodeJS.ProcessEnv): boolean {
71+
const ci = env.CI?.toLowerCase();
72+
return (
73+
env.GITHUB_ACTIONS === "true" ||
74+
(ci !== undefined && ci !== "" && ci !== "false" && ci !== "0")
75+
);
76+
}
77+
78+
export function userAgentGet(
79+
product?: "sim" | "check",
80+
context: UserAgentContext = {
81+
env: process.env,
82+
scriptPath: process.argv[1],
83+
},
84+
): string {
85+
const command = product ?? commandNameGet(context);
86+
const ci = ciRun(context.env) ? " (ci)" : "";
87+
return `vapi-gitops-${command}/${PACKAGE_VERSION}${ci}`;
3988
}

0 commit comments

Comments
 (0)