-
Notifications
You must be signed in to change notification settings - Fork 4k
feat(web): usage page showing what your agents actually cost #5619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
98e451c
575d03f
8afa8ee
ae37ed7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import type { EnvironmentUsageSnapshot } from "@t3tools/contracts"; | ||
| import * as Clock from "effect/Clock"; | ||
| import * as Config from "effect/Config"; | ||
| import * as Context from "effect/Context"; | ||
| import * as DateTime from "effect/DateTime"; | ||
| import * as Duration from "effect/Duration"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
|
|
||
| import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| import { readActivitySummary } from "./activityUsage.ts"; | ||
| import { readLocalAgentUsage } from "./localAgentUsage.ts"; | ||
|
|
||
| /** Scans cost real IO, so a snapshot is reused briefly rather than recomputed per request. */ | ||
| const CACHE_TTL_MILLIS = Duration.toMillis(Duration.seconds(60)); | ||
| const DEFAULT_WINDOW_DAYS = 30; | ||
| const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; | ||
| /** Distinct windows a client realistically asks for; anything past this evicts oldest-first. */ | ||
| const MAX_CACHE_ENTRIES = 8; | ||
|
|
||
| const emptyTokens = { | ||
| inputTokens: 0, | ||
| outputTokens: 0, | ||
| cacheReadTokens: 0, | ||
| cacheWriteTokens: 0, | ||
| reasoningTokens: 0, | ||
| } as const; | ||
|
|
||
| export class UsageService extends Context.Service< | ||
| UsageService, | ||
| { | ||
| readonly getSnapshot: ( | ||
| sinceDate: string | undefined, | ||
| ) => Effect.Effect<EnvironmentUsageSnapshot>; | ||
| } | ||
| >()("t3/usage/UsageService") {} | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | ||
| // Agent session logs live under the OS home, not the server's base directory, | ||
| // so isolating them needs an explicit override. Tests and sandboxed runs set | ||
| // this; in normal operation it is absent and the OS home is used. | ||
| const agentLogHome = yield* Config.string("T3CODE_AGENT_LOG_HOME").pipe(Config.option); | ||
| // Captured once so the returned effects carry no outstanding requirements. | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| // Keyed by the resolved window so a narrower request cannot serve a wider | ||
| // cached answer, or the reverse. | ||
| const cache = new Map< | ||
| string, | ||
| { readonly at: number; readonly snapshot: EnvironmentUsageSnapshot } | ||
| >(); | ||
|
|
||
| /** Snapshot plus whether the log scan actually succeeded. */ | ||
| const build = ( | ||
| sinceDate: string, | ||
| ): Effect.Effect<{ snapshot: EnvironmentUsageSnapshot; complete: boolean }> => | ||
| Effect.gen(function* () { | ||
| const descriptor = yield* serverEnvironment.getDescriptor; | ||
| const generatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| const activity = yield* readActivitySummary(`${sinceDate}T00:00:00.000Z`).pipe( | ||
| Effect.provideService(SqlClient.SqlClient, sql), | ||
| ); | ||
| const local = yield* Effect.tryPromise(() => | ||
| readLocalAgentUsage({ | ||
| sinceDate, | ||
| ...(Option.isSome(agentLogHome) ? { homeDir: agentLogHome.value } : {}), | ||
| }), | ||
| ).pipe( | ||
| Effect.catchCause((cause) => | ||
| Effect.logWarning("Failed to read local agent usage logs", { cause }).pipe( | ||
| Effect.as(undefined), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| const snapshot = { | ||
| environmentId: descriptor.environmentId, | ||
| generatedAt, | ||
| firstDate: local?.firstDate, | ||
| lastDate: local?.lastDate, | ||
| costUsd: local?.costUsd ?? 0, | ||
| tokens: local?.tokens ?? emptyTokens, | ||
| messages: local?.messages ?? 0, | ||
| sessions: local?.sessions ?? 0, | ||
| models: local?.models ?? [], | ||
| daily: local?.daily ?? [], | ||
| projects: local?.projects ?? [], | ||
| rateLimits: local?.rateLimits ?? [], | ||
| sources: local?.sources ?? [], | ||
| activity, | ||
| } satisfies EnvironmentUsageSnapshot; | ||
| return { snapshot, complete: local !== undefined }; | ||
| }); | ||
|
|
||
| return { | ||
| getSnapshot: (sinceDate: string | undefined) => | ||
| Effect.gen(function* () { | ||
| const now = yield* DateTime.now; | ||
| const fallback = DateTime.formatIso( | ||
| DateTime.subtract(now, { days: DEFAULT_WINDOW_DAYS }), | ||
| ).slice(0, 10); | ||
| // The wire type is a plain string, so anything that is not a calendar | ||
| // date falls back to the default window instead of reaching the query. | ||
| const resolved = sinceDate !== undefined && ISO_DATE.test(sinceDate) ? sinceDate : fallback; | ||
|
|
||
| const startedAt = yield* Clock.currentTimeMillis; | ||
| const cached = cache.get(resolved); | ||
| if (cached !== undefined && startedAt - cached.at < CACHE_TTL_MILLIS) { | ||
| return cached.snapshot; | ||
| } | ||
| // Callers choose the window, so evict expired entries before inserting; | ||
| // otherwise one entry accumulates per distinct date string and the map | ||
| // grows without bound. | ||
| for (const [key, entry] of cache) { | ||
| if (startedAt - entry.at >= CACHE_TTL_MILLIS) cache.delete(key); | ||
| } | ||
|
|
||
| const { snapshot, complete } = yield* build(resolved); | ||
| // Only cache a complete read, and stamp it on completion so a slow | ||
| // scan does not immediately expire. A failed scan reports zeros once | ||
| // rather than pinning the page at $0 for the whole TTL. | ||
| if (complete) { | ||
| const finishedAt = yield* Clock.currentTimeMillis; | ||
| // Bounded even within one TTL window, so a burst of distinct dates | ||
| // cannot pin unbounded memory until the entries age out. | ||
| if (cache.size >= MAX_CACHE_ENTRIES) { | ||
| const oldest = cache.keys().next(); | ||
| if (!oldest.done) cache.delete(oldest.value); | ||
| } | ||
| cache.set(resolved, { at: finishedAt, snapshot }); | ||
| } | ||
| return snapshot; | ||
| }), | ||
| }; | ||
| }); | ||
|
|
||
| export const layer = Layer.effect(UsageService, make); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,157 @@ | ||||||
| import type { UsageActivityCount, UsageActivitySummary } from "@t3tools/contracts"; | ||||||
| import * as Effect from "effect/Effect"; | ||||||
| import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||||||
|
|
||||||
| /** | ||||||
| * Behavioral counters drawn from T3 Code's own projections. | ||||||
| * | ||||||
| * The agent session logs record tokens but know nothing about skills, plans, | ||||||
| * subagent types or checkpoint diffs, so this half of the usage page can only | ||||||
| * come from here. Counts are deliberately provider-shaped rather than merged: | ||||||
| * Claude reports a named tool per call while Codex reports an item type, so the | ||||||
| * two are labelled but never summed into a single "tool" figure. | ||||||
| */ | ||||||
|
|
||||||
| const TOP_N = 12; | ||||||
|
|
||||||
| const toCounts = ( | ||||||
| rows: ReadonlyArray<{ name: unknown; count: unknown }>, | ||||||
| ): Array<UsageActivityCount> => | ||||||
| rows | ||||||
| .flatMap((row) => { | ||||||
| const name = typeof row.name === "string" ? row.name.trim() : ""; | ||||||
| const count = typeof row.count === "number" ? Math.trunc(row.count) : 0; | ||||||
| return name.length === 0 || count <= 0 ? [] : [{ name, count }]; | ||||||
| }) | ||||||
| .sort((a, b) => b.count - a.count); | ||||||
|
|
||||||
| const firstNumber = (rows: ReadonlyArray<Record<string, unknown>>, key: string): number => { | ||||||
| const value = rows.at(0)?.[key]; | ||||||
| return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0; | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * @param sinceIso inclusive lower bound on `created_at`, ISO 8601. | ||||||
| */ | ||||||
| export const readActivitySummary = ( | ||||||
| sinceIso: string, | ||||||
| ): Effect.Effect<UsageActivitySummary, never, SqlClient.SqlClient> => | ||||||
| Effect.gen(function* () { | ||||||
| const sql = yield* SqlClient.SqlClient; | ||||||
|
|
||||||
| // Claude records a tool name; Codex records an item type. COALESCE keeps | ||||||
| // both on one axis without pretending they are the same vocabulary. | ||||||
| const tools = yield* sql<{ name: unknown; count: unknown }>` | ||||||
| SELECT | ||||||
| COALESCE( | ||||||
| json_extract(payload_json, '$.data.toolName'), | ||||||
| json_extract(payload_json, '$.itemType') | ||||||
| ) AS name, | ||||||
| COUNT(*) AS count | ||||||
| FROM projection_thread_activities | ||||||
| WHERE kind = 'tool.completed' AND created_at >= ${sinceIso} | ||||||
| GROUP BY name | ||||||
| ORDER BY count DESC | ||||||
| LIMIT ${TOP_N} | ||||||
| `; | ||||||
|
|
||||||
| const skills = yield* sql<{ name: unknown; count: unknown }>` | ||||||
| SELECT | ||||||
| json_extract(payload_json, '$.data.input.skill') AS name, | ||||||
| COUNT(*) AS count | ||||||
| FROM projection_thread_activities | ||||||
| WHERE kind = 'tool.completed' | ||||||
| AND json_extract(payload_json, '$.data.toolName') = 'Skill' | ||||||
| AND created_at >= ${sinceIso} | ||||||
| GROUP BY name | ||||||
| ORDER BY count DESC | ||||||
| LIMIT ${TOP_N} | ||||||
| `; | ||||||
|
|
||||||
| const subagents = yield* sql<{ name: unknown; count: unknown }>` | ||||||
| SELECT | ||||||
| json_extract(payload_json, '$.data.input.subagent_type') AS name, | ||||||
| COUNT(*) AS count | ||||||
| FROM projection_thread_activities | ||||||
| WHERE kind = 'tool.completed' | ||||||
| AND json_extract(payload_json, '$.data.toolName') = 'Agent' | ||||||
| AND created_at >= ${sinceIso} | ||||||
| GROUP BY name | ||||||
| ORDER BY count DESC | ||||||
| LIMIT ${TOP_N} | ||||||
| `; | ||||||
|
|
||||||
| // Timestamps are stored as ISO-8601 UTC, so these buckets are UTC hours. | ||||||
| // Keeping them UTC is what makes them safe to sum across environments in | ||||||
| // different timezones; the UI labels the axis accordingly. | ||||||
| const hourRows = yield* sql<{ hour: unknown; count: unknown }>` | ||||||
| SELECT | ||||||
| CAST(strftime('%H', requested_at) AS INTEGER) AS hour, | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
Suggested change
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||
| COUNT(*) AS count | ||||||
| FROM projection_turns | ||||||
| WHERE requested_at >= ${sinceIso} | ||||||
| GROUP BY hour | ||||||
| `; | ||||||
|
|
||||||
| const totals = yield* sql<Record<string, unknown>>` | ||||||
| SELECT | ||||||
| (SELECT COUNT(*) FROM projection_turns WHERE requested_at >= ${sinceIso}) AS totalTurns, | ||||||
| (SELECT COUNT(*) FROM projection_threads WHERE created_at >= ${sinceIso} AND deleted_at IS NULL) AS totalThreads, | ||||||
| (SELECT COUNT(*) FROM projection_thread_activities | ||||||
| WHERE kind = 'tool.completed' AND created_at >= ${sinceIso}) AS toolCalls, | ||||||
| (SELECT COUNT(*) FROM projection_thread_activities | ||||||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||||||
| WHERE kind = 'tool.completed' AND created_at >= ${sinceIso} | ||||||
| AND ( | ||||||
| json_extract(payload_json, '$.data.item.exitCode') NOT IN (0) | ||||||
| -- Claude tool results carry no exit code; they flag failure here. | ||||||
| OR json_extract(payload_json, '$.data.result.is_error') = 1 | ||||||
| )) AS toolFailures | ||||||
| `; | ||||||
|
|
||||||
| const churn = yield* sql<Record<string, unknown>>` | ||||||
| SELECT | ||||||
| SUM(json_extract(file.value, '$.additions')) AS linesAdded, | ||||||
| SUM(json_extract(file.value, '$.deletions')) AS linesDeleted | ||||||
| FROM projection_turns AS turn, json_each(turn.checkpoint_files_json) AS file | ||||||
| WHERE turn.requested_at >= ${sinceIso} | ||||||
| `; | ||||||
|
|
||||||
| const turnsByHour = Array.from({ length: 24 }, () => 0); | ||||||
| for (const row of hourRows) { | ||||||
| const hour = typeof row.hour === "number" ? row.hour : Number.NaN; | ||||||
| const count = typeof row.count === "number" ? Math.trunc(row.count) : 0; | ||||||
| if (Number.isInteger(hour) && hour >= 0 && hour < 24 && count > 0) turnsByHour[hour] = count; | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| tools: toCounts(tools), | ||||||
| skills: toCounts(skills), | ||||||
| subagents: toCounts(subagents), | ||||||
| turnsByHour, | ||||||
| totalTurns: firstNumber(totals, "totalTurns"), | ||||||
| totalThreads: firstNumber(totals, "totalThreads"), | ||||||
| toolCalls: firstNumber(totals, "toolCalls"), | ||||||
| toolFailures: firstNumber(totals, "toolFailures"), | ||||||
| linesAdded: firstNumber(churn, "linesAdded"), | ||||||
| linesDeleted: firstNumber(churn, "linesDeleted"), | ||||||
| } satisfies UsageActivitySummary; | ||||||
| }).pipe( | ||||||
| // Usage reporting is read-only and non-critical: a malformed payload or a | ||||||
| // schema drift should degrade the panel, never fail the request. | ||||||
| Effect.catchCause((cause) => | ||||||
| Effect.logWarning("Failed to read usage activity summary", { cause }).pipe( | ||||||
| Effect.as({ | ||||||
| tools: [], | ||||||
| skills: [], | ||||||
| subagents: [], | ||||||
| turnsByHour: Array.from({ length: 24 }, () => 0), | ||||||
| totalTurns: 0, | ||||||
| totalThreads: 0, | ||||||
| toolCalls: 0, | ||||||
| toolFailures: 0, | ||||||
| linesAdded: 0, | ||||||
| linesDeleted: 0, | ||||||
| } satisfies UsageActivitySummary), | ||||||
| ), | ||||||
| ), | ||||||
| ); | ||||||
Uh oh!
There was an error while loading. Please reload this page.