Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion bin/brakit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { runMain } from "citty";
import { existsSync } from "node:fs";
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";

const sub = process.argv[2];
const command = sub === "uninstall" ? "uninstall" : sub === "mcp" ? "mcp" : "install";
const cwd = process.cwd();

trackEvent(TELEMETRY_EVENT_CLI_INVOKED, {
command,
has_package_json: existsSync(resolve(cwd, "package.json")),
cwd_has_node_modules: existsSync(resolve(cwd, "node_modules")),
});

if (sub === "uninstall") {
process.argv.splice(2, 1);
Expand All @@ -15,7 +27,6 @@ if (sub === "uninstall") {
process.exitCode = 1;
});
} else {
// `npx brakit` and `npx brakit install` both run install
if (sub === "install") process.argv.splice(2, 1);
runMain(installCommand);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "brakit",
"version": "0.9.0",
"version": "0.9.1",
"description": "See what your API is really doing. Security scanning, N+1 detection, duplicate calls, DB queries — one command, zero config.",
"type": "module",
"bin": {
Expand Down
13 changes: 10 additions & 3 deletions sdks/python/brakit/_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ def _auto_setup() -> None:
_install_hooks(registry)
adapters = _install_adapters(registry)
logger.debug("adapters: %s", adapters)

_start_transport(registry)
_install_frameworks(registry)
detected_framework = _install_frameworks(registry)

# Initialize telemetry after framework detection
from brakit._telemetry import init_session as _init_telemetry
_init_telemetry(framework=detected_framework, adapters=adapters)

logger.debug("initialized")

Expand Down Expand Up @@ -114,7 +119,9 @@ def _retry() -> None:

def _setup_forwarder(registry: "ServiceRegistry", port: int) -> None:
from brakit.transport.forwarder import Forwarder
from brakit._telemetry import record_node_connected

record_node_connected()
forwarder = Forwarder(port=port)
forwarder.start()

Expand All @@ -127,10 +134,10 @@ def _setup_forwarder(registry: "ServiceRegistry", port: int) -> None:
logger.debug("transport ready on port %d", port)


def _install_frameworks(registry: "ServiceRegistry") -> None:
def _install_frameworks(registry: "ServiceRegistry") -> str:
from brakit.frameworks import detect_and_patch as detect_frameworks

detect_frameworks(registry)
return detect_frameworks(registry)


def _forward_request(forwarder: "Forwarder", request: TracedRequest) -> None:
Expand Down
136 changes: 136 additions & 0 deletions sdks/python/brakit/_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Lightweight anonymous telemetry for the Python SDK.

Sends a single 'session' event to PostHog on process exit. No PII is collected —
only framework names, adapter names, counts, and durations.

Disabled via BRAKIT_TELEMETRY=false environment variable.
"""
from __future__ import annotations

import atexit
import json
import os
import platform
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any

_POSTHOG_HOST = "https://us.i.posthog.com"
_POSTHOG_PATH = "/i/v0/e/"
_POSTHOG_KEY = os.environ.get("POSTHOG_API_KEY", "")
_TIMEOUT_MS = 3000
_CONFIG_DIR = Path.home() / ".brakit"
_CONFIG_FILE = _CONFIG_DIR / "config.json"

_session: dict[str, Any] = {
"start_time": 0.0,
"framework": "unknown",
"adapters": [],
"request_count": 0,
"query_count": 0,
"error_count": 0,
"node_connected": False,
}


def _is_enabled() -> bool:
val = os.environ.get("BRAKIT_TELEMETRY", "").lower()
return val not in ("false", "0", "off")


def _get_anonymous_id() -> str:
"""Read or create a persistent anonymous ID (shared with Node SDK)."""
try:
if _CONFIG_FILE.exists():
config = json.loads(_CONFIG_FILE.read_text())
if "anonymousId" in config:
return config["anonymousId"]
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
anonymous_id = str(uuid.uuid4())
_CONFIG_FILE.write_text(json.dumps({
"telemetry": True,
"anonymousId": anonymous_id,
}))
return anonymous_id
except Exception:
return str(uuid.uuid4())


def _get_version() -> str:
"""Read version from package metadata."""
try:
from importlib.metadata import version
return version("brakit")
except Exception:
return "unknown"


def init_session(framework: str, adapters: list[str]) -> None:
_session["start_time"] = time.time()
_session["framework"] = framework
_session["adapters"] = adapters


def record_node_connected() -> None:
_session["node_connected"] = True


def record_counts(*, requests: int = 0, queries: int = 0, errors: int = 0) -> None:
_session["request_count"] = requests
_session["query_count"] = queries
_session["error_count"] = errors


def _send_session() -> None:
if not _is_enabled() or not _POSTHOG_KEY:
return

duration_s = int(time.time() - _session["start_time"]) if _session["start_time"] else 0

payload = json.dumps({
"api_key": _POSTHOG_KEY,
"event": "session",
"distinct_id": _get_anonymous_id(),
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()),
"properties": {
"sdk": "python",
"brakit_version": _get_version(),
"python_version": platform.python_version(),
"os": f"{sys.platform}-{platform.release()}",
"arch": platform.machine(),
"framework": _session["framework"],
"adapters_detected": _session["adapters"],
"request_count": _session["request_count"],
"query_count": _session["query_count"],
"error_count": _session["error_count"],
"brakit_node_connected": _session["node_connected"],
"session_duration_s": duration_s,
"$lib": "brakit",
"$process_person_profile": False,
"$geoip_disable": True,
},
})

# Fire-and-forget via subprocess — never blocks the host app
url = f"{_POSTHOG_HOST}{_POSTHOG_PATH}"
try:
subprocess.Popen(
[
sys.executable, "-c",
f"import urllib.request; urllib.request.urlopen("
f"urllib.request.Request({url!r}, data={payload.encode()!r}, "
f"headers={{'Content-Type': 'application/json'}}), timeout={_TIMEOUT_MS / 1000})",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception:
pass


# Register atexit handler to send session telemetry on shutdown
atexit.register(_send_session)
5 changes: 4 additions & 1 deletion sdks/python/brakit/frameworks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
logger = logging.getLogger(LOGGER_NAME)


def detect_and_patch(registry: ServiceRegistry) -> None:
def detect_and_patch(registry: ServiceRegistry) -> str:
from brakit.frameworks.flask import FlaskAdapter
from brakit.frameworks.fastapi import FastAPIAdapter

detected = "unknown"
for adapter in (FlaskAdapter(), FastAPIAdapter()):
if adapter.detect():
try:
adapter.patch(registry)
detected = adapter.name
except Exception:
logger.debug("failed to patch %s", adapter.name, exc_info=True)
return detected
2 changes: 1 addition & 1 deletion sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "brakit"
version = "0.1.3"
version = "0.1.4"
description = "Zero-config observability for Python web frameworks"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 0 additions & 2 deletions src/analysis/insights/rules/pattern-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
CROSS_ENDPOINT_MIN_OCCURRENCES,
} from "../../../constants/index.js";

// ── Duplicate API Call Detection ──
export const duplicateRule: InsightRule = {
id: "duplicate",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -51,7 +50,6 @@ export const duplicateRule: InsightRule = {
},
};

// ── Cross-Endpoint Query Detection ──
export const crossEndpointRule: InsightRule = {
id: "cross-endpoint",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down
10 changes: 3 additions & 7 deletions src/analysis/insights/rules/query-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
REDUNDANT_QUERY_MIN_COUNT,
OVERFETCH_MIN_REQUESTS,
HIGH_ROW_COUNT,
DETAIL_PREVIEW_LENGTH,
MIN_REQUESTS_FOR_INSIGHT,
HIGH_QUERY_COUNT_PER_REQ,
} from "../../../constants/index.js";
import { SELECT_STAR_RE, SELECT_DOT_STAR_RE } from "../../rules/patterns.js";

// ── N+1 Query Detection ──
export const n1Rule: InsightRule = {
id: "n1",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -49,7 +49,7 @@ export const n1Rule: InsightRule = {
title: "N+1 Query Pattern",
desc: `${endpoint} runs ${shapeGroup.count}x ${info.op} ${info.table} with different params in a single request`,
hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
detail: `${shapeGroup.count} queries with ${shapeGroup.distinctSql.size} distinct param variations. Example: ${[...shapeGroup.distinctSql][0]?.slice(0, 100) ?? info.op + " " + info.table}`,
detail: `${shapeGroup.count} queries with ${shapeGroup.distinctSql.size} distinct param variations. Example: ${[...shapeGroup.distinctSql][0]?.slice(0, DETAIL_PREVIEW_LENGTH) ?? info.op + " " + info.table}`,
});
}
}
Expand All @@ -58,7 +58,6 @@ export const n1Rule: InsightRule = {
},
};

// ── Redundant Query Detection ──
export const redundantQueryRule: InsightRule = {
id: "redundant-query",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -94,7 +93,7 @@ export const redundantQueryRule: InsightRule = {
title: "Redundant Query",
desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
detail: entry.first.sql ? `Query: ${entry.first.sql.slice(0, 120)}` : undefined,
detail: entry.first.sql ? `Query: ${entry.first.sql.slice(0, DETAIL_PREVIEW_LENGTH)}` : undefined,
});
}
}
Expand All @@ -103,7 +102,6 @@ export const redundantQueryRule: InsightRule = {
},
};

// ── SELECT * Detection ──
export const selectStarRule: InsightRule = {
id: "select-star",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -136,7 +134,6 @@ export const selectStarRule: InsightRule = {
},
};

// ── High Row Count Detection ──
export const highRowsRule: InsightRule = {
id: "high-rows",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -173,7 +170,6 @@ export const highRowsRule: InsightRule = {
},
};

// ── Query-Heavy Endpoint Detection ──
export const queryHeavyRule: InsightRule = {
id: "query-heavy",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down
4 changes: 0 additions & 4 deletions src/analysis/insights/rules/reliability-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
BASELINE_MIN_REQUESTS_PER_SESSION,
} from "../../../constants/index.js";

// ── Unhandled Error Detection ──
export const errorRule: InsightRule = {
id: "error",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -41,7 +40,6 @@ export const errorRule: InsightRule = {
},
};

// ── Error Hotspot Detection ──
export const errorHotspotRule: InsightRule = {
id: "error-hotspot",
check(ctx: PreparedInsightContext): Insight[] {
Expand All @@ -65,7 +63,6 @@ export const errorHotspotRule: InsightRule = {
},
};

// ── Performance Regression Detection ──
export const regressionRule: InsightRule = {
id: "regression",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -133,7 +130,6 @@ function getAdaptiveSlowThreshold(
return medianP95 * 2;
}

// ── Slow Endpoint Detection ──
export const slowRule: InsightRule = {
id: "slow",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down
2 changes: 0 additions & 2 deletions src/analysis/insights/rules/response-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
LARGE_RESPONSE_BYTES,
} from "../../../constants/index.js";

// ── Response Overfetch Detection ──
export const responseOverfetchRule: InsightRule = {
id: "response-overfetch",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down Expand Up @@ -74,7 +73,6 @@ export const responseOverfetchRule: InsightRule = {
},
};

// ── Large Response Detection ──
export const largeResponseRule: InsightRule = {
id: "large-response",
check(ctx: PreparedInsightContext): Insight[] {
Expand Down
4 changes: 0 additions & 4 deletions src/analysis/rules/auth-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { isErrorStatus, isRedirect } from "../../utils/http-status.js";
import { deduplicateFindings } from "../../utils/collections.js";
import { collectFromObject } from "../../utils/object-scan.js";

// ── Exposed Secret Detection ──

function findSecretKeys(obj: unknown): string[] {
return collectFromObject(obj, (key, val) =>
Expand Down Expand Up @@ -47,7 +46,6 @@ export const exposedSecretRule: SecurityRule = {
},
};

// ── Token in URL Detection ──

export const tokenInUrlRule: SecurityRule = {
id: "token-in-url",
Expand Down Expand Up @@ -88,7 +86,6 @@ export const tokenInUrlRule: SecurityRule = {
},
};

// ── Insecure Cookie Detection ──

function isFrameworkResponse(request: TracedRequest): boolean {
if (isRedirect(request.statusCode)) return true;
Expand Down Expand Up @@ -140,7 +137,6 @@ export const insecureCookieRule: SecurityRule = {
},
};

// ── CORS Credentials with Wildcard Detection ──

export const corsCredentialsRule: SecurityRule = {
id: "cors-credentials",
Expand Down
Loading
Loading