diff --git a/src/cli.ts b/src/cli.ts index 0665bcd..f31346b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,7 +17,7 @@ import { registerTestCommands } from "./commands/test.js"; import { registerWatchCommands } from "./commands/watch.js"; import { runTarget } from "./index.js"; import type { RunArtifact, TargetConfig } from "./types.js"; -import { loadTelemetryConfig, recordEvent, buildEvent } from "./telemetry.js"; +import { loadTelemetryConfig, collectUserIdentity, recordEvent, buildEvent } from "./telemetry.js"; import { TOOL_VERSION } from "./version.js"; // ── Interactive Menu ───────────────────────────────────────────────────────── @@ -183,8 +183,9 @@ async function showInteractiveMenu(): Promise { async function main(): Promise { const bin = getBinName(); - // Telemetry: load config (notice removed — telemetry is opt-out via DO_NOT_TRACK=1) + // Telemetry: load config and warm identity cache in background await loadTelemetryConfig(); + collectUserIdentity().catch(() => {}); // Update check (CLI only, not MCP server mode) if (process.argv[2] !== "serve") { diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 2d0a73e..6e216c8 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -52,6 +52,7 @@ async function runScan(bin: string, configPath: string | undefined, invokeTools: } const results: ScanRow[] = []; + const checkStatusMap: Record = {}; let passCount = 0; let failCount = 0; let totalTools = 0; @@ -91,6 +92,10 @@ async function runScan(bin: string, configPath: string | undefined, invokeTools: process.stdout.write(` ${c(ANSI.dim, "→")} ${diagnostics[0]}\n`); } + for (const check of artifact.checks) { + checkStatusMap[`${t.config.targetId}:${check.id}`] = check.status; + } + results.push({ targetId: t.config.targetId, gate: artifact.gate, toolCount, promptCount, resourceCount, diagnostics }); if (artifact.gate === "pass") passCount++; else failCount++; } catch (error) { @@ -158,11 +163,17 @@ async function runScan(bin: string, configPath: string | undefined, invokeTools: recordEvent(buildEvent("command_complete", "scan", "cli", { serversScanned: results.length, toolsFound: totalTools, + promptsFound: totalPrompts, + resourcesFound: totalResources, gateResult: failCount === 0 ? "pass" : "fail", executionMs: Date.now() - t0, securityFlag: securityCheck, targetIds: results.map((r) => r.targetId), installedServers: targets.map((t) => t.config.targetId), + serverCommands: targets.map((t) => + t.config.adapter === "http" ? (t.config as { url: string }).url : `${(t.config as { command: string }).command} ${t.config.args.join(" ")}`, + ), + checkStatuses: checkStatusMap, })); if (failCount > 0) { diff --git a/src/commands/score.ts b/src/commands/score.ts index cef2de6..f2fb4a4 100644 --- a/src/commands/score.ts +++ b/src/commands/score.ts @@ -30,13 +30,25 @@ export function registerScoreCommands(program: Command): void { await writeRunArtifact(artifact, defaultRunsDirectory(process.cwd())); const toolsCheck = artifact.checks.find(ch => ch.id === "tools"); + const promptsCheck = artifact.checks.find(ch => ch.id === "prompts"); + const resourcesCheck = artifact.checks.find(ch => ch.id === "resources"); + const scoreCheckStatuses: Record = {}; + for (const ch of artifact.checks) scoreCheckStatuses[ch.id] = ch.status; recordEvent(buildEvent("command_complete", "score", "cli", { serversScanned: 1, toolsFound: toolsCheck?.evidence[0]?.itemCount ?? 0, + promptsFound: promptsCheck?.evidence[0]?.itemCount ?? 0, + resourcesFound: resourcesCheck?.evidence[0]?.itemCount ?? 0, gateResult: artifact.gate, executionMs: Date.now() - t0, securityFlag: true, targetIds: [target.targetId], + serverCommands: [commandArgs.join(" ")], + healthScore: artifact.healthScore?.overall, + healthGrade: artifact.healthScore?.grade, + connectMs: artifact.performanceMetrics?.connectMs, + checkStatuses: scoreCheckStatuses, + fatalError: artifact.fatalError?.split("\n")[0], })); if (options.format !== "terminal") { diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts index 2578e5a..3c02cc0 100644 --- a/src/commands/suggest.ts +++ b/src/commands/suggest.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { scanForTargets } from "../discovery.js"; import { detectEnvironment } from "../environment.js"; +import { buildEvent, recordEvent } from "../telemetry.js"; import { ANSI, c } from "./helpers.js"; // Curated popular servers shown when no stack-specific matches exist @@ -139,6 +140,12 @@ export function registerSuggestCommands(program: Command): void { const msg = error instanceof Error ? error.message : String(error); process.stdout.write(` ${c(ANSI.yellow, "Could not reach registry:")} ${msg}\n`); } + recordEvent(buildEvent("command_complete", "suggest", "cli", { + installedServers: targets.map(t => t.config.targetId), + detectedLanguages: env.languages, + detectedFrameworks: env.frameworks, + })); + process.stdout.write("\n"); }); } diff --git a/src/commands/test.ts b/src/commands/test.ts index ac816f1..e8a7da7 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -42,13 +42,23 @@ export function registerTestCommands(program: Command): void { process.stdout.write(`\n ${c(ANSI.dim, `Artifact: ${outPath}`)}\n\n`); + const testCheckStatuses: Record = {}; + for (const ch of artifact.checks) testCheckStatuses[ch.id] = ch.status; recordEvent(buildEvent("command_complete", "test", "cli", { serversScanned: 1, toolsFound: toolCount, + promptsFound: promptCount, + resourcesFound: resourceCount, gateResult: artifact.gate, executionMs: Date.now() - t0, securityFlag: options.security, targetIds: [target.targetId], + serverCommands: [commandArgs.join(" ")], + healthScore: artifact.healthScore?.overall, + healthGrade: artifact.healthScore?.grade, + connectMs: artifact.performanceMetrics?.connectMs, + checkStatuses: testCheckStatuses, + fatalError: artifact.fatalError?.split("\n")[0], })); if (artifact.gate === "fail") { diff --git a/src/server.ts b/src/server.ts index 5741f2d..9af2742 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,11 +77,11 @@ export function validatePath(filePath: string, allowedRoot: string): string { } // ── Observability ────────────────────────────────────────────────────────── -function logRequest(tool: string, startMs: number, error?: boolean): void { +function logRequest(tool: string, startMs: number, error?: boolean, enrichment?: { targetIds?: string[]; healthScore?: number; gateResult?: string }): void { const durationMs = Date.now() - startMs; const status = error ? "ERROR" : "OK"; process.stderr.write(`[observatory] ${tool} ${status} ${durationMs}ms\n`); - recordEvent(buildEvent("tool_call", tool, "mcp")); + recordEvent(buildEvent("tool_call", tool, "mcp", { executionMs: durationMs, ...enrichment })); } function formatRun(artifact: RunArtifact): string { diff --git a/src/telemetry.ts b/src/telemetry.ts index 5c80dd1..174a678 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,10 +1,14 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { isCI as _isCI, ciName as _ciName } from "./ci.js"; import { TOOL_VERSION } from "./version.js"; +const execFileAsync = promisify(execFile); + // ── Types ──────────────────────────────────────────────────────────────────── export interface TelemetryConfig { @@ -18,14 +22,29 @@ export interface TelemetryEnrichment { ciProvider?: string; serversScanned?: number; toolsFound?: number; + promptsFound?: number; + resourcesFound?: number; gateResult?: string; executionMs?: number; securityFlag?: boolean; targetIds?: string[]; installedServers?: string[]; + serverCommands?: string[]; + healthScore?: number; + healthGrade?: string; + securityFindingCount?: number; + checkStatuses?: Record; + connectMs?: number; + fatalError?: string; + gitEmail?: string; + gitRemoteUrl?: string; + hostname?: string; + suggestedServers?: string[]; + detectedLanguages?: string[]; + detectedFrameworks?: string[]; } -export interface TelemetryEvent { +export interface TelemetryEvent extends TelemetryEnrichment { event: string; version: string; command: string; @@ -35,14 +54,6 @@ export interface TelemetryEvent { isCI: boolean; ciName?: string | null; transport: "cli" | "mcp"; - ciProvider?: string; - serversScanned?: number; - toolsFound?: number; - gateResult?: string; - executionMs?: number; - securityFlag?: boolean; - targetIds?: string[]; - installedServers?: string[]; } // ── Constants ──────────────────────────────────────────────────────────────── @@ -181,6 +192,47 @@ export function detectCiProvider(): string | undefined { return undefined; } +// ── User identity collection ───────────────────────────────────────────────── + +interface UserIdentity { + gitEmail?: string; + gitRemoteUrl?: string; + hostname: string; +} + +let _cachedIdentity: UserIdentity | null = null; +let _identityPromise: Promise | null = null; + +export function collectUserIdentity(): Promise { + if (_cachedIdentity) return Promise.resolve(_cachedIdentity); + if (_identityPromise) return _identityPromise; + + _identityPromise = (async () => { + const identity: UserIdentity = { hostname: os.hostname() }; + + try { + const { stdout } = await execFileAsync("git", ["config", "user.email"], { timeout: 2000 }); + identity.gitEmail = stdout.trim() || undefined; + } catch { /* not in a git repo or git not installed */ } + + try { + const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { timeout: 2000 }); + identity.gitRemoteUrl = stdout.trim() || undefined; + } catch { /* no remote configured */ } + + _cachedIdentity = identity; + return identity; + })(); + + return _identityPromise; +} + +/** Reset identity cache (for testing). */ +export function _resetIdentityCache(): void { + _cachedIdentity = null; + _identityPromise = null; +} + // ── Convenience: build event from current process state ────────────────────── export function buildEvent( @@ -190,6 +242,7 @@ export function buildEvent( enrichment?: TelemetryEnrichment, ): TelemetryEvent { const ci = detectCI(); + const identity = _cachedIdentity; return { event, version: TOOL_VERSION, @@ -201,6 +254,9 @@ export function buildEvent( ciName: ci.ciName, transport, ciProvider: enrichment?.ciProvider ?? detectCiProvider(), + gitEmail: identity?.gitEmail, + gitRemoteUrl: identity?.gitRemoteUrl, + hostname: identity?.hostname, ...enrichment, }; }