diff --git a/bin/brakit.ts b/bin/brakit.ts index c2a094e..093edae 100644 --- a/bin/brakit.ts +++ b/bin/brakit.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import installCommand from "../src/cli/commands/install.js"; import uninstallCommand from "../src/cli/commands/uninstall.js"; import { trackEvent } from "../src/telemetry/index.js"; -import { TELEMETRY_EVENT_CLI_INVOKED } from "../src/constants/config.js"; +import { TELEMETRY_EVENT_CLI_INVOKED } from "../src/constants/telemetry.js"; const sub = process.argv[2]; const command = sub === "uninstall" ? "uninstall" : sub === "mcp" ? "mcp" : "install"; diff --git a/sdks/python/brakit/_setup.py b/sdks/python/brakit/_setup.py index 6bca625..85904f1 100644 --- a/sdks/python/brakit/_setup.py +++ b/sdks/python/brakit/_setup.py @@ -5,6 +5,7 @@ import logging import threading import time +from typing import TYPE_CHECKING from brakit.constants.events import ( CHANNEL_REQUEST_COMPLETED, @@ -25,10 +26,15 @@ from brakit.types.http import TracedRequest from brakit.types.telemetry import TelemetryEntry +if TYPE_CHECKING: + from brakit.core.registry import ServiceRegistry + from brakit.transport.forwarder import Forwarder + logger = logging.getLogger(LOGGER_NAME) _init_lock = threading.Lock() _initialized = False +_detected_stack: dict[str, object] = {"framework": "unknown", "adapters": []} def _auto_setup() -> None: @@ -47,13 +53,17 @@ def _auto_setup() -> None: adapters = _install_adapters(registry) logger.debug("adapters: %s", adapters) - _start_transport(registry) detected_framework = _install_frameworks(registry) - # Initialize telemetry after framework detection + _detected_stack["framework"] = detected_framework + _detected_stack["adapters"] = adapters + from brakit._telemetry import init_session as _init_telemetry _init_telemetry(framework=detected_framework, adapters=adapters) + # Start transport after stack is known so sdk.hello includes full info + _start_transport(registry) + logger.debug("initialized") @@ -99,9 +109,6 @@ def _start_transport(registry: "ServiceRegistry") -> None: _setup_forwarder(registry, port) return - # Port file not found — the Node.js server may not have received its first - # request yet (the port file is written on first request, not on startup). - # Retry discovery in a background thread so we don't block import. def _retry() -> None: for _ in range(PORT_RETRY_COUNT): time.sleep(PORT_RETRY_INTERVAL_S) @@ -125,6 +132,8 @@ def _setup_forwarder(registry: "ServiceRegistry", port: int) -> None: forwarder = Forwarder(port=port) forwarder.start() + _send_hello(forwarder) + registry.bus.on(CHANNEL_REQUEST_COMPLETED, lambda r: _forward_request(forwarder, r)) registry.bus.on(CHANNEL_TELEMETRY_FETCH, lambda e: _forward_telemetry(forwarder, EVENT_TYPE_FETCH, e)) registry.bus.on(CHANNEL_TELEMETRY_LOG, lambda e: _forward_telemetry(forwarder, EVENT_TYPE_LOG, e)) @@ -140,6 +149,24 @@ def _install_frameworks(registry: "ServiceRegistry") -> str: return detect_frameworks(registry) +def _send_hello(forwarder: "Forwarder") -> None: + import platform + import sys + + event = SDKEvent( + type="sdk.hello", # type: ignore[arg-type] + timestamp=time.time() * 1_000, + data={ + "framework": str(_detected_stack["framework"]), + "adapters": list(_detected_stack["adapters"]), # type: ignore[arg-type] + "pythonVersion": platform.python_version(), + "os": f"{sys.platform}-{platform.release()}", + "arch": platform.machine(), + }, + ) + forwarder.send(event) + + def _forward_request(forwarder: "Forwarder", request: TracedRequest) -> None: raw = dataclasses.asdict(request) data = {_to_camel(k): v for k, v in raw.items()} diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index b8d8dfb..07574ae 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "brakit" -version = "0.1.6" +version = "0.1.7" description = "Zero-config observability for Python web frameworks" readme = "README.md" license = "MIT" diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 23f534d..b7bb437 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -24,7 +24,7 @@ import { import { brakitDebug } from "../../utils/log.js"; import { getErrorMessage } from "../../utils/type-guards.js"; import { trackEvent } from "../../telemetry/index.js"; -import { TELEMETRY_EVENT_CLI_UNINSTALL } from "../../constants/config.js"; +import { TELEMETRY_EVENT_CLI_UNINSTALL } from "../../constants/telemetry.js"; /** * Entry point files where brakit may have prepended an import line. diff --git a/src/constants/config.ts b/src/constants/config.ts index bcfb583..b444b4e 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -135,32 +135,6 @@ export const VALID_ISSUE_CATEGORIES = new Set(["security", "perfo export const VALID_AI_FIX_STATUSES = new Set(["fixed", "wont_fix"]); export const VALID_SECURITY_SEVERITIES = new Set(["critical", "warning"]); -// ── Telemetry ── - -export const TELEMETRY_EVENT_CLI_INVOKED = "cli_invoked" as const; -export const TELEMETRY_EVENT_CLI_UNINSTALL = "cli_uninstall" as const; -export const TELEMETRY_EVENT_SETUP_COMPLETED = "setup_completed" as const; -export const TELEMETRY_EVENT_FIRST_REQUEST = "first_request" as const; -export const TELEMETRY_EVENT_DASHBOARD_VIEWED = "dashboard_viewed" as const; -export const TELEMETRY_EVENT_SESSION = "session" as const; -export const TELEMETRY_EVENT_GRAPH_FEATURE = "graph_feature" as const; - -export const EXIT_REASON_CLEAN = "clean" as const; -export const EXIT_REASON_SIGINT = "sigint" as const; -export const EXIT_REASON_SIGTERM = "sigterm" as const; -export const EXIT_REASON_UNKNOWN = "unknown" as const; /** Max characters for SQL/query detail preview in insight cards. */ export const DETAIL_PREVIEW_LENGTH = 120; - -/** - * Known dependency names to check for framework/ORM detection. - * Used for telemetry diagnostics — captures which deps exist in - * the user's package.json (names only, no versions or paths). - */ -export const KNOWN_DEPENDENCY_NAMES = [ - "next", "@remix-run/dev", "nuxt", "vite", "astro", - "@nestjs/core", "@adonisjs/core", "sails", - "express", "fastify", "hono", "koa", "@hapi/hapi", - "prisma", "drizzle-orm", "typeorm", "sequelize", -] as const; diff --git a/src/constants/detection.ts b/src/constants/detection.ts new file mode 100644 index 0000000..4ef9f6a --- /dev/null +++ b/src/constants/detection.ts @@ -0,0 +1,74 @@ +import type { HookName } from "../types/detection.js"; + +/** Segment used to identify node_modules paths in require.cache keys. */ +export const NODE_MODULES_SEGMENT = "/node_modules/"; + +/** Hooks installed during Brakit setup. */ +export const INSTALLED_HOOKS: readonly HookName[] = ["fetch", "console", "error"]; + +/** + * Known public package names for stack detection. + * Matched against package.json dependencies and require.cache at runtime. + * Only public npm package names — no versions, paths, or private scopes. + */ +export const KNOWN_DEPENDENCY_NAMES = [ + // -- Frameworks (meta) -- + "next", "@remix-run/dev", "nuxt", "astro", + // -- Frameworks (backend) -- + "@nestjs/core", "@adonisjs/core", "sails", + "express", "fastify", "hono", "koa", "@hapi/hapi", + "elysia", "h3", "nitro", "@trpc/server", + // -- Bundlers -- + "vite", + // -- ORM / query builders -- + "prisma", "@prisma/client", "drizzle-orm", "typeorm", "sequelize", + "mongoose", "kysely", "knex", "@mikro-orm/core", "objection", + // -- DB drivers -- + "pg", "mysql2", "mongodb", "better-sqlite3", + "@libsql/client", "@planetscale/database", + "ioredis", "redis", + // -- Auth -- + "lucia", "next-auth", "@auth/core", "passport", + // -- Queues / messaging -- + "bullmq", "amqplib", "kafkajs", + // -- Validation -- + "zod", "joi", "yup", "arktype", "valibot", + // -- HTTP clients -- + "axios", "got", "ky", "undici", + // -- Realtime -- + "socket.io", "ws", + // -- CSS / styling -- + "tailwindcss", + // -- Testing -- + "vitest", "jest", "mocha", + // -- Runtime indicators -- + "bun-types", "@types/bun", +] as const; + +/** + * Config file paths → tool labels for stack detection. + * Detection checks file existence only (existsSync), never reads contents. + */ +export const KNOWN_CONFIG_FILES = { + "next.config.js": "nextjs", + "next.config.mjs": "nextjs", + "next.config.ts": "nextjs", + "nuxt.config.ts": "nuxt", + "nuxt.config.js": "nuxt", + "astro.config.mjs": "astro", + "astro.config.ts": "astro", + "vite.config.ts": "vite", + "vite.config.js": "vite", + "drizzle.config.ts": "drizzle-orm", + "drizzle.config.js": "drizzle-orm", + "prisma/schema.prisma": "prisma", + "knexfile.js": "knex", + "knexfile.ts": "knex", + "mikro-orm.config.ts": "@mikro-orm/core", + "nest-cli.json": "@nestjs/core", + "tailwind.config.js": "tailwindcss", + "tailwind.config.ts": "tailwindcss", +} as const; + +/** Pre-built Set for O(1) lookups during require.cache scanning. */ +export const KNOWN_DEPENDENCY_SET: ReadonlySet = new Set(KNOWN_DEPENDENCY_NAMES); diff --git a/src/constants/index.ts b/src/constants/index.ts index 8bb6d90..7a7f05c 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,5 @@ export * from "./config.js"; +export * from "./detection.js"; export * from "./labels.js"; export * from "./features.js"; +export * from "./telemetry.js"; diff --git a/src/constants/labels.ts b/src/constants/labels.ts index 1aa186c..4f80231 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -100,21 +100,8 @@ export const SDK_EVENT_FETCH = "fetch" as const; export const SDK_EVENT_LOG = "log" as const; export const SDK_EVENT_ERROR = "error" as const; export const SDK_EVENT_AUTH_CHECK = "auth.check" as const; +export const SDK_EVENT_HELLO = "sdk.hello" as const; -// ── Telemetry ── - -export const POSTHOG_HOST = "https://us.i.posthog.com"; -export const POSTHOG_CAPTURE_PATH = "/i/v0/e/"; -export const POSTHOG_REQUEST_TIMEOUT_MS = 3_000; -export const POSTHOG_SPAWN_TIMEOUT_MS = 5_000; -export const SIGNAL_EXIT_SIGINT = 130; -export const SIGNAL_EXIT_SIGTERM = 143; - -/** - * Thresholds (in ms) for categorizing endpoint response times - * into human-readable buckets for telemetry reporting. - */ -export const SPEED_BUCKET_THRESHOLDS = [200, 500, 1_000, 2_000, 5_000] as const; // ── Timeline ── @@ -124,3 +111,9 @@ export const TIMELINE_FETCH = "fetch" as const; export const TIMELINE_LOG = "log" as const; export const TIMELINE_ERROR = "error" as const; export const TIMELINE_QUERY = "query" as const; + +// ── Unicode symbols ── + +export const UNICODE_ARROW = "\u2192"; +export const UNICODE_EM_DASH = "\u2014"; +export const UNICODE_CHECK_MARK = "\u2713"; diff --git a/src/constants/telemetry.ts b/src/constants/telemetry.ts new file mode 100644 index 0000000..781178c --- /dev/null +++ b/src/constants/telemetry.ts @@ -0,0 +1,37 @@ +// ── PostHog transport ── + +export const POSTHOG_HOST = "https://us.i.posthog.com"; +export const POSTHOG_CAPTURE_PATH = "/i/v0/e/"; +export const POSTHOG_REQUEST_TIMEOUT_MS = 3_000; + +// ── Telemetry event names ── + +export const TELEMETRY_EVENT_CLI_INVOKED = "cli_invoked" as const; +export const TELEMETRY_EVENT_CLI_UNINSTALL = "cli_uninstall" as const; +export const TELEMETRY_EVENT_SETUP_COMPLETED = "setup_completed" as const; +export const TELEMETRY_EVENT_FIRST_REQUEST = "first_request" as const; +export const TELEMETRY_EVENT_DASHBOARD_VIEWED = "dashboard_viewed" as const; +export const TELEMETRY_EVENT_SESSION = "session" as const; +export const TELEMETRY_EVENT_GRAPH_FEATURE = "graph_feature" as const; + +export const TELEMETRY_SDK_NAME = "node" as const; + +// ── Exit reasons ── + +export const EXIT_REASON_CLEAN = "clean" as const; +export const EXIT_REASON_SIGINT = "sigint" as const; +export const EXIT_REASON_SIGTERM = "sigterm" as const; +export const EXIT_REASON_UNKNOWN = "unknown" as const; + +// ── Signal codes ── + +export const SIGNAL_EXIT_SIGINT = 130; +export const SIGNAL_EXIT_SIGTERM = 143; + +// ── Speed buckets ── + +/** + * Thresholds (in ms) for categorizing endpoint response times + * into human-readable buckets for telemetry reporting. + */ +export const SPEED_BUCKET_THRESHOLDS = [200, 500, 1_000, 2_000, 5_000] as const; diff --git a/src/dashboard/api/ingest.ts b/src/dashboard/api/ingest.ts index 01b25ef..3fe97f3 100644 --- a/src/dashboard/api/ingest.ts +++ b/src/dashboard/api/ingest.ts @@ -6,6 +6,8 @@ import { MAX_INGEST_BYTES } from "../../constants/config.js"; import { HTTP_NO_CONTENT, HTTP_BAD_REQUEST, HTTP_METHOD_NOT_ALLOWED, HTTP_PAYLOAD_TOO_LARGE, TIMELINE_FETCH, TIMELINE_LOG, TIMELINE_ERROR, TIMELINE_QUERY } from "../../constants/labels.js"; import { sendJson } from "./shared.js"; import { routeSDKEvent } from "./sdk-event-parser.js"; +import { SDK_EVENT_HELLO } from "../../constants/labels.js"; +import { session } from "../../telemetry/index.js"; function isBrakitBatch(msg: unknown): msg is TelemetryBatch { return ( @@ -81,6 +83,15 @@ export function createIngestHandler( if (isSDKPayload(body)) { for (const event of body.events) { + if (event.type === SDK_EVENT_HELLO) { + const d = event.data as Record; + session.recordPythonStack({ + framework: String(d.framework ?? "unknown"), + adapters: Array.isArray(d.adapters) ? d.adapters.map(String) : [], + pythonVersion: String(d.pythonVersion ?? "unknown"), + }); + continue; + } routeSDKEvent(event, stores); } res.writeHead(HTTP_NO_CONTENT); diff --git a/src/dashboard/client/views/insights-view.ts b/src/dashboard/client/views/insights-view.ts index 86efe7a..74d4f25 100644 --- a/src/dashboard/client/views/insights-view.ts +++ b/src/dashboard/client/views/insights-view.ts @@ -78,7 +78,12 @@ export class InsightsView extends BkViewBase { `)} + ${this.renderSummaryBar(nonStale)} +
+ ${(open.length + regressed.length) > 0 ? html` +
Copy to your AI: "Fix brakit findings"
+ ` : nothing} ${open.length === 0 && regressed.length === 0 && verifying.length === 0 && resolved.length === 0 && dismissed.length === 0 ? html`
\u2713${this.filter === "all" ? "All clear \u2014 no issues detected" : `No ${this.filter} issues`}
` : nothing} @@ -126,6 +131,23 @@ export class InsightsView extends BkViewBase { `; } + private renderSummaryBar(nonStale: StatefulIssue[]) { + const active = nonStale.filter((e) => (e.state === "open" || e.state === "regressed") && e.aiStatus !== "wont_fix"); + const critical = active.filter((e) => e.issue.severity === "critical").length; + const warning = active.filter((e) => e.issue.severity === "warning").length; + const resolved = nonStale.filter((e) => e.state === "resolved").length; + + if (critical === 0 && warning === 0 && resolved === 0) return nothing; + + return html` +
+ ${critical > 0 ? html`${critical} critical` : nothing} + ${warning > 0 ? html`${warning} warning` : nothing} + ${resolved > 0 ? html`${resolved} resolved` : nothing} +
+ `; + } + private renderIssueCard(entry: StatefulIssue, idx: number) { const issue = entry.issue; const severityConfig = SEVERITY_MAP[issue.severity] || SEVERITY_MAP["info"]; @@ -150,11 +172,13 @@ export class InsightsView extends BkViewBase { ${isResolved ? html`resolved` : nothing}
${issue.desc}
- ${issue.detail ? html`
${issue.detail}
` : nothing} - ${entry.cleanHitsSinceLastSeen > 0 ? html` -
${entry.cleanHitsSinceLastSeen}/${CLEAN_HITS_FOR_RESOLUTION} clean requests
+ ${isExpanded ? html` + ${issue.detail ? html`
${issue.detail}
` : nothing} + ${entry.cleanHitsSinceLastSeen > 0 ? html` +
${entry.cleanHitsSinceLastSeen}/${CLEAN_HITS_FOR_RESOLUTION} clean requests
+ ` : nothing} + ${issue.hint ? html`
${issue.hint}
` : nothing} ` : nothing} - ${isExpanded && issue.hint ? html`
${issue.hint}
` : nothing} ${issue.hint ? html`${isExpanded ? "\u2193" : "\u2192"}` : nothing} diff --git a/src/dashboard/router.ts b/src/dashboard/router.ts index 068b2d8..55a8440 100644 --- a/src/dashboard/router.ts +++ b/src/dashboard/router.ts @@ -53,8 +53,7 @@ import { createGraphHandler } from "./api/graph.js"; import { createSSEHandler } from "./sse.js"; import { getDashboardHtml } from "./page.js"; import { - recordTabViewed, - recordDashboardOpened, + session, recordGraphFeature, isTelemetryEnabled, } from "../telemetry/index.js"; @@ -101,7 +100,7 @@ export function createDashboardHandler( const url = new URL(req.url ?? "/", "http://localhost"); const tab = url.searchParams.get("tab"); if (tab && tab.length <= MAX_TAB_NAME_LENGTH && VALID_TABS.has(tab as DashboardView)) { - recordTabViewed(tab); + session.recordTabViewed(tab); } const event = url.searchParams.get("event"); if (event && event.length <= MAX_TAB_NAME_LENGTH) { @@ -122,7 +121,7 @@ export function createDashboardHandler( return; } - if (isTelemetryEnabled()) recordDashboardOpened(); + if (isTelemetryEnabled()) session.recordDashboardOpened(); res.writeHead(HTTP_OK, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", diff --git a/src/dashboard/styles/insights.ts b/src/dashboard/styles/insights.ts index 4824dfc..e7bbba2 100644 --- a/src/dashboard/styles/insights.ts +++ b/src/dashboard/styles/insights.ts @@ -8,13 +8,24 @@ export function getInsightsStyles(): string { .insights-chip-count{font-size:10px;font-family:var(--mono);background:rgba(0,0,0,.08);padding:1px 5px;border-radius:8px} .insights-chip.active .insights-chip-count{background:rgba(255,255,255,.25)} +/* Summary bar */ +.insights-summary{display:flex;gap:14px;padding:10px 28px;font-size:12px;font-weight:500;border-bottom:1px solid var(--border)} +.insights-summary-stat{display:flex;align-items:center;gap:4px} +.insights-summary-stat.critical{color:var(--red)} +.insights-summary-stat.warning{color:var(--amber)} +.insights-summary-stat.resolved{color:var(--green)} + +/* AI hint */ +.insights-ai-hint{font-size:11px;color:var(--text-muted);padding:0 0 12px} +.insights-ai-hint code{background:var(--bg-muted);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:11px} + /* Insights card list */ .insights-list{padding:16px 28px} .insights-empty{display:flex;align-items:center;gap:10px;padding:24px;color:var(--green);font-size:14px;font-weight:500} .insights-empty-icon{font-size:18px} -.insights-card{display:flex;align-items:flex-start;gap:12px;padding:14px 18px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .15s;margin-bottom:8px} +.insights-card{display:flex;align-items:flex-start;gap:12px;padding:12px 16px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .15s;margin-bottom:6px} .insights-card:hover{border-color:var(--border-light);box-shadow:0 2px 8px rgba(0,0,0,.04)} .insights-card.expanded{border-color:var(--border-light);box-shadow:0 2px 8px rgba(0,0,0,.04)} .insights-card.resolved{opacity:.55} diff --git a/src/detect/project.ts b/src/detect/project.ts index bf522ee..62d5049 100644 --- a/src/detect/project.ts +++ b/src/detect/project.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { join, relative } from "node:path"; import type { Framework, DetectedProject, DetectedPythonProject, PythonPackageManager } from "../types/index.js"; import { fileExists } from "../utils/fs.js"; +import { KNOWN_CONFIG_FILES } from "../constants/detection.js"; export type PackageManager = DetectedProject["packageManager"]; @@ -49,22 +50,11 @@ export async function detectProject( const devBin = matched ? join(rootDir, "node_modules", ".bin", matched.bin) : ""; const defaultPort = matched?.defaultPort ?? 3000; - const packageManager = await detectPackageManager(rootDir); + const packageManager = detectPackageManager(rootDir); return { framework, devCommand, devBin, defaultPort, packageManager }; } -async function detectPackageManager( - rootDir: string, -): Promise { - if (await fileExists(join(rootDir, "bun.lockb"))) return "bun"; - if (await fileExists(join(rootDir, "bun.lock"))) return "bun"; - if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm"; - if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn"; - if (await fileExists(join(rootDir, "package-lock.json"))) return "npm"; - return "unknown"; -} - /** Match framework from a merged dependencies object. */ export function detectFrameworkFromDeps(allDeps: Record): Framework { for (const f of FRAMEWORKS) { @@ -73,8 +63,16 @@ export function detectFrameworkFromDeps(allDeps: Record): Frame return "unknown"; } -/** Synchronous package manager detection via lock-file presence. */ -export function detectPackageManagerSync(rootDir: string): PackageManager { +export function detectConfigFiles(rootDir: string): string[] { + const found = new Set(); + for (const [file, label] of Object.entries(KNOWN_CONFIG_FILES)) { + if (existsSync(join(rootDir, file))) found.add(label); + } + return [...found]; +} + +/** Detect package manager from lock-file presence. */ +export function detectPackageManager(rootDir: string): PackageManager { if (existsSync(join(rootDir, "bun.lockb")) || existsSync(join(rootDir, "bun.lock"))) return "bun"; if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm"; if (existsSync(join(rootDir, "yarn.lock"))) return "yarn"; diff --git a/src/detect/runtime.ts b/src/detect/runtime.ts new file mode 100644 index 0000000..bb8e9bc --- /dev/null +++ b/src/detect/runtime.ts @@ -0,0 +1,41 @@ +import type { JsRuntime } from "../types/detection.js"; +import { NODE_MODULES_SEGMENT, KNOWN_DEPENDENCY_SET } from "../constants/detection.js"; + +export function detectJsRuntime(): JsRuntime { + if ((globalThis as Record).Bun) return "bun"; + if ((globalThis as Record).Deno) return "deno"; + return "node"; +} + +/** + * Scan require.cache for known packages that are actually loaded at runtime. + * Returns only names that match KNOWN_DEPENDENCY_SET — no private packages leak. + * Handles both Unix (/) and Windows (\) path separators. + */ +export function extractLoadedPackages(): string[] { + try { + const cache = require.cache; + if (!cache) return []; + + const seen = new Set(); + const segment = NODE_MODULES_SEGMENT; + const segmentWin = segment.replaceAll("/", "\\"); + + for (const key of Object.keys(cache)) { + const nm = Math.max(key.lastIndexOf(segment), key.lastIndexOf(segmentWin)); + if (nm === -1) continue; + + const after = key.slice(nm + segment.length); + const parts = after.split(/[/\\]/); + const pkgName = parts[0]!.startsWith("@") + ? `${parts[0]}/${parts[1]}` + : parts[0]!; + + if (KNOWN_DEPENDENCY_SET.has(pkgName)) seen.add(pkgName); + } + + return [...seen]; + } catch { + return []; + } +} diff --git a/src/output/terminal.ts b/src/output/terminal.ts index 7e84244..2e92f9a 100644 --- a/src/output/terminal.ts +++ b/src/output/terminal.ts @@ -1,19 +1,9 @@ import pc from "picocolors"; import { VERSION } from "../index.js"; -import { DASHBOARD_PREFIX } from "../constants/index.js"; -import { TERMINAL_TRUNCATE_LENGTH } from "../constants/config.js"; -import { SEVERITY_ICON } from "../constants/labels.js"; -import type { Severity } from "../types/security.js"; -import type { Issue } from "../types/issue-lifecycle.js"; +import { DASHBOARD_PREFIX, UNICODE_ARROW, UNICODE_EM_DASH, UNICODE_CHECK_MARK } from "../constants/index.js"; import type { Services } from "../core/services.js"; import type { AnalysisUpdate } from "../core/event-bus.js"; -const SEVERITY_COLOR: Record string> = { - critical: pc.red, - warning: pc.yellow, - info: pc.dim, -}; - function print(line: string): void { process.stdout.write(line + "\n"); } @@ -34,28 +24,8 @@ export function printBanner(proxyPort: number, targetPort: number): void { print(""); } -function severityIcon(severity: Severity): string { - return SEVERITY_COLOR[severity](SEVERITY_ICON[severity]); -} - -function colorTitle(severity: Severity, text: string): string { - const color = SEVERITY_COLOR[severity]; - return severity === "info" ? color(text) : color(pc.bold(text)); -} - -function truncate(s: string, max = TERMINAL_TRUNCATE_LENGTH): string { - return s.length <= max ? s : s.slice(0, max - 1) + "\u2026"; -} - -function formatConsoleLine(issue: Issue, suffix?: string): string { - const icon = severityIcon(issue.severity); - const title = colorTitle(issue.severity, issue.title); - const desc = pc.dim(truncate(issue.desc) + (suffix ?? "")); - let line = ` ${icon} ${title} \u2014 ${desc}`; - if (issue.detail) { - line += `\n ${pc.dim("\u2514 " + issue.detail)}`; - } - return line; +function pluralize(n: number, word: string): string { + return n === 1 ? `${n} ${word}` : `${n} ${word}s`; } export function startTerminalInsights( @@ -63,15 +33,16 @@ export function startTerminalInsights( proxyPort: number, ): () => void { const bus = services.bus; - const metricsStore = services.metricsStore; const printedKeys = new Set(); const resolvedKeys = new Set(); - const dashUrl = `localhost:${proxyPort}${DASHBOARD_PREFIX}`; + const dashUrl = `http://localhost:${proxyPort}${DASHBOARD_PREFIX}`; + const prefix = ` ${pc.magenta(pc.bold("brakit"))} ${pc.dim(UNICODE_ARROW)}`; + const actionHint = `${pc.underline(dashUrl)} ${pc.dim("or run")} ${pc.bold('"Fix brakit findings"')} ${pc.dim("in your AI tool")}`; return bus.on("analysis:updated", ({ issues }: AnalysisUpdate) => { - const newLines: string[] = []; - const resolvedLines: string[] = []; - const regressedLines: string[] = []; + let newCount = 0; + let resolvedCount = 0; + let regressedCount = 0; for (const si of issues) { if (si.aiStatus === "wont_fix") continue; @@ -80,9 +51,7 @@ export function startTerminalInsights( if (resolvedKeys.has(si.issueId)) continue; resolvedKeys.add(si.issueId); printedKeys.delete(si.issueId); - const title = pc.green(pc.bold(`\u2713 ${si.issue.title}`)); - const desc = pc.dim(truncate(si.issue.desc)); - resolvedLines.push(` ${title} \u2014 ${desc} ${pc.green("resolved")}`); + resolvedCount++; continue; } @@ -90,9 +59,7 @@ export function startTerminalInsights( if (!printedKeys.has(si.issueId)) { printedKeys.add(si.issueId); resolvedKeys.delete(si.issueId); - const title = pc.red(pc.bold(`\u26A0 ${si.issue.title}`)); - const desc = pc.dim(truncate(si.issue.desc)); - regressedLines.push(` ${title} \u2014 ${desc} ${pc.red("regressed")}`); + regressedCount++; } continue; } @@ -101,41 +68,22 @@ export function startTerminalInsights( if (si.issue.severity === "info") continue; if (printedKeys.has(si.issueId)) continue; printedKeys.add(si.issueId); - - let suffix: string | undefined; - if (si.issue.rule === "slow") { - const endpoint = si.issue.endpoint; - if (endpoint) { - const ep = metricsStore.getEndpoint(endpoint); - if (ep && ep.sessions.length > 1) { - const prev = ep.sessions[ep.sessions.length - 2]; - suffix = ` (\u2191 from ${prev.p95DurationMs < 1000 ? prev.p95DurationMs + "ms" : (prev.p95DurationMs / 1000).toFixed(1) + "s"})`; - } - } - } - - newLines.push(formatConsoleLine(si.issue, suffix)); + newCount++; } - if (newLines.length > 0) { + if (newCount > 0) { print(""); - for (const line of newLines) print(line); - print(""); - print(` ${pc.magenta(pc.bold("brakit"))} ${pc.dim("\u2192")} ${pc.dim("Dashboard:")} ${pc.underline(`http://${dashUrl}`)} ${pc.dim("or ask your AI:")} ${pc.bold('"Fix brakit findings"')}`); + print(`${prefix} ${pc.yellow(pluralize(newCount, "new issue"))} ${pc.dim(UNICODE_EM_DASH)} ${actionHint}`); } - if (regressedLines.length > 0) { - print(""); - for (const line of regressedLines) print(line); + if (regressedCount > 0) { print(""); - print(` ${pc.magenta(pc.bold("brakit"))} ${pc.dim("\u2192")} ${pc.red("Issues came back after being resolved!")}`); + print(`${prefix} ${pc.red(pluralize(regressedCount, "issue") + " regressed")} ${pc.dim(UNICODE_EM_DASH)} ${actionHint}`); } - if (resolvedLines.length > 0) { - print(""); - for (const line of resolvedLines) print(line); + if (resolvedCount > 0) { print(""); - print(` ${pc.magenta(pc.bold("brakit"))} ${pc.dim("\u2192")} ${pc.green("Issues fixed!")}`); + print(`${prefix} ${pc.green(pluralize(resolvedCount, "issue") + " resolved " + UNICODE_CHECK_MARK)}`); } }); } diff --git a/src/runtime/setup.ts b/src/runtime/setup.ts index 44895b6..6f2179c 100644 --- a/src/runtime/setup.ts +++ b/src/runtime/setup.ts @@ -5,7 +5,7 @@ import { setupErrorHook } from "../instrument/hooks/errors.js"; import { createDefaultRegistry } from "../instrument/adapters/index.js"; import { createDashboardHandler } from "../dashboard/router.js"; import { readFile, mkdir, writeFile } from "node:fs/promises"; -import { existsSync, unlinkSync } from "node:fs"; +import { existsSync, unlinkSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { EventBus } from "../core/event-bus.js"; import type { Services } from "../core/services.js"; @@ -22,17 +22,18 @@ import { DEFAULT_MAX_BODY_CAPTURE, METRICS_DIR, PORT_FILE, - KNOWN_DEPENDENCY_NAMES, EXIT_REASON_SIGINT, EXIT_REASON_SIGTERM, EXIT_REASON_CLEAN, TELEMETRY_EVENT_SETUP_COMPLETED, TELEMETRY_EVENT_FIRST_REQUEST, } from "../constants/index.js"; +import { KNOWN_DEPENDENCY_NAMES, INSTALLED_HOOKS } from "../constants/detection.js"; import type { TelemetryEvent, BrakitConfig, Framework, + StackDetectionResult, TracedFetch, TracedLog, TracedError, @@ -46,19 +47,11 @@ import { getErrorMessage } from "../utils/type-guards.js"; import { getProjectDataDir } from "../utils/fs.js"; import { detectFrameworkFromDeps, - detectPackageManagerSync, + detectPackageManager, + detectConfigFiles, } from "../detect/project.js"; -import { - initSession, - trackSession, - trackEvent, - recordRequestCount, - recordInsightTypes, - recordRulesTriggered, - recordSetupCompleted, - recordFirstRequest, - recordExitReason, -} from "../telemetry/index.js"; +import { detectJsRuntime, extractLoadedPackages } from "../detect/runtime.js"; +import { session, flushSession, trackEvent } from "../telemetry/index.js"; let initPromise: Promise | null = null; @@ -97,12 +90,7 @@ function createStores(bus: EventBus): Stores { /* Phase 2 — Install instrumentation hooks */ -function installHooks(bus: EventBus): { - framework: Framework; - adapterNames: string[]; - adaptersFailed: string[]; - frameworkCandidates: string[]; -} { +function installHooks(bus: EventBus): StackDetectionResult { const telemetryEmit = (event: TelemetryEvent): void => { const channel = `telemetry:${event.type}` as keyof ChannelMap; bus.emit(channel, event.data as ChannelMap[typeof channel]); @@ -118,24 +106,27 @@ function installHooks(bus: EventBus): { const cwd = process.cwd(); let framework: Framework = "unknown"; - let frameworkCandidates: string[] = []; + let detectedDependencies: string[] = []; + let configFilesDetected: string[] = []; try { const pkg = JSON.parse( - require("node:fs").readFileSync(resolve(cwd, "package.json"), "utf-8"), + readFileSync(resolve(cwd, "package.json"), "utf-8"), ); const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; framework = detectFrameworkFromDeps(allDeps); - // Capture which deps were found for telemetry diagnostics (no PII — just package names) - frameworkCandidates = KNOWN_DEPENDENCY_NAMES.filter((dep) => dep in allDeps); + detectedDependencies = KNOWN_DEPENDENCY_NAMES.filter((dep) => dep in allDeps); + configFilesDetected = detectConfigFiles(cwd); } catch { - /* no package.json */ + /* no package.json or bundler blocked fs access */ } return { framework, adapterNames: adapterRegistry.getActive().map((a) => a.name), adaptersFailed: [...adapterRegistry.getFailed()], - frameworkCandidates, + detectedDependencies, + configFilesDetected, + jsRuntime: detectJsRuntime(), }; } @@ -192,14 +183,12 @@ function registerLifecycle( const sendTelemetry = (): void => { if (telemetrySent) return; telemetrySent = true; - recordRequestCount(stores.requestStore.getAll().length); - recordInsightTypes( + session.recordCounts( + stores.requestStore.getAll().length, services.analysisEngine.getInsights().map((i) => i.type), - ); - recordRulesTriggered( services.analysisEngine.getFindings().map((f) => f.rule), ); - trackSession(allServices); + flushSession(allServices); }; let teardownCalled = false; @@ -223,11 +212,11 @@ function registerLifecycle( health.setTeardown(runTeardown); - process.on("SIGINT", () => { recordExitReason(EXIT_REASON_SIGINT); }); - process.on("SIGTERM", () => { recordExitReason(EXIT_REASON_SIGTERM); }); + process.on("SIGINT", () => { session.recordExitReason(EXIT_REASON_SIGINT); }); + process.on("SIGTERM", () => { session.recordExitReason(EXIT_REASON_SIGTERM); }); process.on("beforeExit", async () => { await drainPendingCaptures(); - recordExitReason(EXIT_REASON_CLEAN); + session.recordExitReason(EXIT_REASON_CLEAN); sendTelemetry(); }); process.on("exit", () => { @@ -253,20 +242,22 @@ async function doSetup(): Promise { } as Services; // Phase 2 — instrumentation hooks - const { framework, adapterNames, adaptersFailed, frameworkCandidates } = installHooks(bus); + const detection = installHooks(bus); - initSession(framework, detectPackageManagerSync(cwd), false, adapterNames); + session.init(detection.framework, detectPackageManager(cwd), false, detection.adapterNames); const setupDurationMs = Date.now() - setupStart; - recordSetupCompleted({ frameworkCandidates, adaptersFailed, setupDurationMs }); + session.recordSetup(detection, setupDurationMs); trackEvent(TELEMETRY_EVENT_SETUP_COMPLETED, { - framework, - framework_detection_candidates: frameworkCandidates, - adapters_detected: adapterNames, - adapters_failed: adaptersFailed, - hooks_installed: ["fetch", "console", "error"], + framework: detection.framework, + framework_detection_candidates: detection.detectedDependencies, + adapters_detected: detection.adapterNames, + adapters_failed: detection.adaptersFailed, + hooks_installed: [...INSTALLED_HOOKS], setup_duration_ms: setupDurationMs, + config_files_detected: detection.configFilesDetected, + js_runtime: detection.jsRuntime, }); // Phase 3 — graph builder @@ -295,10 +286,15 @@ async function doSetup(): Promise { onFirstRequest(port) { setBrakitPort(port); brakitDebug(`[setup] onFirstRequest fired, port=${port}`); - recordFirstRequest(); + + const loadedPackages = extractLoadedPackages(); + session.recordFirstRequest(loadedPackages); + trackEvent(TELEMETRY_EVENT_FIRST_REQUEST, { port, time_to_first_request_ms: Date.now() - setupStart, + loaded_packages: loadedPackages, + loaded_package_count: loadedPackages.length, }); void (async () => { diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index fbe58ee..dd8cd6e 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -14,10 +14,21 @@ export interface TelemetryConfig { const CONFIG_DIR = join(homedir(), ".brakit"); const CONFIG_PATH = join(CONFIG_DIR, "config.json"); +function isValidTelemetryConfig(value: unknown): value is TelemetryConfig { + return ( + typeof value === "object" && + value !== null && + typeof (value as TelemetryConfig).telemetry === "boolean" && + typeof (value as TelemetryConfig).anonymousId === "string" && + (value as TelemetryConfig).anonymousId.length > 0 + ); +} + export function readConfig(): TelemetryConfig | null { try { if (!existsSync(CONFIG_PATH)) return null; - return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as TelemetryConfig; + const parsed: unknown = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); + return isValidTelemetryConfig(parsed) ? parsed : null; } catch { return null; } @@ -42,9 +53,7 @@ export function writeConfig(config: TelemetryConfig): void { export function getOrCreateConfig(): TelemetryConfig { const existing = readConfig(); - if (existing && typeof existing.telemetry === "boolean" && existing.anonymousId) { - return existing; - } + if (existing) return existing; const config: TelemetryConfig = { telemetry: true, anonymousId: randomUUID() }; writeConfig(config); return config; @@ -56,7 +65,8 @@ export function isTelemetryEnabled(): boolean { if (cachedEnabled !== null) return cachedEnabled; const env = process.env.BRAKIT_TELEMETRY; if (env !== undefined) { - cachedEnabled = env !== "false" && env !== "0" && env !== "off"; + const normalized = env.toLowerCase().trim(); + cachedEnabled = normalized !== "false" && normalized !== "0" && normalized !== "off"; return cachedEnabled; } cachedEnabled = readConfig()?.telemetry ?? true; diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 350cb94..f6e2ceb 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -1,173 +1,15 @@ -import { platform, release, arch } from "node:os"; -import { spawn } from "node:child_process"; -import { VERSION } from "../index.js"; -import type { Services } from "../core/services.js"; -import { readConfig, getOrCreateConfig, isTelemetryEnabled } from "./config.js"; -import { - POSTHOG_HOST, - POSTHOG_CAPTURE_PATH, - POSTHOG_REQUEST_TIMEOUT_MS, - SPEED_BUCKET_THRESHOLDS, -} from "../constants/labels.js"; -import { - TELEMETRY_EVENT_DASHBOARD_VIEWED, - TELEMETRY_EVENT_SESSION, - TELEMETRY_EVENT_GRAPH_FEATURE, -} from "../constants/config.js"; - export { isTelemetryEnabled, setTelemetryEnabled } from "./config.js"; +export { trackEvent } from "./transport.js"; +export { Session } from "./session.js"; +export type { PythonStackInfo } from "./session.js"; -const POSTHOG_KEY: string = process.env.POSTHOG_API_KEY ?? ""; - -// Common properties shared across all events - -function commonProperties(): Record { - return { - brakit_version: VERSION, - node_version: process.version, - os: `${platform()}-${release()}`, - arch: arch(), - $lib: "brakit", - $process_person_profile: false, - $geoip_disable: true, - }; -} - -// Lightweight event tracking — fire-and-forget via detached child process - -function sendToPosthog( - event: string, - properties: Record, -): void { - if (!isTelemetryEnabled()) return; - - const config = getOrCreateConfig(); - const payload = { - api_key: POSTHOG_KEY, - event, - distinct_id: config.anonymousId, - timestamp: new Date().toISOString(), - properties: { ...commonProperties(), ...properties }, - }; - - try { - const body = JSON.stringify(payload); - const url = `${POSTHOG_HOST}${POSTHOG_CAPTURE_PATH}`; - const child = spawn( - process.execPath, - [ - "-e", - `fetch(${JSON.stringify(url)},{method:"POST",headers:{"content-type":"application/json"},body:${JSON.stringify(body)},signal:AbortSignal.timeout(${POSTHOG_REQUEST_TIMEOUT_MS})}).catch(()=>{})`, - ], - { detached: true, stdio: "ignore" }, - ); - child.unref(); - } catch { - /* non-critical */ - } -} - -/** - * Track a lightweight event. Use for journey milestones (cli_invoked, - * setup_completed, first_request, dashboard_viewed, cli_uninstall). - * Never blocks the host app. - */ -export function trackEvent( - event: string, - properties: Record, -): void { - sendToPosthog(event, { sdk: "node", ...properties }); -} - -// Session state — accumulated during the session, sent on exit - -interface SessionState { - startTime: number; - framework: string; - packageManager: string; - isCustomCommand: boolean; - adapters: string[]; - requestCount: number; - insightTypes: Set; - rulesTriggered: Set; - tabsViewed: Set; - dashboardOpened: boolean; - explainUsed: boolean; - frameworkCandidates: string[]; - adaptersFailed: string[]; - setupDurationMs: number; - setupSucceeded: boolean; - firstRequestAt: number; - dashboardOpenedAt: number; - exitReason: string; -} - -const session: SessionState = { - startTime: 0, - framework: "", - packageManager: "", - isCustomCommand: false, - adapters: [], - requestCount: 0, - insightTypes: new Set(), - rulesTriggered: new Set(), - tabsViewed: new Set(), - dashboardOpened: false, - explainUsed: false, - frameworkCandidates: [], - adaptersFailed: [], - setupDurationMs: 0, - setupSucceeded: false, - firstRequestAt: 0, - dashboardOpenedAt: 0, - exitReason: "unknown", -}; - -export function initSession( - framework: string, - packageManager: string, - isCustomCommand: boolean, - adapters: string[], -): void { - getOrCreateConfig(); - - session.startTime = Date.now(); - session.framework = framework; - session.packageManager = packageManager; - session.isCustomCommand = isCustomCommand; - session.adapters = adapters; -} - -export function recordRequestCount(count: number): void { - session.requestCount = count; -} - -export function recordInsightTypes(types: string[]): void { - for (const t of types) session.insightTypes.add(t); -} - -export function recordRulesTriggered(rules: string[]): void { - for (const r of rules) session.rulesTriggered.add(r); -} - -export function recordTabViewed(tab: string): void { - session.tabsViewed.add(tab); -} - -export function recordDashboardOpened(): void { - if (session.dashboardOpened) return; // Only track first open - session.dashboardOpened = true; - session.dashboardOpenedAt = Date.now(); - trackEvent(TELEMETRY_EVENT_DASHBOARD_VIEWED, { - time_to_dashboard_ms: - session.startTime > 0 ? Date.now() - session.startTime : null, - request_count_at_open: session.requestCount, - }); -} +import { Session } from "./session.js"; +import { TELEMETRY_EVENT_GRAPH_FEATURE } from "../constants/telemetry.js"; +import { isTelemetryEnabled } from "./config.js"; +import { trackEvent } from "./transport.js"; -export function recordExplainUsed(): void { - session.explainUsed = true; -} +/** Singleton session instance. Created once per process. */ +export const session = new Session(); export function recordGraphFeature(feature: string, detail?: string): void { trackEvent(TELEMETRY_EVENT_GRAPH_FEATURE, { @@ -176,95 +18,7 @@ export function recordGraphFeature(feature: string, detail?: string): void { }); } -export function recordSetupCompleted(info: { - frameworkCandidates: string[]; - adaptersFailed: string[]; - setupDurationMs: number; -}): void { - session.frameworkCandidates = info.frameworkCandidates; - session.adaptersFailed = info.adaptersFailed; - session.setupDurationMs = info.setupDurationMs; - session.setupSucceeded = true; -} - -export function recordFirstRequest(): void { - if (!session.firstRequestAt) session.firstRequestAt = Date.now(); -} - -export function recordExitReason(reason: string): void { - if (session.exitReason === "unknown") session.exitReason = reason; -} - -// Session event — fired on process exit with full session summary - -function speedBucket(ms: number): string { - if (ms === 0) return "none"; - const t = SPEED_BUCKET_THRESHOLDS; - if (ms < t[0]) return `<${t[0]}ms`; - for (let i = 1; i < t.length; i++) { - if (ms < t[i]) return `${t[i - 1]}-${t[i]}ms`; - } - return `>${t[t.length - 1]}ms`; -} - -export function trackSession(services: Services): void { +export function flushSession(services: Parameters[0]): void { if (!isTelemetryEnabled()) return; - - const isFirstSession = readConfig() === null; - const metricsStore = services.metricsStore; - const analysisEngine = services.analysisEngine; - const live = metricsStore.getLiveEndpoints(); - const insights = analysisEngine.getInsights(); - const findings = analysisEngine.getFindings(); - - let totalRequests = 0; - let totalDuration = 0; - let slowestP95 = 0; - - for (const ep of live) { - totalRequests += ep.summary.totalRequests; - totalDuration += ep.summary.p95Ms * ep.summary.totalRequests; - if (ep.summary.p95Ms > slowestP95) slowestP95 = ep.summary.p95Ms; - } - - const now = Date.now(); - sendToPosthog(TELEMETRY_EVENT_SESSION, { - sdk: "node", - framework: session.framework, - package_manager: session.packageManager, - is_custom_command: session.isCustomCommand, - first_session: isFirstSession, - adapters_detected: session.adapters, - request_count: session.requestCount, - error_count: services.errorStore.getAll().length, - query_count: services.queryStore.getAll().length, - fetch_count: services.fetchStore.getAll().length, - insight_count: insights.length, - finding_count: findings.length, - insight_types: [...session.insightTypes], - rules_triggered: [...session.rulesTriggered], - endpoint_count: live.length, - avg_duration_ms: - totalRequests > 0 ? Math.round(totalDuration / totalRequests) : 0, - slowest_endpoint_bucket: speedBucket(slowestP95), - tabs_viewed: [...session.tabsViewed], - dashboard_opened: session.dashboardOpened, - explain_used: session.explainUsed, - session_duration_s: Math.ceil((now - session.startTime) / 1000), - session_duration_ms: now - session.startTime, - setup_succeeded: session.setupSucceeded, - setup_duration_ms: session.setupDurationMs, - framework_detection_candidates: session.frameworkCandidates, - adapters_failed: session.adaptersFailed, - time_to_first_request_ms: session.firstRequestAt - ? session.firstRequestAt - session.startTime - : null, - time_to_dashboard_ms: session.dashboardOpenedAt - ? session.dashboardOpenedAt - session.startTime - : null, - exit_reason: session.exitReason, - }); - - // Ensure config file exists (creates anonymousId if needed) - getOrCreateConfig(); + session.flush(services); } diff --git a/src/telemetry/session.ts b/src/telemetry/session.ts new file mode 100644 index 0000000..f14688b --- /dev/null +++ b/src/telemetry/session.ts @@ -0,0 +1,229 @@ +import type { Services } from "../core/services.js"; +import type { Framework, JsRuntime, ExitReason, StackDetectionResult } from "../types/index.js"; +import { + SPEED_BUCKET_THRESHOLDS, + TELEMETRY_EVENT_SESSION, + TELEMETRY_EVENT_DASHBOARD_VIEWED, + TELEMETRY_SDK_NAME, +} from "../constants/telemetry.js"; +import { readConfig, getOrCreateConfig } from "./config.js"; +import { sendToPosthog, trackEvent } from "./transport.js"; + +export interface TelemetryTransport { + send: typeof sendToPosthog; + track: typeof trackEvent; +} + +export interface PythonStackInfo { + framework: string; + adapters: string[]; + pythonVersion: string; +} + +const defaultTransport: TelemetryTransport = { send: sendToPosthog, track: trackEvent }; + +export class Session { + private readonly transport: TelemetryTransport; + + constructor(transport: TelemetryTransport = defaultTransport) { + this.transport = transport; + } + + // ── Setup phase ── + private startTime = 0; + private framework: Framework = "unknown"; + private packageManager = ""; + private isCustomCommand = false; + private adapters: string[] = []; + private setupDurationMs = 0; + private setupSucceeded = false; + + // ── Detection ── + private detectedDependencies: string[] = []; + private adaptersFailed: string[] = []; + private configFilesDetected: string[] = []; + private jsRuntime: JsRuntime = "node"; + private loadedPackages: string[] = []; + + // ── Python SDK ── + private pythonConnected = false; + private pythonFramework = "unknown"; + private pythonAdapters: string[] = []; + private pythonVersion = ""; + + // ── Runtime accumulation ── + private requestCount = 0; + private insightTypes = new Set(); + private rulesTriggered = new Set(); + private tabsViewed = new Set(); + private dashboardOpened = false; + private explainUsed = false; + private firstRequestAt = 0; + private dashboardOpenedAt = 0; + private exitReason: ExitReason = "unknown"; + + // ── Setup phase ── + + init( + framework: Framework, + packageManager: string, + isCustomCommand: boolean, + adapters: string[], + ): void { + getOrCreateConfig(); + this.startTime = Date.now(); + this.framework = framework; + this.packageManager = packageManager; + this.isCustomCommand = isCustomCommand; + this.adapters = adapters; + } + + recordSetup(detection: StackDetectionResult, durationMs: number): void { + this.detectedDependencies = detection.detectedDependencies; + this.adaptersFailed = detection.adaptersFailed; + this.configFilesDetected = detection.configFilesDetected; + this.jsRuntime = detection.jsRuntime; + this.setupDurationMs = durationMs; + this.setupSucceeded = true; + } + + // ── Runtime events ── + + recordFirstRequest(loadedPackages: string[]): void { + if (!this.firstRequestAt) this.firstRequestAt = Date.now(); + this.loadedPackages = loadedPackages; + } + + recordPythonStack(info: PythonStackInfo): void { + this.pythonConnected = true; + this.pythonFramework = info.framework; + this.pythonAdapters = info.adapters; + this.pythonVersion = info.pythonVersion; + } + + recordDashboardOpened(): void { + if (this.dashboardOpened) return; + this.dashboardOpened = true; + this.dashboardOpenedAt = Date.now(); + this.transport.track(TELEMETRY_EVENT_DASHBOARD_VIEWED, { + time_to_dashboard_ms: + this.startTime > 0 ? Date.now() - this.startTime : null, + request_count_at_open: this.requestCount, + }); + } + + recordTabViewed(tab: string): void { + this.tabsViewed.add(tab); + } + + recordExplainUsed(): void { + this.explainUsed = true; + } + + recordExitReason(reason: ExitReason): void { + if (this.exitReason === "unknown") this.exitReason = reason; + } + + // ── Pre-flush snapshot from services ── + + recordCounts(requestCount: number, insightTypes: string[], rulesTriggered: string[]): void { + this.requestCount = requestCount; + for (const t of insightTypes) this.insightTypes.add(t); + for (const r of rulesTriggered) this.rulesTriggered.add(r); + } + + // ── Serialization ── + + /** Build the full PostHog session payload. Single source of truth for all fields. */ + toPostHogPayload(services: Services): Record { + const metricsStore = services.metricsStore; + const analysisEngine = services.analysisEngine; + const live = metricsStore.getLiveEndpoints(); + const insights = analysisEngine.getInsights(); + const findings = analysisEngine.getFindings(); + + let totalRequests = 0; + let totalDuration = 0; + let slowestP95 = 0; + for (const ep of live) { + totalRequests += ep.summary.totalRequests; + totalDuration += ep.summary.p95Ms * ep.summary.totalRequests; + if (ep.summary.p95Ms > slowestP95) slowestP95 = ep.summary.p95Ms; + } + + const now = Date.now(); + return { + sdk: TELEMETRY_SDK_NAME, + + // Stack detection + framework: this.framework, + package_manager: this.packageManager, + js_runtime: this.jsRuntime, + framework_detection_candidates: this.detectedDependencies, + config_files_detected: this.configFilesDetected, + loaded_packages: this.loadedPackages, + loaded_package_count: this.loadedPackages.length, + adapters_detected: this.adapters, + adapters_failed: this.adaptersFailed, + + // Python SDK + python_connected: this.pythonConnected, + python_framework: this.pythonFramework, + python_adapters: this.pythonAdapters, + python_version: this.pythonVersion, + + // Session metadata + is_custom_command: this.isCustomCommand, + first_session: readConfig() === null, + setup_succeeded: this.setupSucceeded, + setup_duration_ms: this.setupDurationMs, + session_duration_s: Math.ceil((now - this.startTime) / 1000), + session_duration_ms: now - this.startTime, + exit_reason: this.exitReason, + + // Usage + request_count: this.requestCount, + error_count: services.errorStore.getAll().length, + query_count: services.queryStore.getAll().length, + fetch_count: services.fetchStore.getAll().length, + endpoint_count: live.length, + avg_duration_ms: + totalRequests > 0 ? Math.round(totalDuration / totalRequests) : 0, + slowest_endpoint_bucket: speedBucket(slowestP95), + + // Analysis + insight_count: insights.length, + finding_count: findings.length, + insight_types: [...this.insightTypes], + rules_triggered: [...this.rulesTriggered], + + // Dashboard engagement + tabs_viewed: [...this.tabsViewed], + dashboard_opened: this.dashboardOpened, + explain_used: this.explainUsed, + + // Timing + time_to_first_request_ms: this.firstRequestAt + ? this.firstRequestAt - this.startTime + : null, + time_to_dashboard_ms: this.dashboardOpenedAt + ? this.dashboardOpenedAt - this.startTime + : null, + }; + } + + flush(services: Services): void { + this.transport.send(TELEMETRY_EVENT_SESSION, this.toPostHogPayload(services)); + getOrCreateConfig(); + } +} + +export function speedBucket(ms: number): string { + if (ms === 0) return "none"; + const t = SPEED_BUCKET_THRESHOLDS; + if (ms < t[0]) return `<${t[0]}ms`; + for (let i = 1; i < t.length; i++) { + if (ms < t[i]) return `${t[i - 1]}-${t[i]}ms`; + } + return `>${t[t.length - 1]}ms`; +} diff --git a/src/telemetry/transport.ts b/src/telemetry/transport.ts new file mode 100644 index 0000000..b6b8416 --- /dev/null +++ b/src/telemetry/transport.ts @@ -0,0 +1,67 @@ +import { platform, release, arch } from "node:os"; +import { spawn } from "node:child_process"; +import { VERSION } from "../index.js"; +import { + POSTHOG_HOST, + POSTHOG_CAPTURE_PATH, + POSTHOG_REQUEST_TIMEOUT_MS, + TELEMETRY_SDK_NAME, +} from "../constants/telemetry.js"; +import { getOrCreateConfig, isTelemetryEnabled } from "./config.js"; +import { brakitDebug } from "../utils/log.js"; + +const POSTHOG_KEY: string = process.env.POSTHOG_API_KEY ?? ""; + +function commonProperties(): Record { + return { + brakit_version: VERSION, + node_version: process.version, + os: `${platform()}-${release()}`, + arch: arch(), + $lib: "brakit", + $process_person_profile: false, + $geoip_disable: true, + }; +} + +/** Fire-and-forget event delivery via detached child process. Never blocks the host app. */ +export function sendToPosthog( + event: string, + properties: Record, +): void { + if (!isTelemetryEnabled() || !POSTHOG_KEY) return; + + const config = getOrCreateConfig(); + const payload = { + api_key: POSTHOG_KEY, + event, + distinct_id: config.anonymousId, + timestamp: new Date().toISOString(), + properties: { ...commonProperties(), ...properties }, + }; + + try { + const serializedPayload = JSON.stringify(payload); + const url = `${POSTHOG_HOST}${POSTHOG_CAPTURE_PATH}`; + // Safety: JSON.stringify guarantees valid string literals — url is a constant and + // serializedPayload contains only JSON-safe characters, so eval injection is not possible. + const child = spawn( + process.execPath, + [ + "-e", + `fetch(${JSON.stringify(url)},{method:"POST",headers:{"content-type":"application/json"},body:${JSON.stringify(serializedPayload)},signal:AbortSignal.timeout(${POSTHOG_REQUEST_TIMEOUT_MS})}).catch(()=>{})`, + ], + { detached: true, stdio: "ignore" }, + ); + child.unref(); + } catch (err) { + brakitDebug(`telemetry send failed: ${err}`); + } +} + +export function trackEvent( + event: string, + properties: Record, +): void { + sendToPosthog(event, { sdk: TELEMETRY_SDK_NAME, ...properties }); +} diff --git a/src/types/api-contracts.ts b/src/types/api-contracts.ts index 86e32c6..61a7c42 100644 --- a/src/types/api-contracts.ts +++ b/src/types/api-contracts.ts @@ -58,7 +58,7 @@ export interface LiveMetricsResponse { endpoints: LiveEndpointData[]; } -export type SDKEventType = "request" | "db.query" | "fetch" | "log" | "error" | "auth.check"; +export type SDKEventType = "request" | "db.query" | "fetch" | "log" | "error" | "auth.check" | "sdk.hello"; export interface SDKEvent { type: SDKEventType; diff --git a/src/types/detection.ts b/src/types/detection.ts new file mode 100644 index 0000000..518eae5 --- /dev/null +++ b/src/types/detection.ts @@ -0,0 +1,16 @@ +import type { Framework } from "./config.js"; + +export type JsRuntime = "node" | "bun" | "deno"; + +export type ExitReason = "clean" | "sigint" | "sigterm" | "unknown"; + +export type HookName = "fetch" | "console" | "error"; + +export interface StackDetectionResult { + framework: Framework; + detectedDependencies: string[]; + configFilesDetected: string[]; + jsRuntime: JsRuntime; + adapterNames: string[]; + adaptersFailed: string[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7f57ed4..d283ae8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,3 +58,10 @@ export type { StatefulIssue, IssuesData, } from "./issue-lifecycle.js"; + +export type { + JsRuntime, + ExitReason, + HookName, + StackDetectionResult, +} from "./detection.js"; diff --git a/tests/output/terminal.test.ts b/tests/output/terminal.test.ts index 43478be..71cfbfc 100644 --- a/tests/output/terminal.test.ts +++ b/tests/output/terminal.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; import { printBanner, startTerminalInsights } from "../../src/output/terminal.js"; import { makeInsight, makeStatefulIssue, makeAnalysisUpdate } from "../helpers/factories.js"; import { EventBus } from "../../src/core/event-bus.js"; import type { Services } from "../../src/core/services.js"; +import { DASHBOARD_PREFIX } from "../../src/constants/index.js"; describe("printBanner", () => { it("prints proxy, target, and dashboard URLs", () => { @@ -11,14 +12,13 @@ describe("printBanner", () => { const output = spy.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("localhost:3000"); expect(output).toContain("localhost:3001"); - expect(output).toContain("/__brakit"); + expect(output).toContain(DASHBOARD_PREFIX); spy.mockRestore(); }); }); describe("startTerminalInsights", () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let spy: any; + let spy: MockInstance; let bus: EventBus; let services: Services; @@ -36,7 +36,7 @@ describe("startTerminalInsights", () => { start: vi.fn(), stop: vi.fn(), }, - } as any; + } as unknown as Services; }); afterEach(() => { @@ -47,12 +47,15 @@ describe("startTerminalInsights", () => { return spy.mock.calls.map((c: unknown[]) => c[0]).join(""); } - it("prints warning and critical issues to stdout", () => { + it("prints summary line for new issues", () => { const dispose = startTerminalInsights(services, 3000); const issues = [makeStatefulIssue({ severity: "warning", title: "Slow Endpoint" })]; bus.emit("analysis:updated", makeAnalysisUpdate([makeInsight({ severity: "warning", title: "Slow Endpoint" })], [], issues)); const output = getOutput(); - expect(output).toContain("Slow Endpoint"); + expect(output).toContain("1 new issue"); + expect(output).toContain(DASHBOARD_PREFIX); + expect(output).toContain("Fix brakit findings"); + expect(output).not.toContain("Slow Endpoint"); dispose(); }); @@ -71,7 +74,7 @@ describe("startTerminalInsights", () => { bus.emit("analysis:updated", update); const firstOutput = getOutput(); - expect(firstOutput).toContain("Slow Endpoint"); + expect(firstOutput).toContain("1 new issue"); spy.mockClear(); bus.emit("analysis:updated", update); @@ -85,4 +88,45 @@ describe("startTerminalInsights", () => { expect(getOutput()).toBe(""); dispose(); }); + + it("summarizes multiple issues as a count", () => { + const dispose = startTerminalInsights(services, 3000); + const issues = [ + makeStatefulIssue({ severity: "warning", title: "Issue A", rule: "rule-a" }), + makeStatefulIssue({ severity: "critical", title: "Issue B", rule: "rule-b" }), + makeStatefulIssue({ severity: "warning", title: "Issue C", rule: "rule-c" }), + ]; + bus.emit("analysis:updated", makeAnalysisUpdate([], [], issues)); + const output = getOutput(); + expect(output).toContain("3 new issues"); + expect(output).not.toContain("Issue A"); + expect(output).not.toContain("Issue B"); + expect(output).not.toContain("Issue C"); + dispose(); + }); + + it("shows compact resolved summary", () => { + const dispose = startTerminalInsights(services, 3000); + const issues = [ + makeStatefulIssue({ severity: "warning", title: "Fixed" }, { state: "resolved" }), + makeStatefulIssue({ severity: "warning", title: "Fixed 2", rule: "rule-2" }, { state: "resolved" }), + ]; + bus.emit("analysis:updated", makeAnalysisUpdate([], [], issues)); + const output = getOutput(); + expect(output).toContain("2 issues resolved"); + expect(output).not.toContain("Fixed"); + dispose(); + }); + + it("shows compact regressed summary", () => { + const dispose = startTerminalInsights(services, 3000); + const issues = [ + makeStatefulIssue({ severity: "warning", title: "Regressed" }, { state: "regressed" }), + ]; + bus.emit("analysis:updated", makeAnalysisUpdate([], [], issues)); + const output = getOutput(); + expect(output).toContain("1 issue regressed"); + expect(output).toContain(DASHBOARD_PREFIX); + dispose(); + }); });