Skip to content

refactor(activity-log): use db aggregation - #924

Merged
steebchen merged 3 commits into
mainfrom
terragon/move-log-queries-to-db
Sep 23, 2025
Merged

steebchen merged 3 commits into
mainfrom
terragon/move-log-queries-to-db

Conversation

@steebchen

@steebchen steebchen commented Sep 23, 2025

Copy link
Copy Markdown
Member

Summary

  • Refactors activity log queries to perform aggregation directly in the database
  • Replaces raw log fetching and in-memory aggregation with optimized SQL queries
  • Improves performance and reduces memory usage by leveraging SQL grouping and aggregation

Changes

Backend API

  • Updated /activity route to query daily aggregated data from the logs table using SQL aggregation functions
  • Aggregates request counts, token usage, costs, error counts, and cache counts grouped by date
  • Added a separate query for model breakdown data grouped by date, model, and provider
  • Replaced manual aggregation logic in TypeScript with direct mapping of SQL results
  • Calculates error and cache rates based on aggregated counts

Code Cleanup

  • Removed old raw log fetching and manual aggregation loops
  • Simplified data structures by using maps keyed by date for model breakdowns
  • Ensured consistent ordering of results by date and model

Test plan

  • Verify activity endpoint returns correct aggregated data for date ranges
  • Confirm model breakdowns are accurate and grouped properly
  • Check error and cache rate calculations
  • Validate no regressions in activity data display in frontend

🌿 Generated by Terry


ℹ️ Tag @terragon-labs to ask questions and address PR feedback

📎 Task: https://www.terragonlabs.com/task/8cf41406-6317-4286-be78-157eb092c47e

Summary by CodeRabbit

  • New Features

    • Activity reports now include per-day breakdowns by model and provider.
    • Expanded daily metrics: request counts, input/output/total tokens, total and per-type costs, error/cache counts, and error/cache rates.
    • Response preserves existing API shape while delivering richer, pre-aggregated data.
  • Refactor

    • Switched to server-side daily aggregation for activity data to improve performance and scalability.
  • Tests

    • Added end-to-end aggregation tests validating daily totals and per-day breakdowns.

@bunnyshell

bunnyshell Bot commented Sep 23, 2025

Copy link
Copy Markdown

❌ Preview Environment deleted from Bunnyshell

Available commands (reply to this comment):

  • 🚀 /bns:deploy to deploy the environment

@coderabbitai

coderabbitai Bot commented Sep 23, 2025

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@steebchen has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 6 minutes and 7 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between cc6716a and 3724396.

📒 Files selected for processing (1)
  • apps/api/src/routes/activity.ts (2 hunks)

Walkthrough

Replaces per-log in-memory aggregation with database-level daily and model/provider aggregation queries, maps aggregate rows into per-day activity objects (counts, tokens, costs, error/cache metrics, model breakdowns), and returns an activity array with pre-aggregated data.

Changes

Cohort / File(s) Summary of changes
API Activity Route Aggregation
apps/api/src/routes/activity.ts
Replaced per-log in-memory aggregation with SQL-level aggregates: dailyAggregates (group by DATE) and modelBreakdowns (group by DATE, model, provider) using db.select + SQL fragments; applied project ID and date-range filters; rebuilt per-day objects including request/input/output tokens, costs, error/cache counts and rates, and per-day model breakdown arrays; response returns activity with aggregated fields.
Activity route tests
apps/api/src/routes/activity.spec.ts
Added end-to-end test "GET /activity should correctly aggregate token counts" that clears logs, inserts sample logs (today and yesterday), calls /activity, and asserts aggregated totals and per-day breakdowns. (Note: identical test block appears duplicated in the diff.)

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Client
  participant API as Activity Route
  participant DB as Database

  Client->>API: GET /activity?projectIds=&days=
  API->>DB: Query dailyAggregates (SELECT DATE(...), COUNT(...), SUM(...) GROUP BY DATE)
  DB-->>API: dailyAggregates rows
  API->>DB: Query modelBreakdowns (SELECT DATE(...), usedModel, usedProvider, COUNT, SUM GROUP BY DATE, model, provider)
  DB-->>API: modelBreakdowns rows

  rect rgba(200,230,255,0.6)
  note right of API: Merge DB rows by date → build per-day objects:\n- counts, tokens, costs\n- error/cache metrics (rates)\n- modelBreakdown array per date
  end

  API-->>Client: 200 OK { "activity": [ ...per-day aggregated objects... ] }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

