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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### ♻️ Code Quality

- **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification.
- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture).

- **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627.
- **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77.
Expand Down
3 changes: 0 additions & 3 deletions scripts/check/check-db-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,9 @@ const KNOWN_RAW_SQL = new Set([
"src/app/api/logs/export/route.ts", // SELECT de proxy_logs
"src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo)
"src/app/api/provider-metrics/route.ts", // SELECT … FROM call_logs (agregação)
"src/app/api/search/stats/route.ts", // SELECT … FROM call_logs
"src/app/api/settings/export-json/route.ts", // SELECT * de usage_history/domain_*
"src/app/api/skills/[id]/route.ts", // UPDATE skills SET dinâmico
"src/app/api/usage/analytics/route.ts", // SELECT … FROM usage_history/daily_usage_summary
"src/app/api/v1/search/analytics/route.ts", // SELECT … FROM call_logs (request_type=search)
]);

// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
Expand Down
45 changes: 2 additions & 43 deletions src/app/api/provider-metrics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import pino from "pino";

import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";

import { getDbInstance } from "@/lib/db/core";
import { getProviderMetrics } from "@/lib/db/callLogStats";

const logger = pino({ name: "provider-metrics-api" });

type JsonRecord = Record<string, unknown>;

function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
Expand All @@ -24,46 +22,7 @@ function toNumber(value: unknown): number {
*/
export async function GET() {
try {
const db = getDbInstance();
const rows = db
.prepare(
`SELECT
c.provider,
COUNT(*) as totalRequests,
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses,
ROUND(AVG(duration)) as avgLatencyMs,
MAX(timestamp) as lastRequestAt,
MAX(
CASE
WHEN (status IS NOT NULL AND (status < 200 OR status >= 400))
OR error_summary IS NOT NULL
THEN timestamp
ELSE NULL
END
) as lastErrorAt,
(
SELECT c2.status
FROM call_logs c2
WHERE c2.provider = c.provider
ORDER BY c2.timestamp DESC, c2.id DESC
LIMIT 1
) as lastStatus,
(
SELECT c3.status
FROM call_logs c3
WHERE c3.provider = c.provider
AND (
(c3.status IS NOT NULL AND (c3.status < 200 OR c3.status >= 400))
OR c3.error_summary IS NOT NULL
)
ORDER BY c3.timestamp DESC, c3.id DESC
LIMIT 1
) as lastErrorStatus
FROM call_logs c
WHERE c.provider IS NOT NULL AND c.provider != '-'
GROUP BY c.provider`
)
.all() as JsonRecord[];
const rows = getProviderMetrics();

const metrics: Record<
string,
Expand Down
33 changes: 6 additions & 27 deletions src/app/api/search/stats/route.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,24 @@
import { NextResponse } from "next/server";
import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts";
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getSearchProviderStats, getRecentSearchLogs } from "@/lib/db/callLogStats";

export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const cache = getCacheStats();

// Provider aggregate stats — cost is per-query from registry
const providerStats = db
.prepare(
`
SELECT provider, COUNT(*) as requests,
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
FROM call_logs
WHERE request_type = 'search'
GROUP BY provider
`
)
.all();
const providerStats = getSearchProviderStats();

const providers: Record<
string,
{ requests: number; avg_latency_ms: number; total_cost: number }
> = {};
for (const row of providerStats as any[]) {
for (const row of providerStats) {
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
providers[row.provider] = {
requests: row.requests,
Expand All @@ -39,23 +28,13 @@ export async function GET(request: Request) {
}

// Recent searches
const recentRows = db
.prepare(
`
SELECT request_summary, provider, timestamp
FROM call_logs
WHERE request_type = 'search'
ORDER BY timestamp DESC
LIMIT 10
`
)
.all();
const recentRows = getRecentSearchLogs();

const recent_searches = (recentRows as any[]).map((row) => {
const recent_searches = recentRows.map((row) => {
let query = "";
let filters = {};
try {
const summary = JSON.parse(row.request_summary);
const summary = JSON.parse(row.request_summary ?? "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using row.request_summary ?? "" fallback results in JSON.parse("") when request_summary is null or empty, which throws a SyntaxError and triggers the catch block. Since throwing exceptions is computationally expensive, using "{}" as the fallback avoids throwing an error entirely and cleanly defaults to an empty object.

Suggested change
const summary = JSON.parse(row.request_summary ?? "");
const summary = JSON.parse(row.request_summary ?? "{}");

query = summary.query || "";
filters = summary.filters || {};
} catch {
Expand Down
32 changes: 3 additions & 29 deletions src/app/api/v1/search/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,20 @@

import { NextResponse } from "next/server";
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { getSearchAggregateStats, getSearchProviderCounts } from "@/lib/db/callLogStats";

export async function GET(req: Request) {
const policy = await enforceApiKeyPolicy(req, "analytics");
if (policy.rejection) return policy.rejection;

try {
const db = getDbInstance();

// Single aggregated query for all scalar metrics — replaces 5 separate round-trips
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayIso = todayStart.toISOString();

type StatsRow = {
total: number;
today: number;
errors: number;
avg_duration: number | null;
cached: number;
};
const statsRow = db
.prepare(
`SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today,
COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors,
AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration,
COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached
FROM call_logs
WHERE request_type = 'search'`
)
.get(todayIso) as StatsRow | undefined;
const statsRow = getSearchAggregateStats(todayIso);

const total = statsRow?.total ?? 0;
const today = statsRow?.today ?? 0;
Expand All @@ -49,13 +29,7 @@ export async function GET(req: Request) {
const cached = statsRow?.cached ?? 0;

// Per-provider breakdown
const provRows = db
.prepare(
`SELECT provider, COUNT(*) as cnt
FROM call_logs WHERE request_type = 'search'
GROUP BY provider ORDER BY cnt DESC`
)
.all() as Array<{ provider: string; cnt: number }>;
const provRows = getSearchProviderCounts();

const byProvider: Record<string, { count: number; costUsd: number }> = {};
let totalCostUsd = 0;
Expand Down
181 changes: 181 additions & 0 deletions src/lib/db/callLogStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { getDbInstance } from "./core";

/**
* Aggregation queries over `call_logs` extracted from route handlers.
*
* Hard Rule #5: routes must not embed raw SQL — these queries live here so the
* /api/provider-metrics, /api/search/stats, and /api/v1/search/analytics routes
* can delegate. Read-only aggregation; no writes.
*
* Sliced out of #3500 (call_logs cluster).
*/

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ProviderMetricRow {
provider: string;
totalRequests: number;
totalSuccesses: number;
avgLatencyMs: number;
lastRequestAt: string | null;
lastErrorAt: string | null;
lastStatus: number | null;
lastErrorStatus: number | null;
}

export interface SearchProviderStatRow {
provider: string;
requests: number;
avg_latency_ms: number;
}

export interface SearchRecentRow {
request_summary: string | null;
provider: string;
timestamp: string;
}

export interface SearchAggregateStats {
total: number;
today: number;
errors: number;
avg_duration: number | null;
cached: number;
}

export interface SearchProviderCountRow {
provider: string;
cnt: number;
}

// ---------------------------------------------------------------------------
// /api/provider-metrics — aggregate per-provider stats
// ---------------------------------------------------------------------------

/**
* Returns one row per provider with call-level aggregates plus last-status
* subselects. Excludes rows where provider is NULL or '-'.
*/
export function getProviderMetrics(): ProviderMetricRow[] {
const db = getDbInstance();
return db
.prepare(
`SELECT
c.provider,
COUNT(*) as totalRequests,
SUM(CASE WHEN status >= 200 AND status < 400 THEN 1 ELSE 0 END) as totalSuccesses,
ROUND(AVG(duration)) as avgLatencyMs,
MAX(timestamp) as lastRequestAt,
MAX(
CASE
WHEN (status IS NOT NULL AND (status < 200 OR status >= 400))
OR error_summary IS NOT NULL
THEN timestamp
ELSE NULL
END
) as lastErrorAt,
(
SELECT c2.status
FROM call_logs c2
WHERE c2.provider = c.provider
ORDER BY c2.timestamp DESC, c2.id DESC
LIMIT 1
) as lastStatus,
(
SELECT c3.status
FROM call_logs c3
WHERE c3.provider = c.provider
AND (
(c3.status IS NOT NULL AND (c3.status < 200 OR c3.status >= 400))
OR c3.error_summary IS NOT NULL
)
ORDER BY c3.timestamp DESC, c3.id DESC
LIMIT 1
) as lastErrorStatus
FROM call_logs c
WHERE c.provider IS NOT NULL AND c.provider != '-'
GROUP BY c.provider`
)
.all() as ProviderMetricRow[];
}

// ---------------------------------------------------------------------------
// /api/search/stats — search provider aggregates + recent entries
// ---------------------------------------------------------------------------

/**
* Per-provider request count and average latency for search requests.
*/
export function getSearchProviderStats(): SearchProviderStatRow[] {
const db = getDbInstance();
return db
.prepare(
`
SELECT provider, COUNT(*) as requests,
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
FROM call_logs
WHERE request_type = 'search'
GROUP BY provider
`
)
.all() as SearchProviderStatRow[];
}

/**
* Most recent 10 search entries (request_summary + provider + timestamp).
*/
export function getRecentSearchLogs(): SearchRecentRow[] {
const db = getDbInstance();
return db
.prepare(
`
SELECT request_summary, provider, timestamp
FROM call_logs
WHERE request_type = 'search'
ORDER BY timestamp DESC
LIMIT 10
`
)
.all() as SearchRecentRow[];
}

// ---------------------------------------------------------------------------
// /api/v1/search/analytics — aggregated search analytics
// ---------------------------------------------------------------------------

/**
* Single-pass scalar aggregations for all search entries since `todayIso`.
* `todayIso` is the ISO-8601 UTC start-of-day string used for the "today" count.
*/
export function getSearchAggregateStats(todayIso: string): SearchAggregateStats {
const db = getDbInstance();
const row = db
.prepare(
`SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today,
COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors,
AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration,
COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached
FROM call_logs
WHERE request_type = 'search'`
)
.get(todayIso) as SearchAggregateStats | undefined;
return row ?? { total: 0, today: 0, errors: 0, avg_duration: null, cached: 0 };
}

/**
* Per-provider request count for search entries, ordered by count descending.
*/
export function getSearchProviderCounts(): SearchProviderCountRow[] {
const db = getDbInstance();
return db
.prepare(
`SELECT provider, COUNT(*) as cnt
FROM call_logs WHERE request_type = 'search'
GROUP BY provider ORDER BY cnt DESC`
)
.all() as SearchProviderCountRow[];
}
Loading