mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(analytics): address merged review regressions
This commit is contained in:
@@ -361,17 +361,29 @@ export async function GET(request: Request) {
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// Build a UNION data source that merges recent raw rows with aggregated history.
|
||||
// daily_usage_summary rows are included only when the query window extends before rawCutoffIso.
|
||||
// The api_key filter is intentionally NOT applied to daily_usage_summary (api_key not stored there).
|
||||
const needsAggregated = !sinceIso || sinceIso < rawCutoffIso;
|
||||
// daily_usage_summary rows are included only when the query window extends before
|
||||
// rawCutoffIso. They are gated off entirely when an api_key filter is active:
|
||||
// daily_usage_summary does not store api_key/connection, so including it under a
|
||||
// key filter would leak other keys' aggregated usage. With a key filter we serve
|
||||
// only raw rows (older key-scoped data beyond retention is intentionally unavailable).
|
||||
const rawCutoffDate = rawCutoffIso.split("T")[0];
|
||||
const needsAggregated = (!sinceIso || sinceIso < rawCutoffDate) && apiKeyIds.length === 0;
|
||||
|
||||
// Raw leg: when aggregated rows are also included, lower-bound the raw leg at the
|
||||
// raw cutoff so the two legs never overlap (prevents double-counting).
|
||||
const rawConditions: string[] = [];
|
||||
if (sinceIso) rawConditions.push("timestamp >= @since");
|
||||
if (needsAggregated) {
|
||||
rawConditions.push("timestamp >= @rawCutoff");
|
||||
params.rawCutoff = rawCutoffDate;
|
||||
} else if (sinceIso) {
|
||||
rawConditions.push("timestamp >= @since");
|
||||
}
|
||||
if (untilIso) rawConditions.push("timestamp <= @until");
|
||||
if (apiKeyWhere) rawConditions.push(apiKeyWhere);
|
||||
const rawWhere = rawConditions.length > 0 ? `WHERE ${rawConditions.join(" AND ")}` : "";
|
||||
|
||||
// Aggregated rows only span dates within the requested window (no api_key filter).
|
||||
// Aggregated rows span the requested window but strictly before the raw cutoff,
|
||||
// so they never overlap the raw leg above (no api_key filter — see note above).
|
||||
const aggConditions: string[] = [];
|
||||
if (sinceIso) {
|
||||
// Use date comparison on the summary's date column (YYYY-MM-DD).
|
||||
@@ -384,6 +396,8 @@ export async function GET(request: Request) {
|
||||
aggConditions.push("date <= @untilDate");
|
||||
params.untilDate = untilDate;
|
||||
}
|
||||
aggConditions.push("date < @rawCutoffDate");
|
||||
params.rawCutoffDate = rawCutoffDate;
|
||||
const aggWhere = aggConditions.length > 0 ? `WHERE ${aggConditions.join(" AND ")}` : "";
|
||||
|
||||
// Unified source CTE: columns aligned to usage_history shape needed by analytics queries.
|
||||
@@ -1209,10 +1223,16 @@ export async function GET(request: Request) {
|
||||
const presetParams: Record<string, string> = {};
|
||||
|
||||
// Build unified source for preset cost queries (same UNION logic as main query).
|
||||
const presetNeedsAggregated = !presetSinceIso || presetSinceIso < rawCutoffIso;
|
||||
// Aggregated rows are gated off when an api_key filter is active (leakage) and
|
||||
// bounded strictly before the raw cutoff (overlap / double-count) — see main query.
|
||||
const presetNeedsAggregated =
|
||||
(!presetSinceIso || presetSinceIso < rawCutoffDate) && apiKeyIds.length === 0;
|
||||
|
||||
const presetRawConds: string[] = [];
|
||||
if (presetSinceIso) {
|
||||
if (presetNeedsAggregated) {
|
||||
presetRawConds.push("timestamp >= @presetRawCutoff");
|
||||
presetParams.presetRawCutoff = rawCutoffDate;
|
||||
} else if (presetSinceIso) {
|
||||
presetRawConds.push("timestamp >= @presetSince");
|
||||
presetParams.presetSince = presetSinceIso;
|
||||
}
|
||||
@@ -1229,6 +1249,8 @@ export async function GET(request: Request) {
|
||||
presetAggConds.push("date >= @presetSinceDate");
|
||||
presetParams.presetSinceDate = presetSinceDate;
|
||||
}
|
||||
presetAggConds.push("date < @presetRawCutoffDate");
|
||||
presetParams.presetRawCutoffDate = rawCutoffDate;
|
||||
const presetAggWhere =
|
||||
presetAggConds.length > 0 ? `WHERE ${presetAggConds.join(" AND ")}` : "";
|
||||
|
||||
@@ -1252,7 +1274,7 @@ export async function GET(request: Request) {
|
||||
FROM daily_usage_summary
|
||||
${presetAggWhere}
|
||||
)`
|
||||
: `(SELECT timestamp, provider, model, service_tier,
|
||||
: `(SELECT timestamp, provider, model, service_tier,
|
||||
tokens_input, tokens_output,
|
||||
tokens_cache_read, tokens_cache_creation, tokens_reasoning
|
||||
FROM usage_history
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function cleanupQuotaSnapshots(): Promise<CleanupResult> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffISO = cutoffDate.toISOString();
|
||||
const cutoffDateStr = cutoffISO.split("T")[0];
|
||||
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
@@ -86,22 +87,30 @@ export async function cleanupUsageHistory(): Promise<CleanupResult> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffISO = cutoffDate.toISOString();
|
||||
const cutoffDateStr = cutoffISO.split("T")[0];
|
||||
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
try {
|
||||
// Roll up rows that are about to be deleted into daily_usage_summary so that
|
||||
// the analytics route can still surface historical data via the UNION query.
|
||||
await rollupUsageHistoryBeforeDate(cutoffDateStr);
|
||||
} catch (err: unknown) {
|
||||
// Non-fatal: log but continue with deletion so cleanup still runs.
|
||||
console.error("[Cleanup] Error rolling up usage_history before deletion:", err);
|
||||
// Roll up rows that are about to be deleted into daily_usage_summary so that the
|
||||
// analytics route can still surface historical data via the UNION query. The rollup
|
||||
// uses the exact same day boundary as the DELETE below, so every deleted row
|
||||
// is guaranteed to have been aggregated first.
|
||||
//
|
||||
// rollupUsageHistoryBeforeDate catches its own errors and reports them via the
|
||||
// returned result, so we inspect that rather than relying on a thrown exception.
|
||||
// If the rollup failed, abort the DELETE to avoid permanently losing raw usage data
|
||||
// that was never aggregated.
|
||||
const rollupResult = await rollupUsageHistoryBeforeDate(cutoffDateStr);
|
||||
if (rollupResult.errors > 0) {
|
||||
console.error(
|
||||
"[Cleanup] Aborting usage_history deletion because the pre-delete rollup failed."
|
||||
);
|
||||
result.errors += rollupResult.errors;
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare("DELETE FROM usage_history WHERE timestamp < ?");
|
||||
const runResult = stmt.run(cutoffISO);
|
||||
const runResult = stmt.run(cutoffDateStr);
|
||||
result.deleted = runResult.changes;
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function rollupHourlyQuota(
|
||||
* The ON CONFLICT clause uses SUM so re-running is additive-safe: if a date already
|
||||
* has a partial rollup (e.g. from a previous partial cleanup), new rows accumulate.
|
||||
*
|
||||
* @param beforeDate - ISO date string (YYYY-MM-DD). Rows strictly before this date are rolled up.
|
||||
* @param beforeDate - ISO timestamp/date boundary. Rows strictly before this value are rolled up.
|
||||
* @returns Aggregation result with counts
|
||||
*/
|
||||
export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise<AggregationResult> {
|
||||
@@ -162,7 +162,7 @@ export async function rollupUsageHistoryBeforeDate(beforeDate: string): Promise<
|
||||
COALESCE(SUM(tokens_output), 0) as total_output_tokens,
|
||||
0.0 as total_cost
|
||||
FROM usage_history
|
||||
WHERE DATE(timestamp) < ?
|
||||
WHERE timestamp < ?
|
||||
AND provider IS NOT NULL AND provider != ''
|
||||
AND model IS NOT NULL AND model != ''
|
||||
GROUP BY LOWER(provider), LOWER(model), DATE(timestamp)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { z } from "zod";
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS, SIDEBAR_SECTIONS } from "@/shared/constants/sidebarVisibility";
|
||||
import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
|
||||
|
||||
const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const;
|
||||
@@ -38,6 +38,11 @@ export const updateSettingsSchema = z.object({
|
||||
autoRefreshProviderQuotaInterval: z.number().int().min(10).max(3600).optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
sidebarSectionOrder: z
|
||||
.array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]]))
|
||||
.optional(),
|
||||
sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(),
|
||||
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
|
||||
@@ -333,6 +333,75 @@ test("GET /api/usage/analytics includes cost by API key", async () => {
|
||||
assertClose(body.byApiKey[0].cost, body.summary.totalCost);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics does not double-count raw and aggregated rows", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 30);
|
||||
const olderDate = new Date(cutoffDate);
|
||||
olderDate.setDate(olderDate.getDate() - 1);
|
||||
const olderDateStr = olderDate.toISOString().split("T")[0];
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("openai", "gpt-4o", "raw-current", 100, 50, 1, 200, today.toISOString());
|
||||
|
||||
const insertSummary = db.prepare(
|
||||
`INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
insertSummary.run("openai", "gpt-4o", todayStr, 99, 9900, 9900, 0);
|
||||
insertSummary.run("openai", "gpt-4o", olderDateStr, 1, 25, 10, 0);
|
||||
|
||||
const response = await analyticsRoute.GET(
|
||||
makeRequest("http://localhost/api/usage/analytics?range=all")
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.summary.totalRequests, 2);
|
||||
assert.equal(body.summary.totalTokens, 185);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics omits global aggregates when filtering by API key", async () => {
|
||||
const apiKey = await apiKeysDb.createApiKey("Scoped Key", "machine1234567890");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
"openai",
|
||||
"gpt-4o",
|
||||
"scoped-conn",
|
||||
apiKey.id,
|
||||
"Scoped Key",
|
||||
100,
|
||||
50,
|
||||
1,
|
||||
200,
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO daily_usage_summary (provider, model, date, total_requests, total_input_tokens, total_output_tokens, total_cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("openai", "gpt-4o", "2024-01-01", 99, 9900, 9900, 0);
|
||||
|
||||
const response = await analyticsRoute.GET(
|
||||
makeRequest(`http://localhost/api/usage/analytics?range=all&apiKeyIds=${apiKey.id}`)
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.summary.totalRequests, 1);
|
||||
assert.equal(body.summary.totalTokens, 150);
|
||||
assert.equal(body.byApiKey.length, 1);
|
||||
assert.equal(body.byApiKey[0].apiKeyId, apiKey.id);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics groups renamed API key usage by stable ID", async () => {
|
||||
const apiKey = await apiKeysDb.createApiKey("Averyanov", "machine1234567890");
|
||||
await apiKeysDb.updateApiKeyPermissions(apiKey.id, { name: "Alexander Averyanov" });
|
||||
|
||||
Reference in New Issue
Block a user