codex

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "refactor(activity-log): use db aggregation" is concise, clearly describes the primary change (moving activity-log aggregation to the database), and aligns directly with the PR objectives and code changes.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot changed the title Refactor activity log queries to use database-level aggregation refactor(activity-log): use db aggregation Sep 23, 2025

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/api/src/routes/activity.ts (3)

130-176: Use UTC day buckets and ensure numeric sums serialize as numbers

  • DATE() without an explicit timezone can shift day buckets by server/session TZ. Prefer normalizing to UTC.
  • Postgres NUMERIC sums often come back as strings in pg. Cast to double precision to guarantee JSON numbers.

Apply:

-      date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
+      date: sql<string>`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC')`.as("date"),
@@
-      inputTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
+      inputTokens:
+        sql<number>`COALESCE(SUM(${tables.log.promptTokens}::double precision), 0)`.as(
           "inputTokens",
         ),
@@
-      outputTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
+      outputTokens:
+        sql<number>`COALESCE(SUM(${tables.log.completionTokens}::double precision), 0)`.as(
           "outputTokens",
         ),
@@
-      totalTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
+      totalTokens:
+        sql<number>`COALESCE(SUM(${tables.log.totalTokens}::double precision), 0)`.as(
           "totalTokens",
         ),
-      cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
-      inputCost: sql<number>`COALESCE(SUM(${tables.log.inputCost}), 0)`.as(
+      cost: sql<number>`COALESCE(SUM(${tables.log.cost}::double precision), 0)`.as("cost"),
+      inputCost: sql<number>`COALESCE(SUM(${tables.log.inputCost}::double precision), 0)`.as(
         "inputCost",
       ),
-      outputCost: sql<number>`COALESCE(SUM(${tables.log.outputCost}), 0)`.as(
+      outputCost: sql<number>`COALESCE(SUM(${tables.log.outputCost}::double precision), 0)`.as(
         "outputCost",
       ),
-      requestCost: sql<number>`COALESCE(SUM(${tables.log.requestCost}), 0)`.as(
+      requestCost: sql<number>`COALESCE(SUM(${tables.log.requestCost}::double precision), 0)`.as(
         "requestCost",
       ),
@@
-    .groupBy(sql`DATE(${tables.log.createdAt})`)
-    .orderBy(sql`DATE(${tables.log.createdAt}) ASC`);
+    .groupBy(sql`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC')`)
+    .orderBy(sql`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC') ASC`);

Optional (outside this hunk): consider normalizing JS bounds to UTC midnight for exact day inclusion.


177-211: Align date bucketing with UTC and stabilize ordering by provider as well

  • Keep model breakdown date bucketing consistent with daily aggregates (UTC).
  • Include provider in ORDER BY for deterministic modelBreakdown array ordering.
  • Same note on casting sums to double precision as above.
-      date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
+      date: sql<string>`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC')`.as("date"),
@@
-      inputTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
+      inputTokens:
+        sql<number>`COALESCE(SUM(${tables.log.promptTokens}::double precision), 0)`.as(
           "inputTokens",
         ),
@@
-      outputTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
+      outputTokens:
+        sql<number>`COALESCE(SUM(${tables.log.completionTokens}::double precision), 0)`.as(
           "outputTokens",
         ),
@@
-      totalTokens:
-        sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
+      totalTokens:
+        sql<number>`COALESCE(SUM(${tables.log.totalTokens}::double precision), 0)`.as(
           "totalTokens",
         ),
-      cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
+      cost: sql<number>`COALESCE(SUM(${tables.log.cost}::double precision), 0)`.as("cost"),
@@
-    .groupBy(
-      sql`DATE(${tables.log.createdAt}), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
-    )
+    .groupBy(
+      sql`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC'), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
+    )
@@
-    .orderBy(
-      sql`DATE(${tables.log.createdAt}) ASC, ${tables.log.usedModel} ASC`,
-    );
+    .orderBy(
+      sql`DATE(${tables.log.createdAt} AT TIME ZONE 'UTC') ASC, ${tables.log.usedModel} ASC, ${tables.log.usedProvider} ASC`,
+    );

130-211: Add supporting indexes to keep these aggregations fast at scale

Recommend composite indexes:

  • logs(project_id, created_at)
  • logs(project_id, created_at, used_model, used_provider)

This will speed up both WHERE and GROUP BY for the new queries.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between de7d082 and 6e6e8a7.

📒 Files selected for processing (1)
  • apps/api/src/routes/activity.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
{apps/api,apps/gateway,apps/ui,apps/docs,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always use top-level import; never use require() or dynamic imports (e.g., import(), next/dynamic)

Files:

  • apps/api/src/routes/activity.ts
{apps/api,apps/gateway,packages/db}/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

{apps/api,apps/gateway,packages/db}/**/*.ts: Use Drizzle ORM with the latest object syntax for database access
For reads, use db().query.

.findMany() or db().query.
.findFirst()

Files:

  • apps/api/src/routes/activity.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Never use any or as any in this TypeScript project unless absolutely necessary
Always use top-level import; never use require or dynamic imports

Files:

  • apps/api/src/routes/activity.ts
{apps/api,apps/gateway}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

For reads, use db().query.

.findMany() or db().query.
.findFirst()

Files:

  • apps/api/src/routes/activity.ts
🧬 Code graph analysis (1)
apps/api/src/routes/activity.ts (2)
packages/db/src/db.ts (1)
  • db (13-17)
packages/db/src/index.ts (1)
  • tables (13-15)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: build / run
  • GitHub Check: test / run
  • GitHub Check: lint / run
  • GitHub Check: generate / run
  • GitHub Check: autofix
🔇 Additional comments (2)
apps/api/src/routes/activity.ts (2)

5-5: LGTM on imports

Top-level imports look correct and align with project guidelines.


233-256: Verify day coverage and rate units

  • This returns only days that have logs. If the UI expects a contiguous series with zero-filled days, we need to fill gaps.
  • Confirm whether errorRate/cacheRate should be 0–100 (%) as implemented or 0–1 (fraction).

Comment thread apps/api/src/routes/activity.ts
steebchen and others added 2 commits September 23, 2025 22:26
- Replace raw log fetching and in-memory aggregation with SQL-level aggregation
- Aggregate daily activity and model breakdown data directly in the database
- Simplify data processing by mapping aggregated results to response format
- Improve performance and reduce memory usage for activity data queries

Co-authored-by: terragon-labs[bot] <terragon-labs[bot]@users.noreply.github.com>
- Added comprehensive test for `/activity` endpoint to validate token aggregation and daily statistics.
- Ensured proper handling of input/output tokens, total cost, and daily data breakdown in the response.
- Fixed type inconsistency in `activity.ts` by converting string fields to numbers.
@steebchen
steebchen force-pushed the terragon/move-log-queries-to-db branch from 3ebcb4e to cc6716a Compare September 23, 2025 21:51
- Replaced `typeof` with `z.infer` for improved type consistency.
@steebchen
steebchen enabled auto-merge September 23, 2025 21:58

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/activity.ts (1)

46-50: Stricter validation for days: don’t accept strings like "7d"

parseInt("7d", 10) -> 7, which passes .int().positive(). Use a digit‑only regex or z.coerce.number with refinement to reject junk.

- days: z
-   .string()
-   .transform((val) => parseInt(val, 10))
-   .pipe(z.number().int().positive()),
+ days: z.string().regex(/^\d+$/).transform((val) => Number(val)),
🧹 Nitpick comments (5)
apps/api/src/routes/activity.spec.ts (2)

249-392: Make the test deterministic: freeze time to avoid date-window flakiness

The test derives “today/yesterday” dynamically while the API groups by DATE(). Around midnight or with TZ differences this can flake. Freeze the clock.

Apply within this test:

 test("GET /activity should correctly aggregate token counts", async () => {
-		// Clear existing logs and insert test data with known values
+		// Freeze time for deterministic DATE() bucketing
+		vi.useFakeTimers();
+		vi.setSystemTime(new Date("2024-01-01T12:00:00Z"));
+
+		// Clear existing logs and insert test data with known values
 		await db.delete(tables.log);
@@
-	});
+		vi.useRealTimers();
+	});

Also ensure vi is imported:

// at top-level import
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

349-368: Avoid any in TS tests: type daily entries

Replace any with a minimal type to comply with repo guidelines and improve safety.

-const totalRequests = data.activity.reduce(
-  (sum: number, day: any) => sum + day.requestCount,
+type ActivityDay = {
+  requestCount: number;
+  totalTokens: number;
+  inputTokens: number;
+  outputTokens: number;
+  cost: number;
+};
+
+const totalRequests = data.activity.reduce(
+  (sum: number, day: ActivityDay) => sum + day.requestCount,
   0,
 );
-const totalTokens = data.activity.reduce(
-  (sum: number, day: any) => sum + day.totalTokens,
+const totalTokens = data.activity.reduce(
+  (sum: number, day: ActivityDay) => sum + day.totalTokens,
   0,
 );
-const totalInputTokens = data.activity.reduce(
-  (sum: number, day: any) => sum + day.inputTokens,
+const totalInputTokens = data.activity.reduce(
+  (sum: number, day: ActivityDay) => sum + day.inputTokens,
   0,
 );
-const totalOutputTokens = data.activity.reduce(
-  (sum: number, day: any) => sum + day.outputTokens,
+const totalOutputTokens = data.activity.reduce(
+  (sum: number, day: ActivityDay) => sum + day.outputTokens,
   0,
 );
-const totalCost = data.activity.reduce(
-  (sum: number, day: any) => sum + day.cost,
+const totalCost = data.activity.reduce(
+  (sum: number, day: ActivityDay) => sum + day.cost,
   0,
 );
apps/api/src/routes/activity.ts (3)

167-173: Normalize the date range to day boundaries to match day-level grouping

Currently startDate is “now − days” at current time, causing a partial first day. Normalize to [startOfDay, endOfDay] to avoid off‑by‑one surprises.

Add right after computing dates:

// Normalize to day boundaries
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);

130-176: Project guideline alignment: prefer query API or a DB view for aggregates

Guidelines recommend db().query.<table>.findMany() for reads. For complex aggregates, consider defining a SQL view in packages/db and query it via db.query.<view>.findMany() to keep call sites consistent.


167-176: Add covering indexes to support the new aggregations

To keep scans fast under load, add:

  • logs(project_id, created_at)
  • logs(project_id, created_at, used_model, used_provider)

Optionally partial index if used_model/used_provider can be null. Consider concurrent creation and migration notes.

Also applies to: 200-205

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e6e8a7 and cc6716a.

📒 Files selected for processing (2)
  • apps/api/src/routes/activity.spec.ts (1 hunks)
  • apps/api/src/routes/activity.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
{apps/api,apps/gateway,apps/ui,apps/docs,packages}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always use top-level import; never use require() or dynamic imports (e.g., import(), next/dynamic)

Files:

  • apps/api/src/routes/activity.spec.ts
  • apps/api/src/routes/activity.ts
{apps/api,apps/gateway,packages/db}/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

{apps/api,apps/gateway,packages/db}/**/*.ts: Use Drizzle ORM with the latest object syntax for database access
For reads, use db().query..findMany() or db().query.

.findFirst()

Files:

  • apps/api/src/routes/activity.spec.ts
  • apps/api/src/routes/activity.ts
**/*.spec.ts

📄 CodeRabbit inference engine (AGENTS.md)

Place unit tests in files named *.spec.ts

Unit test files must be named with the .spec.ts suffix

Files:

  • apps/api/src/routes/activity.spec.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Never use any or as any in this TypeScript project unless absolutely necessary
Always use top-level import; never use require or dynamic imports

Files:

  • apps/api/src/routes/activity.spec.ts
  • apps/api/src/routes/activity.ts
{apps/api,apps/gateway}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

For reads, use db().query.

.findMany() or db().query.
.findFirst()

Files:

  • apps/api/src/routes/activity.spec.ts
  • apps/api/src/routes/activity.ts
🧬 Code graph analysis (2)
apps/api/src/routes/activity.spec.ts (3)
packages/db/src/db.ts (1)
  • db (13-17)
packages/db/src/index.ts (1)
  • tables (13-15)
apps/api/src/index.ts (1)
  • app (36-36)
apps/api/src/routes/activity.ts (2)
packages/db/src/db.ts (1)
  • db (13-17)
packages/db/src/index.ts (1)
  • tables (13-15)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: build / run
  • GitHub Check: test / run
  • GitHub Check: generate / run
  • GitHub Check: lint / run
  • GitHub Check: autofix
🔇 Additional comments (2)
apps/api/src/routes/activity.spec.ts (1)

249-392: Nice E2E coverage for aggregation

Good assertions on totals and per-day splits. Consider also asserting per‑day cost to fully cover the new cost aggregation.

apps/api/src/routes/activity.ts (1)

213-217: Fix Zod type extraction: use z.infer; schema._type isn’t public API

Replace internal _type usage with z.infer for stability.

-const modelBreakdownByDate = new Map<
-  string,
-  (typeof modelUsageSchema._type)[]
->();
+const modelBreakdownByDate = new Map<string, z.infer<typeof modelUsageSchema>[]>();

Comment on lines +133 to +176
date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
inputCost: sql<number>`COALESCE(SUM(${tables.log.inputCost}), 0)`.as(
"inputCost",
),
outputCost: sql<number>`COALESCE(SUM(${tables.log.outputCost}), 0)`.as(
"outputCost",
),
requestCost: sql<number>`COALESCE(SUM(${tables.log.requestCost}), 0)`.as(
"requestCost",
),
errorCount:
sql<number>`SUM(CASE WHEN ${tables.log.hasError} = true THEN 1 ELSE 0 END)`.as(
"errorCount",
),
cacheCount:
sql<number>`SUM(CASE WHEN ${tables.log.cached} = true THEN 1 ELSE 0 END)`.as(
"cacheCount",
),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(sql`DATE(${tables.log.createdAt})`)
.orderBy(sql`DATE(${tables.log.createdAt}) ASC`);

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.

⚠️ Potential issue

TZ-safe day bucketing: avoid DATE(timestamp) ambiguity

DATE(createdAt) is session-TZ dependent and can mis-bucket around midnight. Use a UTC-normalized day key consistently in SELECT/GROUP BY/ORDER BY.

-  date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
+  date: sql<string>`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`.as("date"),
@@
-  .groupBy(sql`DATE(${tables.log.createdAt})`)
-  .orderBy(sql`DATE(${tables.log.createdAt}) ASC`);
+  .groupBy(sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`)
+  .orderBy(sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') ASC`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
inputCost: sql<number>`COALESCE(SUM(${tables.log.inputCost}), 0)`.as(
"inputCost",
),
outputCost: sql<number>`COALESCE(SUM(${tables.log.outputCost}), 0)`.as(
"outputCost",
),
requestCost: sql<number>`COALESCE(SUM(${tables.log.requestCost}), 0)`.as(
"requestCost",
),
errorCount:
sql<number>`SUM(CASE WHEN ${tables.log.hasError} = true THEN 1 ELSE 0 END)`.as(
"errorCount",
),
cacheCount:
sql<number>`SUM(CASE WHEN ${tables.log.cached} = true THEN 1 ELSE 0 END)`.as(
"cacheCount",
),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(sql`DATE(${tables.log.createdAt})`)
.orderBy(sql`DATE(${tables.log.createdAt}) ASC`);
date: sql<string>`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`.as("date"),
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
inputCost: sql<number>`COALESCE(SUM(${tables.log.inputCost}), 0)`.as(
"inputCost",
),
outputCost: sql<number>`COALESCE(SUM(${tables.log.outputCost}), 0)`.as(
"outputCost",
),
requestCost: sql<number>`COALESCE(SUM(${tables.log.requestCost}), 0)`.as(
"requestCost",
),
errorCount:
sql<number>`SUM(CASE WHEN ${tables.log.hasError} = true THEN 1 ELSE 0 END)`.as(
"errorCount",
),
cacheCount:
sql<number>`SUM(CASE WHEN ${tables.log.cached} = true THEN 1 ELSE 0 END)`.as(
"cacheCount",
),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`)
.orderBy(sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') ASC`);
🤖 Prompt for AI Agents
In apps/api/src/routes/activity.ts around lines 133 to 176, the query uses
DATE(${tables.log.createdAt}) which is session-timezone dependent and can
mis-bucket records around midnight; change the SELECT, GROUP BY and ORDER BY to
use a UTC-normalized day key (e.g., CAST(${tables.log.createdAt} AT TIME ZONE
'UTC' AS DATE) or date_trunc('day', timezone('UTC', ${tables.log.createdAt}))
cast to date) and reference that exact same expression everywhere so the day
bucketing is TZ-safe and consistent across SELECT, GROUP BY, and ORDER BY.

Comment on lines +179 to +211
.select({
date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
usedModel: tables.log.usedModel,
usedProvider: tables.log.usedProvider,
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(
sql`DATE(${tables.log.createdAt}), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
)
.orderBy(
sql`DATE(${tables.log.createdAt}) ASC, ${tables.log.usedModel} ASC`,
);

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.

⚠️ Potential issue

Apply the same UTC-normalized bucketing to model breakdowns

Keep the grouping key identical to daily aggregates to ensure joins by date don’t drift across TZ boundaries.

-  date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
+  date: sql<string>`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`.as("date"),
@@
-  .groupBy(
-    sql`DATE(${tables.log.createdAt}), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
-  )
-  .orderBy(
-    sql`DATE(${tables.log.createdAt}) ASC, ${tables.log.usedModel} ASC`,
-  );
+  .groupBy(
+    sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD'), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
+  )
+  .orderBy(
+    sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') ASC, ${tables.log.usedModel} ASC`,
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.select({
date: sql<string>`DATE(${tables.log.createdAt})`.as("date"),
usedModel: tables.log.usedModel,
usedProvider: tables.log.usedProvider,
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(
sql`DATE(${tables.log.createdAt}), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
)
.orderBy(
sql`DATE(${tables.log.createdAt}) ASC, ${tables.log.usedModel} ASC`,
);
.select({
date: sql<string>`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD')`.as("date"),
usedModel: tables.log.usedModel,
usedProvider: tables.log.usedProvider,
requestCount: sql<number>`COUNT(*)`.as("requestCount"),
inputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.promptTokens} AS NUMERIC)), 0)`.as(
"inputTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.completionTokens} AS NUMERIC)), 0)`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${tables.log.totalTokens} AS NUMERIC)), 0)`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${tables.log.cost}), 0)`.as("cost"),
})
.from(tables.log)
.where(
and(
inArray(tables.log.projectId, projectIds),
gte(tables.log.createdAt, startDate),
lte(tables.log.createdAt, endDate),
),
)
.groupBy(
sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD'), ${tables.log.usedModel}, ${tables.log.usedProvider}`,
)
.orderBy(
sql`to_char((${tables.log.createdAt} AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') ASC, ${tables.log.usedModel} ASC`,
);
🤖 Prompt for AI Agents
In apps/api/src/routes/activity.ts around lines 179 to 211, the model-breakdown
query is grouping by DATE(${tables.log.createdAt}) which can differ from the
daily-aggregates date bucket when time zone normalization is applied; update
both the selected date and the GROUP BY key to use the same UTC-normalized date
expression used by the daily aggregates (e.g. DATE(${tables.log.createdAt} AT
TIME ZONE 'UTC') or the project’s canonical UTC normalization function) so the
grouping key matches exactly and joins by date won’t drift across time zones.

@steebchen
steebchen added this pull request to the merge queue Sep 23, 2025
Merged via the queue into main with commit 46470b9 Sep 23, 2025
11 checks passed
@steebchen
steebchen deleted the terragon/move-log-queries-to-db branch September 23, 2025 22:04
@coderabbitai coderabbitai Bot mentioned this pull request Feb 24, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant