diff --git a/package.json b/package.json index a9b03bb..5dd273a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 05a4eaf..baef407 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.2" +version = "0.1.3" description = "Zero-config observability for Python web frameworks" readme = "README.md" license = "MIT" diff --git a/src/analysis/insights/rules/pattern-rules.ts b/src/analysis/insights/rules/pattern-rules.ts index 858c7b2..10829f9 100644 --- a/src/analysis/insights/rules/pattern-rules.ts +++ b/src/analysis/insights/rules/pattern-rules.ts @@ -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", }); } @@ -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.`, }); } } diff --git a/src/analysis/insights/rules/query-rules.ts b/src/analysis/insights/rules/query-rules.ts index 99eeb74..e1d288f 100644 --- a/src/analysis/insights/rules/query-rules.ts +++ b/src/analysis/insights/rules/query-rules.ts @@ -18,7 +18,7 @@ export const n1Rule: InsightRule = { id: "n1", check(ctx: PreparedInsightContext): Insight[] { const insights: Insight[] = []; - const seen = new Set(); + const reportedKeys = new Set(); for (const [reqId, reqQueries] of ctx.queriesByReq) { const req = ctx.reqById.get(reqId); @@ -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}`, }); } } @@ -63,38 +63,38 @@ export const redundantQueryRule: InsightRule = { id: "redundant-query", check(ctx: PreparedInsightContext): Insight[] { const insights: Insight[] = []; - const seen = new Set(); + const reportedKeys = new Set(); 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(); + const identicalQueryMap = new Map(); 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, }); } } @@ -107,7 +107,7 @@ export const redundantQueryRule: InsightRule = { export const selectStarRule: InsightRule = { id: "select-star", check(ctx: PreparedInsightContext): Insight[] { - const seen = new Map(); + const tableCounts = new Map(); for (const [, reqQueries] of ctx.queriesByReq) { for (const query of reqQueries) { @@ -115,13 +115,13 @@ export const selectStarRule: InsightRule = { 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", @@ -129,7 +129,6 @@ export const selectStarRule: InsightRule = { 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", }); } @@ -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", }); } @@ -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", + }); } } diff --git a/src/analysis/insights/rules/reliability-rules.ts b/src/analysis/insights/rules/reliability-rules.ts index 5ca2ac8..4c6eac5 100644 --- a/src/analysis/insights/rules/reliability-rules.ts +++ b/src/analysis/insights/rules/reliability-rules.ts @@ -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 ── @@ -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, }); } @@ -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", }); } } @@ -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", }); } @@ -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", }); } } @@ -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", @@ -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); @@ -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", }); } diff --git a/src/analysis/insights/rules/response-rules.ts b/src/analysis/insights/rules/response-rules.ts index 0f7f3b8..26e6f30 100644 --- a/src/analysis/insights/rules/response-rules.ts +++ b/src/analysis/insights/rules/response-rules.ts @@ -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", }); } } @@ -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", }); } } diff --git a/src/analysis/insights/rules/security.ts b/src/analysis/insights/rules/security.ts index 2b987ab..b76cd27 100644 --- a/src/analysis/insights/rules/security.ts +++ b/src/analysis/insights/rules/security.ts @@ -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, })); }, }; diff --git a/src/analysis/issue-mappers.ts b/src/analysis/issue-mappers.ts index de78e03..348a9c5 100644 --- a/src/analysis/issue-mappers.ts +++ b/src/analysis/issue-mappers.ts @@ -19,7 +19,6 @@ export function insightToIssue(insight: Insight): Issue { hint: insight.hint, detail: insight.detail, endpoint: extractEndpointFromDesc(insight.desc) ?? undefined, - nav: insight.nav, }; } @@ -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", }; } diff --git a/src/analysis/rules/auth-rules.ts b/src/analysis/rules/auth-rules.ts index c37ce49..5c7d83c 100644 --- a/src/analysis/rules/auth-rules.ts +++ b/src/analysis/rules/auth-rules.ts @@ -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, }, @@ -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, }, @@ -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, }, diff --git a/src/analysis/rules/data-rules.ts b/src/analysis/rules/data-rules.ts index 09231c1..3e7f927 100644 --- a/src/analysis/rules/data-rules.ts +++ b/src/analysis/rules/data-rules.ts @@ -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: { @@ -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, }, @@ -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, }, @@ -267,6 +270,11 @@ 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: { @@ -274,7 +282,8 @@ export const responsePiiLeakRule: SecurityRule = { 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, }, diff --git a/src/constants/config.ts b/src/constants/config.ts index ccf5c53..a2420ab 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -90,6 +90,22 @@ export const STALE_ISSUE_TTL_MS = 30 * 60 * 1_000; */ export const STRICT_MODE_MAX_GAP_MS = 2000; +// ── Adaptive baseline ── + +/** Minimum sessions needed to compute a per-endpoint performance baseline. */ +export const BASELINE_MIN_SESSIONS = 2; +/** Minimum requests per session for the session to count toward the baseline. */ +export const BASELINE_MIN_REQUESTS_PER_SESSION = 3; +/** Ratio thresholds for adaptive health grading (current p95 / baseline p95). */ +export const BASELINE_FAST_RATIO = 0.7; +export const BASELINE_GOOD_RATIO = 1.2; +export const BASELINE_OK_RATIO = 2.0; +export const BASELINE_SLOW_RATIO = 3.0; +/** Minimum requests before p95 becomes statistically meaningful over median. */ +export const P95_MIN_SAMPLE_SIZE = 20; +/** Minimum pending points (pre-flush) to compute an intra-session baseline. */ +export const BASELINE_PENDING_POINTS_MIN = 3; + // ── Metrics ── export const METRICS_DIR = ".brakit"; diff --git a/src/dashboard/client/constants.ts b/src/dashboard/client/constants.ts index b9e2503..c0f0fca 100644 --- a/src/dashboard/client/constants.ts +++ b/src/dashboard/client/constants.ts @@ -24,8 +24,6 @@ export { } from "./constants/events.js"; export { - NAV_LABELS, - VIEW_CONTAINERS, VIEW_TITLES, VIEW_SUBTITLES, } from "./constants/navigation.js"; diff --git a/src/dashboard/client/constants/layout.ts b/src/dashboard/client/constants/layout.ts index 38d8ecc..d0f4da0 100644 --- a/src/dashboard/client/constants/layout.ts +++ b/src/dashboard/client/constants/layout.ts @@ -23,5 +23,9 @@ export const WF_PROPORTIONAL_SPREAD = 85; // Scatter chart (performance view) export const SCATTER_CHART_HEIGHT_PX = 240; -export const RECENT_REQUESTS_LIMIT = 50; export const CLICK_TOLERANCE_PX = 16; + +// Performance view limits +export const HISTORY_TABLE_LIMIT = 50; +export const RECENT_SESSIONS_LIMIT = 10; +export const QUERY_BREAKDOWN_REQUEST_LIMIT = 20; diff --git a/src/dashboard/client/constants/navigation.ts b/src/dashboard/client/constants/navigation.ts index 1e20c1b..2a14466 100644 --- a/src/dashboard/client/constants/navigation.ts +++ b/src/dashboard/client/constants/navigation.ts @@ -1,29 +1,5 @@ /** View navigation labels, containers, titles, and subtitles. */ -export const NAV_LABELS: Record = { - overview: "Overview", - queries: "Queries", - requests: "Requests", - actions: "Actions", - errors: "Errors", - security: "Security", - fetches: "Fetches", - logs: "Logs", - performance: "Performance", -}; - -export const VIEW_CONTAINERS: Record = { - overview: "overview-container", - actions: "flow-container", - requests: "request-container", - fetches: "fetch-container", - queries: "query-container", - errors: "error-container", - logs: "log-container", - performance: "performance-container", - security: "security-container", -}; - export const VIEW_TITLES: Record = { overview: "Overview", actions: "Actions", diff --git a/src/dashboard/client/store/dashboard-store.ts b/src/dashboard/client/store/dashboard-store.ts index fc7c9fa..db19d3d 100644 --- a/src/dashboard/client/store/dashboard-store.ts +++ b/src/dashboard/client/store/dashboard-store.ts @@ -10,7 +10,7 @@ import type { TracedQuery, TracedLog, TracedError, - RequestFlow, + FlowData, StatefulIssue, EndpointMetrics, } from "./types.js"; @@ -37,7 +37,7 @@ export class DashboardStore extends EventTarget { // -- Bulk setters (initial load) -- - setFlows(flows: RequestFlow[]): void { + setFlows(flows: FlowData[]): void { this._state = { ...this._state, flows }; this.notify("flows"); } diff --git a/src/dashboard/client/store/types.ts b/src/dashboard/client/store/types.ts index a2133cc..38f749b 100644 --- a/src/dashboard/client/store/types.ts +++ b/src/dashboard/client/store/types.ts @@ -10,7 +10,13 @@ // Shared enums — keep in sync with src/types/telemetry.ts // --------------------------------------------------------------------------- -export type DbDriver = "pg" | "mysql2" | "prisma" | "asyncpg" | "sqlalchemy" | "sdk"; +export type DbDriver = + | "pg" + | "mysql2" + | "prisma" + | "asyncpg" + | "sqlalchemy" + | "sdk"; export type LogLevel = "log" | "warn" | "error" | "info" | "debug"; export type NormalizedOp = "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "OTHER"; export type IssueState = "open" | "fixing" | "resolved" | "stale" | "regressed"; @@ -214,6 +220,7 @@ export interface LiveRequestPoint { export interface LiveEndpointSummary { p95Ms: number; + medianMs: number; errorRate: number; avgQueryCount: number; totalRequests: number; @@ -226,6 +233,20 @@ export interface LiveEndpointData { endpoint: string; requests: LiveRequestPoint[]; summary: LiveEndpointSummary; + sessions?: SessionMetric[]; + baselineP95Ms: number | null; +} + +export interface SessionMetric { + sessionId: string; + startedAt: number; + avgDurationMs: number; + p95DurationMs: number; + requestCount: number; + errorCount: number; + avgQueryCount: number; + avgQueryTimeMs: number; + avgFetchTimeMs: number; } // --------------------------------------------------------------------------- @@ -281,12 +302,20 @@ export interface FlowActivityData { export type ViewMode = "simple" | "detailed"; export type StoreStateKey = - | "flows" | "requests" | "fetches" | "errors" - | "logs" | "queries" | "issues" | "metrics" - | "viewMode" | "activeView" | "all"; + | "flows" + | "requests" + | "fetches" + | "errors" + | "logs" + | "queries" + | "issues" + | "metrics" + | "viewMode" + | "activeView" + | "all"; export interface DashboardState { - flows: RequestFlow[]; + flows: FlowData[]; requests: TracedRequest[]; fetches: TracedFetch[]; errors: TracedError[]; diff --git a/src/dashboard/client/utils/health.ts b/src/dashboard/client/utils/health.ts new file mode 100644 index 0000000..56a37b5 --- /dev/null +++ b/src/dashboard/client/utils/health.ts @@ -0,0 +1,55 @@ +/** + * Adaptive health grading — determines an endpoint's health relative to + * its own historical baseline rather than hardcoded absolute thresholds. + * + * When a baseline exists, the grade reflects how the current performance + * compares to what's "normal" for THIS endpoint. + * + * When no baseline is available (not enough data), returns a neutral + * "Pending" grade — we don't judge an endpoint until we know what's + * normal for it. + * + * Uses median instead of p95 for health grading when there are fewer than + * P95_MIN_SAMPLE_SIZE requests, since p95 with small samples is the max. + */ + +import { HEALTH_GRADES } from "../constants.js"; +import type { HealthGrade } from "../constants.js"; +import { + BASELINE_FAST_RATIO, + BASELINE_GOOD_RATIO, + BASELINE_OK_RATIO, + BASELINE_SLOW_RATIO, + P95_MIN_SAMPLE_SIZE, +} from "../../../constants/config.js"; + +/** Neutral grade shown when insufficient data to judge performance. */ +const PENDING_GRADE: HealthGrade = { + max: Infinity, + label: "Pending", + color: "var(--text-muted)", + bg: "var(--bg-muted)", + border: "var(--border)", +}; + +export function representativeLatency( + p95Ms: number, + medianMs: number, + totalRequests: number, +): number { + return totalRequests >= P95_MIN_SAMPLE_SIZE ? p95Ms : medianMs; +} + +export function adaptiveHealthGrade( + currentMs: number, + baselineMs: number | null | undefined, +): HealthGrade { + if (!baselineMs || baselineMs <= 0) return PENDING_GRADE; + + const ratio = currentMs / baselineMs; + if (ratio < BASELINE_FAST_RATIO) return HEALTH_GRADES[0]; + if (ratio < BASELINE_GOOD_RATIO) return HEALTH_GRADES[1]; + if (ratio < BASELINE_OK_RATIO) return HEALTH_GRADES[2]; + if (ratio < BASELINE_SLOW_RATIO) return HEALTH_GRADES[3]; + return HEALTH_GRADES[4]; +} diff --git a/src/dashboard/client/views/flows-view.ts b/src/dashboard/client/views/flows-view.ts index 8cc6abb..f093331 100644 --- a/src/dashboard/client/views/flows-view.ts +++ b/src/dashboard/client/views/flows-view.ts @@ -53,7 +53,7 @@ export class FlowsView extends LitElement { } private get flows(): FlowData[] { - return this.store.state.flows as FlowData[]; + return this.store.state.flows; } private get viewMode() { diff --git a/src/dashboard/client/views/overview-view.ts b/src/dashboard/client/views/overview-view.ts index 33a9f5a..7063820 100644 --- a/src/dashboard/client/views/overview-view.ts +++ b/src/dashboard/client/views/overview-view.ts @@ -7,9 +7,7 @@ import { DashboardStore, dashboardContext } from "../store/dashboard-store.js"; import { formatDuration } from "../utils/format.js"; import { SEVERITY_MAP, - VIEW_TITLES, DASHBOARD_PREFIX, - NAV_LABELS, CLEAN_HITS_FOR_RESOLUTION, } from "../constants.js"; import type { StatefulIssue } from "../store/types.js"; @@ -30,27 +28,7 @@ export class OverviewView extends LitElement { this.store.addEventListener("state-changed", () => this.requestUpdate()); } - private navigateToView(view: string) { - const buttons = document.querySelectorAll(".sidebar-item"); - for (const btn of buttons) { - const label = btn.querySelector(".item-label"); - if (label && label.textContent?.trim() === (VIEW_TITLES[view] || view)) { - btn.click(); - return; - } - } - } - - private toggleCard(idx: number, e: Event) { - let target = e.target as HTMLElement | null; - while (target && target !== e.currentTarget) { - if (target.classList?.contains("ov-card-link")) { - const nav = target.getAttribute("data-nav"); - if (nav) this.navigateToView(nav); - return; - } - target = target.parentElement; - } + private toggleCard(idx: number) { this.expandedCardIdx = this.expandedCardIdx === idx ? -1 : idx; } @@ -149,21 +127,16 @@ export class OverviewView extends LitElement { : nothing; return html` -
this.toggleCard(idx, e)}> +
this.toggleCard(idx)}> ${sevCfg.icon}
${issue.title}${aiBadge}
${issue.desc}
+ ${issue.detail ? html`
${issue.detail}
` : nothing} ${resolvingHtml} -
- ${issue.detail ? html`
` : nothing} - ${issue.hint ? html`
${issue.hint}
` : nothing} - ${issue.nav - ? html`View in ${NAV_LABELS[issue.nav] || issue.nav} \u2192` - : nothing} -
+ ${isExpanded && issue.hint ? html`
${issue.hint}
` : nothing}
- ${isExpanded ? "\u2193" : "\u2192"} + ${issue.hint ? html`${isExpanded ? "\u2193" : "\u2192"}` : nothing}
`; } diff --git a/src/dashboard/client/views/performance-view.ts b/src/dashboard/client/views/performance-view.ts index ce0819b..85f34e6 100644 --- a/src/dashboard/client/views/performance-view.ts +++ b/src/dashboard/client/views/performance-view.ts @@ -1,19 +1,45 @@ -/** — Performance monitoring with canvas scatter charts. */ +/** — Endpoint performance dashboard with heat map and drill-down. */ -import { LitElement, html, nothing, type TemplateResult } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { consume } from "@lit/context"; import { DashboardStore, dashboardContext } from "../store/dashboard-store.js"; +import { formatDuration } from "../utils/format.js"; import { ALL_ENDPOINTS_SELECTOR, GRAPH_COLORS, - HEALTH_GRADES, HIGH_QUERY_COUNT_PER_REQ, API, } from "../constants.js"; +import { + HISTORY_TABLE_LIMIT, + RECENT_SESSIONS_LIMIT, + QUERY_BREAKDOWN_REQUEST_LIMIT, + CLICK_TOLERANCE_PX, +} from "../constants/layout.js"; import type { HealthGrade } from "../constants.js"; -import type { LiveRequestPoint, LiveEndpointData, LiveEndpointSummary } from "../store/types.js"; -import { drawScatterChart, drawInlineScatter, type ScatterDot } from "../utils/scatter-chart.js"; +import type { + LiveRequestPoint, + LiveEndpointData, + LiveEndpointSummary, + TracedRequest, + FlowActivityData, +} from "../store/types.js"; +import { drawScatterChart, type ScatterDot } from "../utils/scatter-chart.js"; +import { adaptiveHealthGrade, representativeLatency } from "../utils/health.js"; + +interface QueryShapeAggregate { + label: string; + totalMs: number; + count: number; + avgMs: number; +} + +interface CallerInfo { + label: string; + count: number; + avgMs: number; +} @customElement("bk-performance-view") export class PerformanceView extends LitElement { @@ -23,6 +49,8 @@ export class PerformanceView extends LitElement { @state() private selectedEndpoint: string = ALL_ENDPOINTS_SELECTOR; @state() private graphData: LiveEndpointData[] = []; @state() private loadError = false; + @state() private queryBreakdown: QueryShapeAggregate[] = []; + @state() private queryBreakdownLoading = false; private scatterDots: ScatterDot[] = []; @@ -38,9 +66,9 @@ export class PerformanceView extends LitElement { private async loadMetrics() { try { - const res = await fetch(API.metricsLive); - const data = await res.json(); - this.graphData = data.endpoints || []; + const response = await fetch(API.metricsLive); + const json = await response.json(); + this.graphData = json.endpoints || []; this.loadError = false; if (!this.selectedEndpoint || this.selectedEndpoint === ALL_ENDPOINTS_SELECTOR) { this.selectedEndpoint = ALL_ENDPOINTS_SELECTOR; @@ -50,39 +78,129 @@ export class PerformanceView extends LitElement { } } - private healthGrade(ms: number): HealthGrade { - for (const grade of HEALTH_GRADES) { - if (ms < grade.max) return grade; + private healthGradeForEndpoint(endpoint: LiveEndpointData): HealthGrade { + const latency = representativeLatency( + endpoint.summary.p95Ms, + endpoint.summary.medianMs, + endpoint.summary.totalRequests, + ); + return adaptiveHealthGrade(latency, endpoint.baselineP95Ms); + } + + private healthGradeForDuration(durationMs: number, baseline?: number | null): HealthGrade { + return adaptiveHealthGrade(durationMs, baseline); + } + + private getCallers(endpointKey: string): CallerInfo[] { + const flows = this.store.state.flows; + const callerMap = new Map(); + + for (const flow of flows) { + for (const request of flow.requests) { + const requestKey = `${request.method} ${request.path}`; + if (requestKey === endpointKey || this.normalizeEndpoint(request) === endpointKey) { + const existing = callerMap.get(flow.label); + if (existing) { + existing.count++; + existing.totalMs += request.durationMs; + } else { + callerMap.set(flow.label, { count: 1, totalMs: request.durationMs }); + } + } + } } - return HEALTH_GRADES[HEALTH_GRADES.length - 1]; + + return [...callerMap.entries()] + .map(([label, stats]) => ({ + label, + count: stats.count, + avgMs: Math.round(stats.totalMs / stats.count), + })) + .sort((a, b) => b.count - a.count); } - private fmtMs(ms: number): string { - if (ms < 1) return "<1ms"; - if (ms < 1000) return Math.round(ms) + "ms"; - return (ms / 1000).toFixed(1) + "s"; + private normalizeEndpoint(request: TracedRequest): string { + const normalized = request.path + .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "/:id") + .replace(/\/\d+/g, "/:id"); + return `${request.method} ${normalized}`; + } + + private async loadQueryBreakdown(endpointKey: string) { + if (this.queryBreakdownLoading) return; + + const allRequests = this.store.state.requests; + const matchingIds = allRequests + .filter((request: TracedRequest) => { + const key = `${request.method} ${request.path}`; + return key === endpointKey || this.normalizeEndpoint(request) === endpointKey; + }) + .slice(-QUERY_BREAKDOWN_REQUEST_LIMIT) + .map((request: TracedRequest) => request.id) + .filter(Boolean); + + if (matchingIds.length === 0) { + this.queryBreakdown = []; + return; + } + + this.queryBreakdownLoading = true; + try { + const response = await fetch(`${API.activity}?requestIds=${matchingIds.join(",")}`); + if (!response.ok) { + this.queryBreakdownLoading = false; + return; + } + const activityData: FlowActivityData = await response.json(); + + const shapeMap = new Map(); + for (const activity of Object.values(activityData.activities)) { + for (const event of activity.timeline) { + if (event.type !== "query") continue; + const query = event.data; + const operation = (query.normalizedOp || query.operation || "QUERY").toUpperCase(); + const table = query.table || query.model || ""; + const label = `${operation} ${table}`.trim(); + const existing = shapeMap.get(label); + if (existing) { + existing.totalMs += query.durationMs; + existing.count++; + } else { + shapeMap.set(label, { label, totalMs: query.durationMs, count: 1 }); + } + } + } + + this.queryBreakdown = [...shapeMap.values()] + .map((shape) => ({ ...shape, avgMs: Math.round(shape.totalMs / shape.count) })) + .sort((a, b) => b.totalMs - a.totalMs); + } catch { + // Retry available by re-selecting the endpoint + } + this.queryBreakdownLoading = false; } private renderScatterChart(canvas: HTMLCanvasElement, requests: LiveRequestPoint[]) { this.scatterDots = drawScatterChart(canvas, requests); - canvas.style.cursor = "pointer"; - canvas.onclick = (e: MouseEvent) => { + canvas.onclick = (event: MouseEvent) => { const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left, my = e.clientY - rect.top; - let closest: ScatterDot | null = null, closestDist = Infinity; - for (const d of this.scatterDots) { - const dist = Math.sqrt((d.x - mx) ** 2 + (d.y - my) ** 2); - if (dist < closestDist) { closestDist = dist; closest = d; } + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + let closestDot: ScatterDot | null = null; + let closestDist = Infinity; + for (const dot of this.scatterDots) { + const dist = Math.sqrt((dot.x - mouseX) ** 2 + (dot.y - mouseY) ** 2); + if (dist < closestDist) { closestDist = dist; closestDot = dot; } } - if (closest && closestDist < 16) this.highlightRow(closest.idx); + if (closestDot && closestDist < CLICK_TOLERANCE_PX) this.highlightRow(closestDot.idx); }; } - private highlightRow(reqIdx: number) { - const prev = this.querySelector(".perf-hist-row-hl"); - if (prev) prev.classList.remove("perf-hist-row-hl"); - const row = this.querySelector(`[data-req-idx="${reqIdx}"]`); + private highlightRow(requestIndex: number) { + const previousHighlight = this.querySelector(".perf-hist-row-hl"); + if (previousHighlight) previousHighlight.classList.remove("perf-hist-row-hl"); + const row = this.querySelector(`[data-req-idx="${requestIndex}"]`); if (row) { row.classList.add("perf-hist-row-hl"); row.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -90,18 +208,11 @@ export class PerformanceView extends LitElement { } updated() { - if (this.selectedEndpoint === ALL_ENDPOINTS_SELECTOR) { - this.graphData.forEach((ep, idx) => { - if (ep.requests.length === 0) return; - const canvas = this.querySelector(`#inline-scatter-${idx}`); - if (canvas) drawInlineScatter(canvas, ep.requests); - }); - } else { - const canvas = this.querySelector("#perf-detail-canvas"); - if (canvas) { - const ep = this.graphData.find((e) => e.endpoint === this.selectedEndpoint); - if (ep) this.renderScatterChart(canvas, ep.requests); - } + if (this.selectedEndpoint === ALL_ENDPOINTS_SELECTOR) return; + const canvas = this.querySelector("#perf-detail-canvas"); + if (canvas) { + const endpointData = this.graphData.find((item) => item.endpoint === this.selectedEndpoint); + if (endpointData) this.renderScatterChart(canvas, endpointData.requests); } } @@ -123,10 +234,10 @@ export class PerformanceView extends LitElement {
- ${this.graphData.map((ep, idx) => html` - `)}
@@ -134,114 +245,151 @@ export class PerformanceView extends LitElement { } private renderOverview() { + const activeEndpoints = this.graphData.filter((endpoint) => endpoint.requests.length > 0); + if (activeEndpoints.length === 0) return nothing; + + const totalRequests = activeEndpoints.reduce((sum, endpoint) => sum + endpoint.summary.totalRequests, 0); + const weightedP95 = totalRequests > 0 + ? Math.round(activeEndpoints.reduce((sum, endpoint) => sum + endpoint.summary.p95Ms * endpoint.summary.totalRequests, 0) / totalRequests) + : 0; + const totalErrors = activeEndpoints.reduce((sum, endpoint) => sum + Math.round(endpoint.summary.errorRate * endpoint.summary.totalRequests), 0); + const overallErrorRate = totalRequests > 0 ? totalErrors / totalRequests : 0; + const slowestEndpoint = activeEndpoints[0]; + return html` -
- ${this.graphData.map((ep, idx) => ep.requests.length === 0 ? nothing : this.renderEndpointCard(ep, idx))} +
+
+
+ Total Requests + ${totalRequests} +
+
+ Avg P95 + ${formatDuration(weightedP95)} +
+
+ Error Rate + ${Math.round(overallErrorRate * 100)}% +
+
+ Slowest + ${slowestEndpoint?.endpoint ?? "-"} +
+
+ + + + + + + + + + + + + + ${activeEndpoints.map((endpoint) => this.renderHeatmapRow(endpoint))} + +
EndpointCallsP95ErrorsQ/ReqTime Split
`; } - private renderEndpointCard(ep: LiveEndpointData, idx: number) { - const s = ep.summary; - const g = this.healthGrade(s.p95Ms); - const errors = Math.round(s.errorRate * s.totalRequests); - - const ovTotal = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0); - let breakdownHtml: TemplateResult | typeof nothing = nothing; - if (ovTotal > 0) { - const dbPct = Math.round(((s.avgQueryTimeMs || 0) / ovTotal) * 100); - const fetchPct = Math.round(((s.avgFetchTimeMs || 0) / ovTotal) * 100); - const appPct = Math.max(0, 100 - dbPct - fetchPct); - breakdownHtml = html` -
-
- ${dbPct > 0 ? html`
` : nothing} - ${fetchPct > 0 ? html`
` : nothing} - ${appPct > 0 ? html`
` : nothing} -
- - ${dbPct > 0 ? html`${this.fmtMs(s.avgQueryTimeMs || 0)}` : nothing} - ${fetchPct > 0 ? html`${this.fmtMs(s.avgFetchTimeMs || 0)}` : nothing} - ${this.fmtMs(s.avgAppTimeMs || 0)} - -
- `; + private renderHeatmapRow(endpoint: LiveEndpointData) { + const summary = endpoint.summary; + const grade = this.healthGradeForEndpoint(endpoint); + const errorCount = Math.round(summary.errorRate * summary.totalRequests); + const totalAvgMs = (summary.avgQueryTimeMs || 0) + (summary.avgFetchTimeMs || 0) + (summary.avgAppTimeMs || 0); + + let dbPct = 0, fetchPct = 0, appPct = 100; + if (totalAvgMs > 0) { + dbPct = Math.round(((summary.avgQueryTimeMs || 0) / totalAvgMs) * 100); + fetchPct = Math.round(((summary.avgFetchTimeMs || 0) / totalAvgMs) * 100); + appPct = Math.max(0, 100 - dbPct - fetchPct); } return html` -
{ this.selectedEndpoint = ep.endpoint; }}> -
- ${ep.endpoint} - - p95: ${this.fmtMs(s.p95Ms)} - ${errors} err - ${s.avgQueryCount > 0 ? html`${s.avgQueryCount} q/req` : nothing} - ${s.totalRequests} req${s.totalRequests !== 1 ? "s" : ""} + { this.selectedEndpoint = endpoint.endpoint; this.queryBreakdown = []; this.loadQueryBreakdown(endpoint.endpoint); }}> + ${endpoint.endpoint} + ${summary.totalRequests} + + ${formatDuration(summary.p95Ms)} + + ${errorCount > 0 ? errorCount : "-"} + ${summary.avgQueryCount} + + + ${dbPct > 0 ? html`` : nothing} + ${fetchPct > 0 ? html`` : nothing} + ${appPct > 0 ? html`` : nothing} -
- ${breakdownHtml} - -
+ + `; } private renderDetail() { - const ep = this.graphData.find((e) => e.endpoint === this.selectedEndpoint); - if (!ep?.requests?.length) { + const endpointData = this.graphData.find((item) => item.endpoint === this.selectedEndpoint); + if (!endpointData?.requests?.length) { return html``; } - const s = ep.summary; - const g = this.healthGrade(s.p95Ms); - const errors = Math.round(s.errorRate * s.totalRequests); + const summary = endpointData.summary; + const grade = this.healthGradeForEndpoint(endpointData); + const errorCount = Math.round(summary.errorRate * summary.totalRequests); return html` - ${this.renderDetailHeader(ep, g)} - ${this.renderDetailMetrics(s, g, errors)} - ${this.renderDetailBreakdown(s)} + ${this.renderDetailHeader(endpointData, grade)} + ${this.renderDetailMetrics(summary, grade, errorCount)} + ${this.renderDetailBreakdown(summary)} + ${this.renderCallers(endpointData.endpoint)} + ${this.renderQueryBreakdown()} + ${this.renderTrends(endpointData)} ${this.renderDetailChart()} - ${this.renderDetailHistory(ep)} + ${this.renderDetailHistory(endpointData)} `; } - private renderDetailHeader(ep: LiveEndpointData, g: HealthGrade) { + private renderDetailHeader(endpointData: LiveEndpointData, grade: HealthGrade) { return html`
- ${g.label} - ${ep.endpoint} + ${grade.label} + ${endpointData.endpoint} + ${endpointData.baselineP95Ms ? html`Baseline: ${formatDuration(endpointData.baselineP95Ms)}` : nothing}
`; } - private renderDetailMetrics(s: LiveEndpointSummary, g: HealthGrade, errors: number) { + private renderDetailMetrics(summary: LiveEndpointSummary, grade: HealthGrade, errorCount: number) { return html`
P95 - ${this.fmtMs(s.p95Ms)} + ${formatDuration(summary.p95Ms)}
Errors - - ${errors > 0 ? errors + " (" + Math.round(s.errorRate * 100) + "%)" : "0"} + + ${errorCount > 0 ? errorCount + " (" + Math.round(summary.errorRate * 100) + "%)" : "0"}
Queries/req - ${s.avgQueryCount} + ${summary.avgQueryCount}
`; } - private renderDetailBreakdown(s: LiveEndpointSummary) { - const totalAvg = (s.avgQueryTimeMs || 0) + (s.avgFetchTimeMs || 0) + (s.avgAppTimeMs || 0); - if (totalAvg <= 0) return nothing; + private renderDetailBreakdown(summary: LiveEndpointSummary) { + const totalAvgMs = (summary.avgQueryTimeMs || 0) + (summary.avgFetchTimeMs || 0) + (summary.avgAppTimeMs || 0); + if (totalAvgMs <= 0) return nothing; - const dbPct = Math.round(((s.avgQueryTimeMs || 0) / totalAvg) * 100); - const fetchPct = Math.round(((s.avgFetchTimeMs || 0) / totalAvg) * 100); + const dbPct = Math.round(((summary.avgQueryTimeMs || 0) / totalAvgMs) * 100); + const fetchPct = Math.round(((summary.avgFetchTimeMs || 0) / totalAvgMs) * 100); const appPct = Math.max(0, 100 - dbPct - fetchPct); return html` @@ -253,14 +401,104 @@ export class PerformanceView extends LitElement { ${appPct > 0 ? html`
` : nothing}
- DB ${this.fmtMs(s.avgQueryTimeMs || 0)} (${dbPct}%) - Fetch ${this.fmtMs(s.avgFetchTimeMs || 0)} (${fetchPct}%) - App ${this.fmtMs(s.avgAppTimeMs || 0)} (${appPct}%) + DB ${formatDuration(summary.avgQueryTimeMs || 0)} (${dbPct}%) + Fetch ${formatDuration(summary.avgFetchTimeMs || 0)} (${fetchPct}%) + App ${formatDuration(summary.avgAppTimeMs || 0)} (${appPct}%) +
+
+ `; + } + + private renderCallers(endpointKey: string) { + const callers = this.getCallers(endpointKey); + if (callers.length === 0) return nothing; + + return html` +
+
Called By
+
+ ${callers.map((caller) => html` +
+ ${caller.label} + ${caller.count} call${caller.count !== 1 ? "s" : ""} + avg ${formatDuration(caller.avgMs)} +
+ `)}
`; } + private renderQueryBreakdown() { + if (this.queryBreakdownLoading) { + return html`
DB Queries
Loading...
`; + } + if (this.queryBreakdown.length === 0) return nothing; + + return html` +
+
DB Queries
+
+ ${this.queryBreakdown.map((queryShape) => html` +
+ ${queryShape.label} + avg ${formatDuration(queryShape.avgMs)} + ${queryShape.count} call${queryShape.count !== 1 ? "s" : ""} +
+ `)} +
+
+ `; + } + + private renderTrends(endpointData: LiveEndpointData) { + const sessions = endpointData.sessions; + if (!sessions || sessions.length === 0) return nothing; + + const recentSessions = sessions.slice(-RECENT_SESSIONS_LIMIT); + + return html` + + `; + } + + private formatTimeAgo(timestamp: number): string { + const diffMs = Date.now() - timestamp; + const minutes = Math.round(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; + } + private renderDetailChart() { return html`
@@ -270,52 +508,58 @@ export class PerformanceView extends LitElement { `; } - private renderDetailHistory(ep: LiveEndpointData) { - if (ep.requests.length === 0) return nothing; + private renderDetailHistory(endpointData: LiveEndpointData) { + if (endpointData.requests.length === 0) return nothing; - const recent: { r: LiveRequestPoint; origIdx: number }[] = []; - for (let i = ep.requests.length - 1; i >= 0 && recent.length < 50; i--) { - recent.push({ r: ep.requests[i], origIdx: i }); + const recentRequests: { point: LiveRequestPoint; originalIndex: number }[] = []; + for (let i = endpointData.requests.length - 1; i >= 0 && recentRequests.length < HISTORY_TABLE_LIMIT; i--) { + recentRequests.push({ point: endpointData.requests[i], originalIndex: i }); } return html`
-
- Time - Health - Duration - Breakdown - Status - Queries -
- ${recent.map((item) => this.renderHistoryRow(item.r, item.origIdx))} + + + + + + + + + + + + + ${recentRequests.map((item) => this.renderHistoryRow(item.point, item.originalIndex, endpointData.baselineP95Ms))} + +
TimeHealthDurationBreakdownStatusQueries
`; } - private renderHistoryRow(r: LiveRequestPoint, origIdx: number) { - const rg = this.healthGrade(r.durationMs); - const timeStr = new Date(r.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const isError = r.statusCode >= 400; - const rDbMs = r.queryTimeMs || 0; - const rFetchMs = r.fetchTimeMs || 0; - const rAppMs = Math.max(0, r.durationMs - rDbMs - rFetchMs); + private renderHistoryRow(request: LiveRequestPoint, originalIndex: number, baseline?: number | null) { + const requestGrade = this.healthGradeForDuration(request.durationMs, baseline); + const timeStr = new Date(request.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const isError = request.statusCode >= 400; + const dbTimeMs = request.queryTimeMs || 0; + const fetchTimeMs = request.fetchTimeMs || 0; + const appTimeMs = Math.max(0, request.durationMs - dbTimeMs - fetchTimeMs); return html` -
- ${timeStr} - - ${rg.label} - - ${this.fmtMs(r.durationMs)} - - ${rDbMs > 0 ? html`DB ${this.fmtMs(rDbMs)}` : nothing} - ${rFetchMs > 0 ? html`Fetch ${this.fmtMs(rFetchMs)}` : nothing} - App ${this.fmtMs(rAppMs)} - - ${r.statusCode} - ${r.queryCount} -
+ + ${timeStr} + + ${requestGrade.label} + + ${formatDuration(request.durationMs)} + + ${dbTimeMs > 0 ? html`DB ${formatDuration(dbTimeMs)}` : nothing} + ${fetchTimeMs > 0 ? html`Fetch ${formatDuration(fetchTimeMs)}` : nothing} + App ${formatDuration(appTimeMs)} + + ${request.statusCode} + ${request.queryCount} + `; } } diff --git a/src/dashboard/styles/base.ts b/src/dashboard/styles/base.ts index 326a93f..8b0d37a 100644 --- a/src/dashboard/styles/base.ts +++ b/src/dashboard/styles/base.ts @@ -16,9 +16,10 @@ export function getBaseStyles(): string { --amber-bg:rgba(217,119,6,0.07);--red-bg:rgba(220,38,38,0.07);--blue-bg:rgba(37,99,235,0.08);--cyan-bg:rgba(8,145,178,0.07); --sidebar-width:232px;--header-height:52px; --radius:8px;--radius-sm:6px; - --shadow-sm:0 1px 2px rgba(0,0,0,0.05); - --shadow-md:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04); - --shadow-lg:0 4px 12px rgba(0,0,0,0.08),0 1px 4px rgba(0,0,0,0.04); + --shadow-sm:0 1px 3px rgba(0,0,0,0.06),0 1px 2px rgba(0,0,0,0.03); + --shadow-md:0 2px 6px rgba(0,0,0,0.08),0 1px 3px rgba(0,0,0,0.04); + --shadow-lg:0 4px 16px rgba(0,0,0,0.1),0 2px 6px rgba(0,0,0,0.05); + --transition:0.15s ease; --breakdown-db:#6366f1;--breakdown-fetch:#f59e0b;--breakdown-app:#94a3b8; --mono:'JetBrains Mono',ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace; --sans:Inter,system-ui,-apple-system,sans-serif; diff --git a/src/dashboard/styles/graph.ts b/src/dashboard/styles/graph.ts index 1fa5ee2..ca3ccfa 100644 --- a/src/dashboard/styles/graph.ts +++ b/src/dashboard/styles/graph.ts @@ -12,22 +12,39 @@ export function getPerformanceStyles(): string { .perf-badge-lg{padding:4px 12px;font-size:13px;border-radius:var(--radius-sm)} .perf-badge-sm{padding:1px 6px;font-size:9px} -/* Overview: endpoint list with inline scatter charts */ -.perf-endpoint-list{padding:16px 28px;display:flex;flex-direction:column;gap:8px} -.perf-endpoint-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px 8px;cursor:pointer;transition:all .15s;box-shadow:var(--shadow-sm)} -.perf-endpoint-card:hover{background:var(--bg-hover);border-color:var(--border-light);box-shadow:var(--shadow-md)} -.perf-ep-header{display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap} -.perf-ep-name{flex:1;font-family:var(--mono);font-size:13px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:120px} -.perf-ep-stats{display:flex;align-items:center;gap:14px;flex-shrink:0} -.perf-ep-stat{font-size:11px;font-family:var(--mono);color:var(--text-muted)} -.perf-ep-stat-err{color:var(--red)} -.perf-ep-stat-warn{color:var(--amber)} -.perf-ep-stat-muted{color:var(--text-dim)} -.perf-inline-canvas{width:100%;height:88px;border-radius:var(--radius-sm);background:var(--bg-muted);border:1px solid var(--border);display:block} +/* Overview: summary cards */ +.perf-overview{padding:16px 28px} +.perf-summary-row{display:flex;gap:8px;margin-bottom:16px} +.perf-summary-card{flex:1;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:4px;box-shadow:var(--shadow-sm)} +.perf-summary-label{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);font-family:var(--sans);font-weight:600} +.perf-summary-value{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--text)} +.perf-summary-value-sm{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* Shared table styles */ +.perf-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px} +.perf-table thead th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);font-weight:600;font-family:var(--sans);padding:10px 14px;border-bottom:2px solid var(--border);white-space:nowrap} +.perf-table tbody td{padding:11px 14px;border-bottom:1px solid var(--border-subtle);color:var(--text)} +.perf-table-row{cursor:pointer;transition:background var(--transition, .15s ease)} +.perf-table-row:hover{background:var(--bg-hover)} +.perf-table tbody tr:last-child td{border-bottom:none} +.perf-th-right{text-align:right !important} +.perf-th-center{text-align:center !important} +.perf-td-right{text-align:right} +.perf-td-center{text-align:center} +.perf-td-name{font-weight:600;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.perf-td-muted{color:var(--text-dim)} +.perf-row-err{background:var(--red-bg)} +.perf-row-err:hover{background:rgba(220,38,38,0.1)} + +/* Heat map table wrapper */ +.perf-heatmap{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow-sm)} +.perf-hm-p95{display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid} +.perf-hm-split-bar{display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--bg-muted);width:100%;min-width:80px} /* Detail view */ .perf-detail-header{padding:20px 28px 16px;border-bottom:1px solid var(--border-subtle)} .perf-detail-title{display:flex;align-items:center;gap:12px;font-size:17px;font-weight:600;color:var(--text);font-family:var(--mono)} +.perf-baseline-hint{font-size:11px;font-weight:400;color:var(--text-muted);padding:2px 8px;background:var(--bg-muted);border:1px solid var(--border);border-radius:var(--radius-sm)} .perf-metric-row{display:flex;gap:4px;padding:16px 28px;border-bottom:1px solid var(--border-subtle)} .perf-metric-card{flex:1;background:var(--bg-muted);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px 16px;display:flex;flex-direction:column;gap:4px} .perf-metric-label{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);font-family:var(--sans);font-weight:600} @@ -62,21 +79,41 @@ span.perf-breakdown-dot.perf-breakdown-app{background:var(--breakdown-app)} .perf-canvas{border-radius:var(--radius);background:var(--bg-muted);border:1px solid var(--border)} .perf-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);margin-bottom:10px} -/* Request history table */ +/* Request history */ .perf-history-wrap{padding:0 28px 20px} -.perf-history-wrap .col-header{padding:8px 0;margin:0;position:static;background:var(--bg);gap:0} -.perf-hist-row{display:flex;align-items:center;padding:10px 0;border-bottom:1px solid var(--border-subtle);font-family:var(--mono);font-size:12px} -.perf-hist-row:hover{background:var(--bg-hover);margin:0 -28px;padding-left:28px;padding-right:28px} -.perf-hist-row-err{background:rgba(220,38,38,0.04)} -.perf-hist-row-err:hover{background:rgba(220,38,38,0.08)} -.perf-hist-row-hl{background:rgba(37,99,235,0.1);margin:0 -28px;padding-left:28px;padding-right:28px;border-left:3px solid #4ade80} -.perf-hist-row-hl.perf-hist-row-err{background:rgba(220,38,38,0.12);border-left-color:#f87171} -.perf-col{flex-shrink:0;border-right:1px solid var(--border-subtle);padding-right:16px;margin-right:16px} -.perf-col:last-child{border-right:none;padding-right:0;margin-right:0} -.perf-col-date{width:100px;color:var(--text-dim)} -.perf-col-health{width:60px;display:flex;align-items:center} -.perf-col-avg{width:70px;color:var(--text)} -.perf-col-status{width:50px;text-align:center} -.perf-col-qpr{width:60px;text-align:right;color:var(--text-dim)} +.perf-hist-row-hl{background:rgba(37,99,235,0.1) !important;border-left:3px solid #4ade80} + +/* Callers section */ +.perf-callers{padding:16px 28px;border-bottom:1px solid var(--border-subtle)} +.perf-callers-list{display:flex;flex-direction:column;gap:0} +.perf-caller-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border-subtle);font-family:var(--mono);font-size:12px} +.perf-caller-row:last-child{border-bottom:none} +.perf-caller-name{flex:1;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.perf-caller-count{color:var(--text-muted);font-size:11px;flex-shrink:0} +.perf-caller-avg{color:var(--text-dim);font-size:11px;flex-shrink:0} + +/* Query breakdown section */ +.perf-queries{padding:16px 28px;border-bottom:1px solid var(--border-subtle)} +.perf-queries-loading{font-size:11px;color:var(--text-muted);font-family:var(--mono)} +.perf-queries-list{display:flex;flex-direction:column;gap:0} +.perf-query-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border-subtle);font-family:var(--mono);font-size:12px} +.perf-query-row:last-child{border-bottom:none} +.perf-query-label{flex:1;font-weight:500;color:var(--accent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.perf-query-avg{color:var(--text-muted);font-size:11px;flex-shrink:0} +.perf-query-count{color:var(--text-dim);font-size:11px;flex-shrink:0} + +/* Session trends */ +.perf-trends{padding:16px 28px;border-bottom:1px solid var(--border-subtle)} +.perf-trends-list{display:flex;flex-direction:column;gap:0} +.perf-trend-row{display:flex;align-items:center;gap:14px;padding:8px 12px;border-bottom:1px solid var(--border-subtle);font-family:var(--mono);font-size:12px} +.perf-trend-row:last-child{border-bottom:none} +.perf-trend-current{background:var(--bg-muted);border-radius:var(--radius-sm);font-weight:600} +.perf-trend-time{width:80px;color:var(--text-dim);font-size:11px;flex-shrink:0} +.perf-trend-p95{flex-shrink:0} +.perf-trend-reqs{color:var(--text-muted);font-size:11px;flex-shrink:0} +.perf-trend-errs{font-size:11px;flex-shrink:0} +.perf-trend-arrow{font-size:10px;font-weight:600;flex-shrink:0} +.perf-trend-slower{color:var(--red)} +.perf-trend-faster{color:var(--green)} `; } diff --git a/src/dashboard/styles/overview.ts b/src/dashboard/styles/overview.ts index ade986e..6c64f16 100644 --- a/src/dashboard/styles/overview.ts +++ b/src/dashboard/styles/overview.ts @@ -4,9 +4,10 @@ export function getOverviewStyles(): string { .ov-container{padding:24px 28px} /* Summary banner */ -.ov-summary{display:flex;gap:24px;padding:16px 20px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:24px;flex-wrap:wrap;box-shadow:var(--shadow-sm)} -.ov-stat{display:flex;flex-direction:column;gap:2px} -.ov-stat-value{font-size:19px;font-weight:700;font-family:var(--mono);color:var(--text)} +.ov-summary{display:flex;gap:10px;margin-bottom:24px;flex-wrap:wrap} +.ov-stat{display:flex;flex-direction:column;gap:4px;flex:1;min-width:100px;padding:16px 18px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);transition:box-shadow var(--transition, .15s ease)} +.ov-stat:hover{box-shadow:var(--shadow-md)} +.ov-stat-value{font-size:22px;font-weight:700;font-family:var(--mono);color:var(--text);line-height:1.2} .ov-stat-label{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--text-muted);font-weight:600} /* Section header */ @@ -15,8 +16,8 @@ export function getOverviewStyles(): string { /* Insight cards */ .ov-cards{display:flex;flex-direction:column;gap:8px} -.ov-card{display:flex;align-items:flex-start;gap:14px;padding:14px 18px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:all .15s;box-shadow:var(--shadow-sm)} -.ov-card:hover{background:var(--bg-hover);border-color:var(--border-light);box-shadow:var(--shadow-md)} +.ov-card{display:flex;align-items:flex-start;gap:14px;padding:16px 20px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:all var(--transition, .15s ease);box-shadow:var(--shadow-sm)} +.ov-card:hover{background:var(--bg-hover);border-color:var(--border-light);box-shadow:var(--shadow-md);transform:translateY(-1px)} .ov-card-icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:10px;border-radius:50%;margin-top:2px} .ov-card-icon.critical{background:rgba(220,38,38,.08);color:var(--red)} .ov-card-icon.warning{background:rgba(217,119,6,.08);color:var(--amber)} @@ -25,6 +26,7 @@ export function getOverviewStyles(): string { .ov-card-body{flex:1;min-width:0} .ov-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:2px} .ov-card-desc{font-size:12px;color:var(--text-dim);line-height:1.5} +.ov-card-detail{font-size:11px;font-family:var(--mono);color:var(--text-muted);margin-top:6px;padding:8px 10px;background:var(--bg-muted);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);line-height:1.5} .ov-card-desc strong{color:var(--text);font-family:var(--mono);font-weight:600} .ov-card-arrow{color:var(--text-muted);font-size:12px;flex-shrink:0;margin-top:2px;font-family:var(--mono);transition:transform .15s} diff --git a/src/store/metrics/metrics-store.ts b/src/store/metrics/metrics-store.ts index c657d1f..24a6111 100644 --- a/src/store/metrics/metrics-store.ts +++ b/src/store/metrics/metrics-store.ts @@ -14,6 +14,9 @@ import { METRICS_MAX_DATA_POINTS, MAX_UNIQUE_ENDPOINTS, MAX_ACCUMULATOR_ENTRIES, + BASELINE_MIN_SESSIONS, + BASELINE_MIN_REQUESTS_PER_SESSION, + BASELINE_PENDING_POINTS_MIN, } from "../../constants/index.js"; import { percentile } from "../../utils/math.js"; import { isErrorStatus } from "../../utils/http-status.js"; @@ -140,6 +143,67 @@ export class MetricsStore { return this.endpointIndex.get(endpoint); } + /** + * Compute the adaptive performance baseline for an endpoint. + * Returns the median p95 across historical sessions, or null when + * there isn't enough data to establish a meaningful baseline. + */ + /** + * Cached baselines — invalidated on flush (when sessions change) and + * on new request recordings (when pending points grow). Avoids recomputing + * on every getLiveEndpoints() API call. + */ + private baselineCache = new Map(); + + getEndpointBaseline(endpoint: string): number | null { + const pending = this.pendingPoints.get(endpoint); + const pointCount = pending?.length ?? 0; + const cached = this.baselineCache.get(endpoint); + + // Cache hit — return if pending point count hasn't changed + if (cached && cached.pointCount === pointCount) return cached.value; + + const value = this.computeBaseline(endpoint, pending); + this.baselineCache.set(endpoint, { value, pointCount }); + return value; + } + + private computeBaseline( + endpoint: string, + pending: LiveRequestPoint[] | undefined, + ): number | null { + const ep = this.endpointIndex.get(endpoint); + + // Multiple historical sessions → use median p95 across them + if (ep && ep.sessions.length >= BASELINE_MIN_SESSIONS) { + const validSessions = ep.sessions.filter( + (s) => s.requestCount >= BASELINE_MIN_REQUESTS_PER_SESSION, + ); + if (validSessions.length >= BASELINE_MIN_SESSIONS) { + const p95s = validSessions.map((s) => s.p95DurationMs).sort((a, b) => a - b); + return p95s[Math.floor(p95s.length / 2)]; + } + } + + // Single session that's been flushed (has enough requests) → use its p95. + // This handles the common case: app running for a while in a single session, + // pendingPoints cleared by flush, but the session's aggregate stats are valid. + if (ep && ep.sessions.length === 1) { + const session = ep.sessions[0]; + if (session.requestCount >= BASELINE_MIN_REQUESTS_PER_SESSION) { + return session.p95DurationMs; + } + } + + // No flushed sessions yet — use pending points from current session + if (pending && pending.length >= BASELINE_PENDING_POINTS_MIN) { + const warmDurations = pending.slice(1).map((r) => r.durationMs).sort((a, b) => a - b); + return warmDurations[Math.floor(warmDurations.length / 2)]; + } + + return null; + } + getLiveEndpoints(): LiveEndpointData[] { const merged = new Map(); @@ -159,29 +223,41 @@ export class MetricsStore { for (const [endpoint, requests] of merged) { if (requests.length === 0) continue; - const durations = requests.map((r) => r.durationMs); + // Exclude the first request (cold start) from aggregate stats when + // there are 2+ requests. The first request is always an outlier due + // to server compilation, connection pooling, and JIT warmup. + const warmRequests = requests.length > 1 ? requests.slice(1) : requests; + const warmDurations = warmRequests.map((r) => r.durationMs); + const errors = requests.filter((r) => isErrorStatus(r.statusCode)).length; - const totalQueries = requests.reduce((s, r) => s + r.queryCount, 0); - const totalQueryTime = requests.reduce((s, r) => s + (r.queryTimeMs ?? 0), 0); - const totalFetchTime = requests.reduce((s, r) => s + (r.fetchTimeMs ?? 0), 0); - const n = requests.length; + const totalQueries = warmRequests.reduce((s, r) => s + r.queryCount, 0); + const totalQueryTime = warmRequests.reduce((s, r) => s + (r.queryTimeMs ?? 0), 0); + const totalFetchTime = warmRequests.reduce((s, r) => s + (r.fetchTimeMs ?? 0), 0); + const n = warmRequests.length; - const avgDurationMs = Math.round(durations.reduce((s, d) => s + d, 0) / n); + const avgDurationMs = Math.round(warmDurations.reduce((s, d) => s + d, 0) / n); const avgQueryTimeMs = Math.round(totalQueryTime / n); const avgFetchTimeMs = Math.round(totalFetchTime / n); + const p95Ms = percentile(warmDurations, 0.95); + const medianMs = percentile(warmDurations, 0.5); + + const epData = this.endpointIndex.get(endpoint); endpoints.push({ endpoint, requests, summary: { - p95Ms: percentile(durations, 0.95), - errorRate: errors / n, + p95Ms, + medianMs, + errorRate: errors / requests.length, // Error rate uses ALL requests avgQueryCount: Math.round(totalQueries / n), - totalRequests: n, + totalRequests: requests.length, avgQueryTimeMs, avgFetchTimeMs, avgAppTimeMs: Math.max(0, avgDurationMs - avgQueryTimeMs - avgFetchTimeMs), }, + sessions: epData?.sessions, + baselineP95Ms: this.getEndpointBaseline(endpoint), }); } @@ -194,6 +270,7 @@ export class MetricsStore { this.endpointIndex.clear(); this.accumulators.clear(); this.pendingPoints.clear(); + this.baselineCache.clear(); this.dirty = false; this.persistence.remove(); } @@ -243,6 +320,7 @@ export class MetricsStore { epMetrics.dataPoints = existing.concat(points).slice(-METRICS_MAX_DATA_POINTS); } this.pendingPoints.clear(); + this.baselineCache.clear(); if (!this.dirty) return; diff --git a/src/types/metrics.ts b/src/types/metrics.ts index 835c5c7..98feb66 100644 --- a/src/types/metrics.ts +++ b/src/types/metrics.ts @@ -32,6 +32,7 @@ export interface LiveRequestPoint { export interface LiveEndpointSummary { p95Ms: number; + medianMs: number; errorRate: number; avgQueryCount: number; totalRequests: number; @@ -44,6 +45,8 @@ export interface LiveEndpointData { endpoint: string; requests: LiveRequestPoint[]; summary: LiveEndpointSummary; + sessions?: SessionMetric[]; + baselineP95Ms: number | null; } export interface RequestMetrics { diff --git a/src/types/security.ts b/src/types/security.ts index 3ba95af..b03ca83 100644 --- a/src/types/security.ts +++ b/src/types/security.ts @@ -9,6 +9,7 @@ export interface SecurityFinding { title: string; desc: string; hint: string; + detail?: string; endpoint: string; count: number; } diff --git a/src/utils/format.ts b/src/utils/format.ts index c9ec719..2f796df 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -12,3 +12,7 @@ export function formatSize(bytes: number): string { export function pct(part: number, total: number): number { return total > 0 ? Math.round((part / total) * 100) : 0; } + +export function plural(count: number, word: string): string { + return `${count} ${word}${count !== 1 ? "s" : ""}`; +} diff --git a/tests/analysis/insight-rules.test.ts b/tests/analysis/insight-rules.test.ts index 1b2f240..dcd5328 100644 --- a/tests/analysis/insight-rules.test.ts +++ b/tests/analysis/insight-rules.test.ts @@ -124,15 +124,26 @@ describe("duplicateRule", () => { }); describe("slowRule", () => { - it("detects slow endpoints exceeding threshold", () => { + it("detects slow endpoints exceeding adaptive baseline", () => { const runner = new InsightRunner(); runner.register(slowRule); + // Provide historical sessions so a baseline exists (median p95 = 300ms). + // Current requests at 1500-2000ms are far above 2x baseline (600ms). + const previousMetrics: EndpointMetrics[] = [{ + endpoint: "GET /api/posts", + sessions: [ + { sessionId: "s1", startedAt: 0, avgDurationMs: 250, p95DurationMs: 300, requestCount: 10, errorCount: 0, avgQueryCount: 1, avgQueryTimeMs: 50, avgFetchTimeMs: 0 }, + { sessionId: "s2", startedAt: 1000, avgDurationMs: 280, p95DurationMs: 320, requestCount: 8, errorCount: 0, avgQueryCount: 1, avgQueryTimeMs: 55, avgFetchTimeMs: 0 }, + ], + }]; + const ctx = makeCtx({ requests: [ makeRequest({ id: "req-1", url: "/api/posts", path: "/api/posts", durationMs: 1500, responseSize: 200 }), makeRequest({ id: "req-2", url: "/api/posts", path: "/api/posts", durationMs: 2000, responseSize: 200 }), ], + previousMetrics, }); const insights = runner.run(ctx);