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
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": "9.0.0",
"version": "0.9.0",
"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
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.2"
version = "0.1.3"
description = "Zero-config observability for Python web frameworks"
readme = "README.md"
license = "MIT"
Expand Down
3 changes: 1 addition & 2 deletions src/analysis/insights/rules/pattern-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export const duplicateRule: InsightRule = {
title: "Duplicate API Call",
desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
nav: "actions",
});
}

Expand Down Expand Up @@ -96,7 +95,7 @@ export const crossEndpointRule: InsightRule = {
title: "Repeated Query Across Endpoints",
desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
nav: "queries",
detail: `Endpoints: ${[...queryMetric.endpoints].slice(0, 5).join(", ")}${queryMetric.endpoints.size > 5 ? ` +${queryMetric.endpoints.size - 5} more` : ""}. Total: ${queryMetric.count} executions.`,
});
}
}
Expand Down
44 changes: 21 additions & 23 deletions src/analysis/insights/rules/query-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const n1Rule: InsightRule = {
id: "n1",
check(ctx: PreparedInsightContext): Insight[] {
const insights: Insight[] = [];
const seen = new Set<string>();
const reportedKeys = new Set<string>();

for (const [reqId, reqQueries] of ctx.queriesByReq) {
const req = ctx.reqById.get(reqId);
Expand All @@ -37,19 +37,19 @@ export const n1Rule: InsightRule = {
group.distinctSql.add(query.sql ?? shape);
}

for (const [, sg] of shapeGroups) {
if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
const info = getQueryInfo(sg.first);
for (const [, shapeGroup] of shapeGroups) {
if (shapeGroup.count <= N1_QUERY_THRESHOLD || shapeGroup.distinctSql.size <= 1) continue;
const info = getQueryInfo(shapeGroup.first);
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
if (seen.has(key)) continue;
seen.add(key);
if (reportedKeys.has(key)) continue;
reportedKeys.add(key);
insights.push({
severity: "critical",
type: "n1",
title: "N+1 Query Pattern",
desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
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.",
nav: "queries",
detail: `${shapeGroup.count} queries with ${shapeGroup.distinctSql.size} distinct param variations. Example: ${[...shapeGroup.distinctSql][0]?.slice(0, 100) ?? info.op + " " + info.table}`,
});
}
}
Expand All @@ -63,38 +63,38 @@ export const redundantQueryRule: InsightRule = {
id: "redundant-query",
check(ctx: PreparedInsightContext): Insight[] {
const insights: Insight[] = [];
const seen = new Set<string>();
const reportedKeys = new Set<string>();

for (const [reqId, reqQueries] of ctx.queriesByReq) {
const req = ctx.reqById.get(reqId);
if (!req) continue;
const endpoint = getEndpointKey(req.method, req.path);
const exact = new Map<string, { count: number; first: TracedQuery }>();
const identicalQueryMap = new Map<string, { count: number; first: TracedQuery }>();

for (const query of reqQueries) {
if (!query.sql) continue;
let entry = exact.get(query.sql);
let entry = identicalQueryMap.get(query.sql);
if (!entry) {
entry = { count: 0, first: query };
exact.set(query.sql, entry);
identicalQueryMap.set(query.sql, entry);
}
entry.count++;
}

for (const [, entry] of exact) {
for (const [, entry] of identicalQueryMap) {
if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
const info = getQueryInfo(entry.first);
const label = info.op + (info.table ? ` ${info.table}` : "");
const deduplicationKey = `${endpoint}:${label}`;
if (seen.has(deduplicationKey)) continue;
seen.add(deduplicationKey);
if (reportedKeys.has(deduplicationKey)) continue;
reportedKeys.add(deduplicationKey);
insights.push({
severity: "warning",
type: "redundant-query",
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.",
nav: "queries",
detail: entry.first.sql ? `Query: ${entry.first.sql.slice(0, 120)}` : undefined,
});
}
}
Expand All @@ -107,29 +107,28 @@ export const redundantQueryRule: InsightRule = {
export const selectStarRule: InsightRule = {
id: "select-star",
check(ctx: PreparedInsightContext): Insight[] {
const seen = new Map<string, number>();
const tableCounts = new Map<string, number>();

for (const [, reqQueries] of ctx.queriesByReq) {
for (const query of reqQueries) {
if (!query.sql) continue;
const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
if (!isSelectStar) continue;
const info = getQueryInfo(query);
const key = info.table || "unknown";
seen.set(key, (seen.get(key) ?? 0) + 1);
const table = info.table || "unknown";
tableCounts.set(table, (tableCounts.get(table) ?? 0) + 1);
}
}

const insights: Insight[] = [];
for (const [table, count] of seen) {
for (const [table, count] of tableCounts) {
if (count < OVERFETCH_MIN_REQUESTS) continue;
insights.push({
severity: "warning",
type: "select-star",
title: "SELECT * Query",
desc: `SELECT * on ${table} — ${count} occurrence${count !== 1 ? "s" : ""}`,
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
nav: "queries",
});
}

Expand Down Expand Up @@ -167,7 +166,6 @@ export const highRowsRule: InsightRule = {
title: "Large Result Set",
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
nav: "queries",
});
}

Expand All @@ -191,7 +189,7 @@ export const queryHeavyRule: InsightRule = {
title: "Query-Heavy Endpoint",
desc: `${endpointKey} — avg ${avgQueries} queries/request`,
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
nav: "queries",

});
}
}
Expand Down
39 changes: 32 additions & 7 deletions src/analysis/insights/rules/reliability-rules.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { InsightRule } from "../rule.js";
import type { Insight, PreparedInsightContext } from "../types.js";
import type { EndpointMetrics } from "../../../types/index.js";
import { formatDuration, pct } from "../../../utils/format.js";
import {
MIN_REQUESTS_FOR_INSIGHT,
ERROR_RATE_THRESHOLD_PCT,
SLOW_ENDPOINT_THRESHOLD_MS,
REGRESSION_PCT_THRESHOLD,
REGRESSION_MIN_INCREASE_MS,
REGRESSION_MIN_REQUESTS,
QUERY_COUNT_REGRESSION_RATIO,
BASELINE_MIN_SESSIONS,
BASELINE_MIN_REQUESTS_PER_SESSION,
} from "../../../constants/index.js";

// ── Unhandled Error Detection ──
Expand All @@ -31,7 +33,7 @@ export const errorRule: InsightRule = {
title: "Unhandled Error",
desc: `${name} — occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
nav: "errors",
detail: ctx.errors.find((e) => e.name === name)?.message,
});
}

Expand All @@ -55,7 +57,6 @@ export const errorHotspotRule: InsightRule = {
title: "Error Hotspot",
desc: `${endpointKey} — ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
nav: "requests",
});
}
}
Expand Down Expand Up @@ -91,7 +92,6 @@ export const regressionRule: InsightRule = {
title: "Performance Regression",
desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing.",
nav: "graph",
});
}

Expand All @@ -102,7 +102,6 @@ export const regressionRule: InsightRule = {
title: "Query Count Regression",
desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations.",
nav: "queries",
});
}
}
Expand All @@ -111,6 +110,29 @@ export const regressionRule: InsightRule = {
},
};

/**
* Compute per-endpoint adaptive slow threshold from session history.
* Returns null when insufficient data — the rule should skip the endpoint
* rather than judging it against arbitrary absolute thresholds.
*/
function getAdaptiveSlowThreshold(
endpointKey: string,
previousMetrics: readonly EndpointMetrics[] | undefined,
): number | null {
if (!previousMetrics) return null;

const ep = previousMetrics.find((m) => m.endpoint === endpointKey);
if (!ep || ep.sessions.length < BASELINE_MIN_SESSIONS) return null;

const valid = ep.sessions.filter((s) => s.requestCount >= BASELINE_MIN_REQUESTS_PER_SESSION);
if (valid.length < BASELINE_MIN_SESSIONS) return null;

const p95s = valid.map((s) => s.p95DurationMs).sort((a, b) => a - b);
const medianP95 = p95s[Math.floor(p95s.length / 2)];

return medianP95 * 2;
}

// ── Slow Endpoint Detection ──
export const slowRule: InsightRule = {
id: "slow",
Expand All @@ -120,7 +142,11 @@ export const slowRule: InsightRule = {
for (const [endpointKey, group] of ctx.endpointGroups) {
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
const avgMs = Math.round(group.totalDuration / group.total);
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;

// Only flag as slow when we have a baseline to compare against.
// Without historical data, we can't know if this is normal for this endpoint.
const threshold = getAdaptiveSlowThreshold(endpointKey, ctx.previousMetrics);
if (threshold === null || avgMs < threshold) continue;

const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
Expand Down Expand Up @@ -154,7 +180,6 @@ export const slowRule: InsightRule = {
? "Most time is in outbound HTTP calls. Check if upstream services are slow or if calls can be parallelized."
: "Most time is in application code. Profile the handler for CPU-heavy operations or blocking calls.",
detail,
nav: "requests",
});
}

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 @@ -66,7 +66,6 @@ export const responseOverfetchRule: InsightRule = {
title: "Response Overfetch",
desc: `${endpointKey} — ${reasons.join(", ")}`,
hint: "This response returns more data than the client likely needs. Use a DTO or select only required fields to reduce payload size and avoid leaking internal structure.",
nav: "requests",
});
}
}
Expand All @@ -91,7 +90,6 @@ export const largeResponseRule: InsightRule = {
title: "Large Response",
desc: `${endpointKey} — avg ${formatSize(avgSize)} response`,
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
nav: "requests",
});
}
}
Expand Down
12 changes: 6 additions & 6 deletions src/analysis/insights/rules/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ export const securityRule: InsightRule = {
check(ctx: PreparedInsightContext): Insight[] {
if (!ctx.securityFindings) return [];

return ctx.securityFindings.map((f) => ({
severity: f.severity,
return ctx.securityFindings.map((finding) => ({
severity: finding.severity,
type: "security" as const,
title: f.title,
desc: f.desc,
hint: f.hint,
nav: "security",
title: finding.title,
desc: finding.desc,
hint: finding.hint,
detail: finding.detail,
}));
},
};
3 changes: 1 addition & 2 deletions src/analysis/issue-mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export function insightToIssue(insight: Insight): Issue {
hint: insight.hint,
detail: insight.detail,
endpoint: extractEndpointFromDesc(insight.desc) ?? undefined,
nav: insight.nav,
};
}

Expand All @@ -31,7 +30,7 @@ export function securityFindingToIssue(finding: SecurityFinding): Issue {
title: finding.title,
desc: finding.desc,
hint: finding.hint,
detail: finding.detail,
endpoint: finding.endpoint,
nav: "security",
};
}
3 changes: 3 additions & 0 deletions src/analysis/rules/auth-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const exposedSecretRule: SecurityRule = {
title: "Exposed Secret in Response",
desc: `${ep} — response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
hint: this.hint,
detail: `Exposed fields: ${keys.join(", ")}. ${keys.length} unmasked secret value${keys.length !== 1 ? "s" : ""} in response body.`,
endpoint: ep,
count: 1,
},
Expand Down Expand Up @@ -78,6 +79,7 @@ export const tokenInUrlRule: SecurityRule = {
title: "Auth Token in URL",
desc: `${ep} — ${flagged.join(", ")} exposed in query string`,
hint: this.hint,
detail: `Parameters in URL: ${flagged.join(", ")}. Auth tokens in URLs are logged by proxies, browsers, and CDNs.`,
endpoint: ep,
count: 1,
},
Expand Down Expand Up @@ -129,6 +131,7 @@ export const insecureCookieRule: SecurityRule = {
title: "Insecure Cookie",
desc: `${cookieName} — missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
hint: this.hint,
detail: `Missing: ${issues.join(", ")}. ${issues.includes("HttpOnly") ? "Cookie accessible via JavaScript (XSS risk). " : ""}${issues.includes("SameSite") ? "Cookie sent on cross-site requests (CSRF risk)." : ""}`,
endpoint: cookieName,
count: 1,
},
Expand Down
11 changes: 10 additions & 1 deletion src/analysis/rules/data-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const stackTraceLeakRule: SecurityRule = {
if (!request.responseBody) return null;
if (!STACK_TRACE_RE.test(request.responseBody)) return null;
const ep = `${request.method} ${request.path}`;
const firstLine = request.responseBody.split("\n").find((l) => STACK_TRACE_RE.test(l))?.trim() ?? "";
return {
key: ep,
finding: {
Expand All @@ -39,6 +40,7 @@ export const stackTraceLeakRule: SecurityRule = {
title: "Stack Trace Leaked to Client",
desc: `${ep} — response exposes internal stack trace`,
hint: this.hint,
detail: firstLine ? `Stack trace: ${firstLine.slice(0, 120)}` : undefined,
endpoint: ep,
count: 1,
},
Expand Down Expand Up @@ -84,6 +86,7 @@ export const errorInfoLeakRule: SecurityRule = {
title: "Sensitive Data in Error Response",
desc: `${ep} — error response exposes ${pattern.label}`,
hint: this.hint,
detail: `Detected: ${pattern.label} in error response body`,
endpoint: ep,
count: 1,
},
Expand Down Expand Up @@ -267,14 +270,20 @@ export const responsePiiLeakRule: SecurityRule = {
if (!detection) return null;

const ep = `${request.method} ${request.path}`;
const fieldCount = topLevelFieldCount(resJson);
const detailParts = [`Pattern: ${REASON_LABELS[detection.reason]}`];
if (detection.emailCount > 0) detailParts.push(`${detection.emailCount} email${detection.emailCount !== 1 ? "s" : ""} detected`);
if (fieldCount > 0) detailParts.push(`${fieldCount} fields per record`);

return {
key: ep,
finding: {
severity: "warning",
rule: "response-pii-leak",
title: "PII Leak in Response",
desc: `${ep} — exposes PII in response`,
hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
hint: this.hint,
detail: detailParts.join(". "),
endpoint: ep,
count: 1,
},
Expand Down
Loading
Loading