@cantinasecurity/apex-cli 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,755 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { execFileSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import path from "node:path";
5
+ import { loadConfig, saveConfig } from "./config.js";
6
+ import { APEX_CLI_VERSION } from "./version.js";
7
+ const TELEMETRY_ENDPOINT_PATH = "/api/cli/v1/telemetry/events";
8
+ const TELEMETRY_SESSION_ID = randomUUID();
9
+ const TELEMETRY_TIMEOUT_MS = 750;
10
+ const telemetryContext = new AsyncLocalStorage();
11
+ const KNOWN_CLI_FLAG_NAMES = [
12
+ "comment",
13
+ "company",
14
+ "content",
15
+ "dismissal-reason",
16
+ "fix-pr-url",
17
+ "force",
18
+ "format",
19
+ "help",
20
+ "json",
21
+ "label",
22
+ "limit",
23
+ "mode",
24
+ "no-open",
25
+ "non-interactive",
26
+ "output",
27
+ "parent-comment",
28
+ "pr",
29
+ "pr-path",
30
+ "repo",
31
+ "scan",
32
+ "source-mode",
33
+ "status",
34
+ "suggested-severity",
35
+ "workspace-name",
36
+ ];
37
+ const KNOWN_CLI_COMMANDS = [
38
+ "cancel-scan",
39
+ "connect",
40
+ "credits",
41
+ "doctor",
42
+ "export",
43
+ "findings",
44
+ "help",
45
+ "interactive-shell",
46
+ "login",
47
+ "logout",
48
+ "mcp",
49
+ "scan",
50
+ "scans",
51
+ "setup",
52
+ "status",
53
+ "telemetry",
54
+ "update",
55
+ "workspace",
56
+ "workspaces",
57
+ ];
58
+ let cachedParentProcesses = null;
59
+ let volatileTelemetryInstallId = null;
60
+ function boolEnv(value) {
61
+ if (!value)
62
+ return false;
63
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
64
+ }
65
+ function getDisabledEnvName() {
66
+ for (const name of [
67
+ "APEX_TELEMETRY_DISABLED",
68
+ "APEX_DISABLE_TELEMETRY",
69
+ "DO_NOT_TRACK",
70
+ ]) {
71
+ if (boolEnv(process.env[name])) {
72
+ return name;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ function safeToken(value, fallback = "unknown") {
78
+ const trimmed = value?.trim();
79
+ if (!trimmed)
80
+ return fallback;
81
+ const normalized = trimmed
82
+ .slice(0, 80)
83
+ .replace(/[^A-Za-z0-9_.:-]+/g, "_")
84
+ .replace(/^_+|_+$/g, "");
85
+ return normalized || fallback;
86
+ }
87
+ function safeHeader(value) {
88
+ const token = safeToken(value, "");
89
+ return token.length > 0 ? token.slice(0, 120) : null;
90
+ }
91
+ function stringFlag(flags, key) {
92
+ const value = flags[key];
93
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
94
+ }
95
+ function flagCount(flags, key) {
96
+ const value = flags[key];
97
+ if (Array.isArray(value))
98
+ return value.length;
99
+ return typeof value === "string" && value.trim().length > 0 ? 1 : 0;
100
+ }
101
+ function boolFlag(flags, key) {
102
+ return flags[key] === true;
103
+ }
104
+ function safeEnum(value, allowed) {
105
+ return allowed.includes(value) ? value : null;
106
+ }
107
+ function bucketCount(count) {
108
+ if (count <= 0)
109
+ return "0";
110
+ if (count === 1)
111
+ return "1";
112
+ if (count <= 5)
113
+ return "2-5";
114
+ if (count <= 20)
115
+ return "6-20";
116
+ return "21+";
117
+ }
118
+ function bucketLength(value) {
119
+ if (typeof value !== "string")
120
+ return null;
121
+ const length = value.length;
122
+ if (length === 0)
123
+ return "0";
124
+ if (length <= 80)
125
+ return "1-80";
126
+ if (length <= 500)
127
+ return "81-500";
128
+ if (length <= 2_000)
129
+ return "501-2000";
130
+ return "2001+";
131
+ }
132
+ function bucketLimit(value) {
133
+ const numeric = typeof value === "number"
134
+ ? value
135
+ : typeof value === "string"
136
+ ? Number(value)
137
+ : Number.NaN;
138
+ if (!Number.isFinite(numeric))
139
+ return null;
140
+ if (numeric <= 0)
141
+ return "0";
142
+ if (numeric <= 10)
143
+ return "1-10";
144
+ if (numeric <= 50)
145
+ return "11-50";
146
+ if (numeric <= 100)
147
+ return "51-100";
148
+ return "101+";
149
+ }
150
+ function sanitizeFlagNames(flags) {
151
+ return Object.keys(flags)
152
+ .filter((key) => KNOWN_CLI_FLAG_NAMES.includes(key))
153
+ .map((key) => safeToken(key))
154
+ .sort();
155
+ }
156
+ function unknownFlagCount(flags) {
157
+ return Object.keys(flags).filter((key) => !KNOWN_CLI_FLAG_NAMES.includes(key)).length;
158
+ }
159
+ function sanitizeCliFlags(flags) {
160
+ return {
161
+ flagNames: sanitizeFlagNames(flags),
162
+ unknownFlagCount: unknownFlagCount(flags),
163
+ json: boolFlag(flags, "json"),
164
+ force: boolFlag(flags, "force"),
165
+ nonInteractive: boolFlag(flags, "non-interactive"),
166
+ noOpen: boolFlag(flags, "no-open"),
167
+ companyProvided: stringFlag(flags, "company") !== null,
168
+ workspaceNameProvided: stringFlag(flags, "workspace-name") !== null,
169
+ scanIdProvided: stringFlag(flags, "scan") !== null,
170
+ outputProvided: stringFlag(flags, "output") !== null,
171
+ contentProvided: stringFlag(flags, "content") !== null,
172
+ commentProvided: stringFlag(flags, "comment") !== null,
173
+ parentCommentProvided: stringFlag(flags, "parent-comment") !== null,
174
+ mode: safeEnum(stringFlag(flags, "mode"), ["standard", "audit", "ultra", "pr"]),
175
+ sourceMode: safeEnum(stringFlag(flags, "source-mode"), ["auto", "remote", "local"]),
176
+ format: safeEnum(stringFlag(flags, "format"), ["markdown", "json", "gitlab-sast"]),
177
+ feedbackStatus: safeEnum(stringFlag(flags, "status"), ["valid", "invalid"]),
178
+ dismissalReason: safeEnum(stringFlag(flags, "dismissal-reason"), [
179
+ "false-positive",
180
+ "by-design",
181
+ "not-relevant",
182
+ ]),
183
+ suggestedSeverity: safeEnum(stringFlag(flags, "suggested-severity"), [
184
+ "extreme",
185
+ "critical",
186
+ "high",
187
+ "medium",
188
+ "low",
189
+ "informational",
190
+ ]),
191
+ repoCount: flagCount(flags, "repo"),
192
+ prCount: flagCount(flags, "pr"),
193
+ prPathCount: flagCount(flags, "pr-path"),
194
+ labelCount: flagCount(flags, "label"),
195
+ fixPrUrlCount: flagCount(flags, "fix-pr-url"),
196
+ limitBucket: bucketLimit(flags.limit),
197
+ };
198
+ }
199
+ function sanitizeShellArgs(parsed) {
200
+ switch (parsed.command) {
201
+ case "cancel":
202
+ case "cancel-scan":
203
+ case "export":
204
+ case "status":
205
+ return {
206
+ scanIdProvided: parsed.args.length > 0,
207
+ };
208
+ case "company":
209
+ return {
210
+ companyProvided: parsed.args.length > 0,
211
+ };
212
+ case "connect":
213
+ return {
214
+ provider: safeEnum(parsed.args[0] ?? null, ["github", "gitlab"]),
215
+ };
216
+ case "findings": {
217
+ const action = parsed.args[0] ?? null;
218
+ if (!action)
219
+ return {};
220
+ if (action === "comment") {
221
+ return {
222
+ findingRefProvided: Boolean(parsed.args[1]),
223
+ commentLengthBucket: bucketLength(parsed.args.slice(2).join(" ")),
224
+ };
225
+ }
226
+ if (action === "feedback") {
227
+ const feedbackStatus = safeEnum(parsed.args[2] ?? null, ["valid", "invalid"]);
228
+ return {
229
+ findingRefProvided: Boolean(parsed.args[1]),
230
+ feedbackStatus,
231
+ dismissalReason: feedbackStatus === "invalid"
232
+ ? safeEnum(parsed.args[3] ?? null, [
233
+ "false-positive",
234
+ "by-design",
235
+ "not-relevant",
236
+ ])
237
+ : null,
238
+ commentLengthBucket: bucketLength(parsed.args.slice(feedbackStatus === "invalid" ? 4 : 3).join(" ")),
239
+ };
240
+ }
241
+ if (action === "fix-review") {
242
+ return {
243
+ findingRefProvided: Boolean(parsed.args[1]),
244
+ };
245
+ }
246
+ return {
247
+ scanIdProvided: true,
248
+ };
249
+ }
250
+ case "scan": {
251
+ const mode = safeEnum(parsed.args[0] ?? "default", [
252
+ "standard",
253
+ "audit",
254
+ "ultra",
255
+ "pr",
256
+ ]);
257
+ return {
258
+ mode,
259
+ prCount: mode === "pr" && parsed.args[1] ? 1 : 0,
260
+ };
261
+ }
262
+ case "telemetry":
263
+ return {
264
+ telemetryAction: safeEnum(parsed.args[0] ?? "status", [
265
+ "status",
266
+ "enable",
267
+ "disable",
268
+ ]),
269
+ };
270
+ case "workspace":
271
+ return {
272
+ workspaceRefProvided: parsed.args[0] === "use" && parsed.args.length > 1,
273
+ workspaceNameProvided: parsed.args[0] === "name" ? parsed.args.length > 1 : parsed.args.length > 0,
274
+ };
275
+ default:
276
+ return {};
277
+ }
278
+ }
279
+ function safeCliSubcommand(parsed) {
280
+ const subcommand = parsed.subcommand?.trim();
281
+ if (!subcommand)
282
+ return null;
283
+ switch (parsed.command) {
284
+ case "connect":
285
+ return safeEnum(subcommand, ["github", "gitlab"]);
286
+ case "export":
287
+ return safeEnum(subcommand, ["findings"]);
288
+ case "findings":
289
+ return safeEnum(subcommand, ["comment", "feedback", "fix-review"]);
290
+ case "setup":
291
+ return safeEnum(subcommand, ["all", "codex", "claude", "copilot"]);
292
+ case "telemetry":
293
+ return safeEnum(subcommand, ["status", "enable", "disable"]);
294
+ case "workspace":
295
+ return safeEnum(subcommand, ["use"]);
296
+ default:
297
+ return null;
298
+ }
299
+ }
300
+ function safeCliCommand(parsed) {
301
+ const command = parsed.command ?? "interactive-shell";
302
+ return KNOWN_CLI_COMMANDS.includes(command) ? command : "unknown";
303
+ }
304
+ function getInputKeys(input) {
305
+ return Object.entries(input)
306
+ .filter(([, value]) => value !== undefined)
307
+ .map(([key]) => safeToken(key))
308
+ .sort();
309
+ }
310
+ function arrayLength(value) {
311
+ return Array.isArray(value) ? value.length : 0;
312
+ }
313
+ function pullRequestPathCount(value) {
314
+ if (!Array.isArray(value))
315
+ return 0;
316
+ return value.reduce((count, item) => {
317
+ if (!item || typeof item !== "object")
318
+ return count;
319
+ const paths = item.paths;
320
+ return count + (Array.isArray(paths) ? paths.length : 0);
321
+ }, 0);
322
+ }
323
+ export function sanitizeMcpInput(input = {}) {
324
+ return {
325
+ inputKeys: getInputKeys(input),
326
+ cwdProvided: typeof input.cwd === "string" && input.cwd.trim().length > 0,
327
+ companyProvided: typeof input.company === "string" && input.company.trim().length > 0,
328
+ workspaceNameProvided: typeof input.workspaceName === "string" && input.workspaceName.trim().length > 0,
329
+ workspaceRefProvided: typeof input.workspaceRef === "string" && input.workspaceRef.trim().length > 0,
330
+ scanIdProvided: typeof input.scanId === "string" && input.scanId.trim().length > 0,
331
+ findingRefProvided: typeof input.findingRef === "string" && input.findingRef.trim().length > 0,
332
+ outputProvided: typeof input.output === "string" && input.output.trim().length > 0,
333
+ force: input.force === true,
334
+ mode: safeEnum(typeof input.mode === "string" ? input.mode : null, [
335
+ "standard",
336
+ "audit",
337
+ "ultra",
338
+ "pr",
339
+ ]),
340
+ sourceMode: safeEnum(typeof input.sourceMode === "string" ? input.sourceMode : null, [
341
+ "auto",
342
+ "remote",
343
+ "local",
344
+ ]),
345
+ format: safeEnum(typeof input.format === "string" ? input.format : null, [
346
+ "markdown",
347
+ "json",
348
+ "gitlab-sast",
349
+ ]),
350
+ provider: safeEnum(typeof input.provider === "string" ? input.provider : null, [
351
+ "github",
352
+ "gitlab",
353
+ ]),
354
+ feedbackStatus: safeEnum(typeof input.status === "string" ? input.status : null, [
355
+ "valid",
356
+ "invalid",
357
+ ]),
358
+ dismissalReason: safeEnum(typeof input.dismissalReason === "string" ? input.dismissalReason : null, ["false-positive", "by-design", "not-relevant"]),
359
+ suggestedSeverity: safeEnum(typeof input.suggestedSeverity === "string" ? input.suggestedSeverity : null, ["extreme", "critical", "high", "medium", "low", "informational"]),
360
+ repoCount: arrayLength(input.repoPaths),
361
+ pullRequestCount: arrayLength(input.pullRequests),
362
+ pullRequestPathCount: pullRequestPathCount(input.pullRequests),
363
+ labelCount: arrayLength(input.labels),
364
+ fixPrUrlCount: arrayLength(input.fixPrUrls),
365
+ contentLengthBucket: bucketLength(input.content),
366
+ commentLengthBucket: bucketLength(input.comment),
367
+ limitBucket: bucketLimit(input.limit),
368
+ };
369
+ }
370
+ function getEnvHints() {
371
+ return [
372
+ "APEX_MCP_CLIENT",
373
+ "APEX_CLIENT_INTEGRATION",
374
+ "APEX_CLIENT_NAME",
375
+ "CODEX_HOME",
376
+ "CODEX_SANDBOX",
377
+ "CLAUDECODE",
378
+ "CLAUDE_CODE_ENTRYPOINT",
379
+ "COPILOT_HOME",
380
+ "CURSOR_TRACE_ID",
381
+ "VSCODE_PID",
382
+ "CI",
383
+ ].filter((name) => process.env[name] !== undefined);
384
+ }
385
+ function getLauncher() {
386
+ const candidates = [
387
+ process.env.npm_execpath ? path.basename(process.env.npm_execpath) : null,
388
+ process.env._ ? path.basename(process.env._) : null,
389
+ process.argv[1] ? path.basename(process.argv[1]) : null,
390
+ ];
391
+ return candidates.find((candidate) => candidate && candidate.length > 0) ?? null;
392
+ }
393
+ function readProcessInfo(pid) {
394
+ try {
395
+ const output = execFileSync("ps", ["-o", "ppid=", "-o", "comm=", "-p", String(pid)], {
396
+ encoding: "utf8",
397
+ timeout: 100,
398
+ }).trim();
399
+ const match = output.match(/^(\d+)\s+(.+)$/);
400
+ if (!match?.[1] || !match[2])
401
+ return null;
402
+ return {
403
+ ppid: Number(match[1]),
404
+ name: safeToken(path.basename(match[2])),
405
+ };
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ }
411
+ function getParentProcesses() {
412
+ if (cachedParentProcesses)
413
+ return cachedParentProcesses;
414
+ const processes = [];
415
+ let pid = process.ppid;
416
+ for (let index = 0; index < 4; index += 1) {
417
+ if (!pid || pid <= 1)
418
+ break;
419
+ const info = readProcessInfo(pid);
420
+ if (!info)
421
+ break;
422
+ processes.push(info.name);
423
+ pid = info.ppid;
424
+ }
425
+ cachedParentProcesses = processes;
426
+ return processes;
427
+ }
428
+ function inferIntegrationFromProcessNames(parentProcesses) {
429
+ const joined = parentProcesses.join(" ").toLowerCase();
430
+ if (joined.includes("codex"))
431
+ return "codex";
432
+ if (joined.includes("claude"))
433
+ return "claude";
434
+ if (joined.includes("copilot"))
435
+ return "copilot";
436
+ if (joined.includes("cursor"))
437
+ return "cursor";
438
+ if (joined.includes("code") || joined.includes("vscode"))
439
+ return "vscode";
440
+ return null;
441
+ }
442
+ function getExplicitIntegration() {
443
+ const explicit = process.env.APEX_MCP_CLIENT ??
444
+ process.env.APEX_CLIENT_INTEGRATION ??
445
+ process.env.APEX_CLIENT_NAME ??
446
+ null;
447
+ return explicit ? safeToken(explicit) : null;
448
+ }
449
+ export function getTelemetryClientContext(surface) {
450
+ const envHints = getEnvHints();
451
+ const parentProcesses = getParentProcesses();
452
+ const explicit = getExplicitIntegration();
453
+ let integration = explicit;
454
+ let integrationSource = explicit ? "env" : "unknown";
455
+ if (!integration) {
456
+ if (process.env.CODEX_HOME || process.env.CODEX_SANDBOX) {
457
+ integration = "codex";
458
+ integrationSource = "env";
459
+ }
460
+ else if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT) {
461
+ integration = "claude";
462
+ integrationSource = "env";
463
+ }
464
+ else if (process.env.COPILOT_HOME) {
465
+ integration = "copilot";
466
+ integrationSource = "env";
467
+ }
468
+ else if (process.env.CURSOR_TRACE_ID) {
469
+ integration = "cursor";
470
+ integrationSource = "env";
471
+ }
472
+ else if (process.env.VSCODE_PID) {
473
+ integration = "vscode";
474
+ integrationSource = "env";
475
+ }
476
+ }
477
+ if (!integration) {
478
+ const inferred = inferIntegrationFromProcessNames(parentProcesses);
479
+ if (inferred) {
480
+ integration = inferred;
481
+ integrationSource = "process";
482
+ }
483
+ }
484
+ if (!integration && surface !== "mcp" && process.stdin.isTTY && process.stdout.isTTY) {
485
+ integration = "terminal";
486
+ integrationSource = "tty";
487
+ }
488
+ return {
489
+ integration: integration ?? "unknown",
490
+ integrationSource,
491
+ envHints,
492
+ launcher: getLauncher(),
493
+ parentProcesses,
494
+ isTty: process.stdin.isTTY === true && process.stdout.isTTY === true,
495
+ isCi: boolEnv(process.env.CI),
496
+ };
497
+ }
498
+ function createInvocation(params) {
499
+ return {
500
+ id: randomUUID(),
501
+ surface: params.surface,
502
+ command: safeToken(params.command),
503
+ subcommand: params.subcommand ? safeToken(params.subcommand) : null,
504
+ mcpTool: params.mcpTool ? safeToken(params.mcpTool) : null,
505
+ metadata: params.metadata ?? {},
506
+ startedAtMs: Date.now(),
507
+ startedAt: new Date().toISOString(),
508
+ client: getTelemetryClientContext(params.surface),
509
+ };
510
+ }
511
+ export function createCliTelemetryInvocation(parsed) {
512
+ return createInvocation({
513
+ surface: "cli",
514
+ command: safeCliCommand(parsed),
515
+ subcommand: safeCliSubcommand(parsed),
516
+ metadata: {
517
+ argCount: parsed.args.length,
518
+ ...sanitizeCliFlags(parsed.flags),
519
+ },
520
+ });
521
+ }
522
+ export function createShellTelemetryInvocation(parsed, flags) {
523
+ const shellMode = parsed.command === "scan"
524
+ ? safeEnum(parsed.args[0] ?? "default", ["standard", "audit", "ultra", "pr"]) ??
525
+ (parsed.args[0] ? "other" : "default")
526
+ : null;
527
+ return createInvocation({
528
+ surface: "interactive_shell",
529
+ command: parsed.command,
530
+ metadata: {
531
+ argCount: parsed.args.length,
532
+ ...sanitizeCliFlags(flags),
533
+ ...sanitizeShellArgs(parsed),
534
+ shellMode,
535
+ },
536
+ });
537
+ }
538
+ export function createMcpTelemetryInvocation(toolName, input = {}) {
539
+ return createInvocation({
540
+ surface: "mcp",
541
+ command: toolName,
542
+ mcpTool: toolName,
543
+ metadata: sanitizeMcpInput(input),
544
+ });
545
+ }
546
+ export function createMcpServerTelemetryInvocation() {
547
+ return createInvocation({
548
+ surface: "mcp",
549
+ command: "server",
550
+ subcommand: "start",
551
+ metadata: {},
552
+ });
553
+ }
554
+ export async function withTelemetryContext(invocation, action) {
555
+ return telemetryContext.run(invocation, action);
556
+ }
557
+ function currentInvocation() {
558
+ return telemetryContext.getStore() ?? null;
559
+ }
560
+ function getTelemetryDisabled(config) {
561
+ const disabledEnv = getDisabledEnvName();
562
+ if (disabledEnv)
563
+ return `env:${disabledEnv}`;
564
+ return config.telemetryDisabled === true ? "config" : null;
565
+ }
566
+ function isUuid(value) {
567
+ return Boolean(value &&
568
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value));
569
+ }
570
+ async function getTelemetryStatusInternal(options) {
571
+ const config = await loadConfig();
572
+ const disabledBy = getTelemetryDisabled(config);
573
+ let installId = isUuid(config.telemetryInstallId)
574
+ ? config.telemetryInstallId
575
+ : volatileTelemetryInstallId;
576
+ if (!disabledBy && !installId && options.createInstallId) {
577
+ installId = randomUUID();
578
+ volatileTelemetryInstallId = installId;
579
+ try {
580
+ await saveConfig({
581
+ ...config,
582
+ telemetryInstallId: installId,
583
+ });
584
+ }
585
+ catch {
586
+ // Telemetry-only ID creation must not break normal CLI/API behavior.
587
+ }
588
+ }
589
+ return {
590
+ enabled: disabledBy === null,
591
+ disabledBy,
592
+ installId,
593
+ sessionId: TELEMETRY_SESSION_ID,
594
+ endpointPath: TELEMETRY_ENDPOINT_PATH,
595
+ client: getTelemetryClientContext(options.surface),
596
+ baseUrl: config.baseUrl,
597
+ };
598
+ }
599
+ export async function getTelemetryStatus(surface = "cli", options = {}) {
600
+ const { baseUrl: _baseUrl, ...status } = await getTelemetryStatusInternal({
601
+ createInstallId: options.createInstallId === true,
602
+ surface,
603
+ });
604
+ return status;
605
+ }
606
+ export async function setTelemetryEnabled(enabled) {
607
+ const config = await loadConfig();
608
+ await saveConfig({
609
+ ...config,
610
+ telemetryDisabled: enabled ? false : true,
611
+ });
612
+ return getTelemetryStatus("cli", { createInstallId: enabled });
613
+ }
614
+ function runtimePayload() {
615
+ return {
616
+ nodeMajor: Number(process.versions.node.split(".")[0] ?? "0") || null,
617
+ platform: process.platform,
618
+ arch: process.arch,
619
+ shell: process.env.SHELL ? safeToken(path.basename(process.env.SHELL)) : null,
620
+ termProgram: process.env.TERM_PROGRAM ? safeToken(process.env.TERM_PROGRAM) : null,
621
+ packageManager: process.env.npm_config_user_agent
622
+ ? safeToken(process.env.npm_config_user_agent.split(" ")[0])
623
+ : null,
624
+ };
625
+ }
626
+ function errorPayload(error) {
627
+ if (!error || typeof error !== "object") {
628
+ return {
629
+ name: typeof error,
630
+ category: "unknown",
631
+ };
632
+ }
633
+ const maybeError = error;
634
+ const status = typeof maybeError.status === "number" ? maybeError.status : null;
635
+ return {
636
+ name: typeof maybeError.name === "string" ? safeToken(maybeError.name) : "Error",
637
+ status,
638
+ code: typeof maybeError.code === "string" ? safeToken(maybeError.code) : null,
639
+ category: status ? `http_${status}` : "error",
640
+ };
641
+ }
642
+ function buildPayload(event, invocation, installId, outcome) {
643
+ return {
644
+ schemaVersion: 1,
645
+ event,
646
+ occurredAt: new Date().toISOString(),
647
+ sessionId: TELEMETRY_SESSION_ID,
648
+ installId,
649
+ cliVersion: APEX_CLI_VERSION,
650
+ invocation: {
651
+ id: invocation.id,
652
+ surface: invocation.surface,
653
+ command: invocation.command,
654
+ subcommand: invocation.subcommand,
655
+ mcpTool: invocation.mcpTool,
656
+ startedAt: invocation.startedAt,
657
+ metadata: invocation.metadata,
658
+ },
659
+ client: invocation.client,
660
+ runtime: runtimePayload(),
661
+ outcome: outcome ?? null,
662
+ };
663
+ }
664
+ async function postTelemetry(payload, baseUrl) {
665
+ const controller = new AbortController();
666
+ const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
667
+ try {
668
+ const invocationPayload = payload.invocation;
669
+ const clientPayload = payload.client;
670
+ const headers = new Headers({
671
+ "Content-Type": "application/json",
672
+ "X-Apex-Client-Surface": safeToken(String(invocationPayload?.surface)),
673
+ "X-Apex-Client-Version": APEX_CLI_VERSION,
674
+ "X-Apex-Client-Integration": safeToken(String(clientPayload?.integration)),
675
+ "X-Apex-Telemetry-Schema": "1",
676
+ });
677
+ await fetch(new URL(TELEMETRY_ENDPOINT_PATH, baseUrl), {
678
+ method: "POST",
679
+ headers,
680
+ body: JSON.stringify({ events: [payload] }),
681
+ signal: controller.signal,
682
+ });
683
+ }
684
+ catch {
685
+ // Telemetry must never affect CLI/MCP behavior.
686
+ }
687
+ finally {
688
+ clearTimeout(timeout);
689
+ }
690
+ }
691
+ async function emitTelemetry(event, invocation, outcome) {
692
+ const status = await getTelemetryStatusInternal({
693
+ createInstallId: true,
694
+ surface: invocation.surface,
695
+ });
696
+ if (!status.enabled)
697
+ return;
698
+ const payload = buildPayload(event, invocation, status.installId, outcome);
699
+ await postTelemetry(payload, status.baseUrl);
700
+ }
701
+ export function emitInvocationStarted(invocation) {
702
+ void emitTelemetry("apex.invocation.started", invocation);
703
+ }
704
+ export function emitInvocationCompleted(invocation, error) {
705
+ const durationMs = Math.max(0, Date.now() - invocation.startedAtMs);
706
+ void emitTelemetry("apex.invocation.completed", invocation, {
707
+ success: error === undefined,
708
+ durationMs,
709
+ ...(error === undefined ? {} : { error: errorPayload(error) }),
710
+ });
711
+ }
712
+ export function emitMcpServerStarted(invocation) {
713
+ void emitTelemetry("apex.mcp.server.started", invocation);
714
+ }
715
+ export async function getTelemetryRequestHeaders() {
716
+ const invocation = currentInvocation();
717
+ const status = await getTelemetryStatusInternal({
718
+ createInstallId: true,
719
+ surface: invocation?.surface ?? "cli",
720
+ });
721
+ if (!status.enabled)
722
+ return {};
723
+ const client = invocation?.client ?? status.client;
724
+ const headers = {
725
+ "X-Apex-Client-Surface": invocation?.surface ?? "cli",
726
+ "X-Apex-Client-Version": APEX_CLI_VERSION,
727
+ "X-Apex-Client-Integration": client.integration,
728
+ "X-Apex-Client-Session-Id": TELEMETRY_SESSION_ID,
729
+ "X-Apex-Telemetry-Schema": "1",
730
+ };
731
+ if (status.installId) {
732
+ headers["X-Apex-Client-Install-Id"] = status.installId;
733
+ }
734
+ if (invocation) {
735
+ headers["X-Apex-Invocation-Id"] = invocation.id;
736
+ headers["X-Apex-Invocation-Command"] = invocation.command;
737
+ if (invocation.subcommand) {
738
+ headers["X-Apex-Invocation-Subcommand"] = invocation.subcommand;
739
+ }
740
+ if (invocation.mcpTool) {
741
+ headers["X-Apex-Mcp-Tool"] = invocation.mcpTool;
742
+ }
743
+ }
744
+ return Object.fromEntries(Object.entries(headers).flatMap(([key, value]) => {
745
+ const safe = safeHeader(value);
746
+ return safe ? [[key, safe]] : [];
747
+ }));
748
+ }
749
+ export const testing = {
750
+ bucketCount,
751
+ bucketLength,
752
+ getTelemetryClientContext,
753
+ sanitizeCliFlags,
754
+ sanitizeMcpInput,
755
+ };