Skip to content
Closed
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
45 changes: 45 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3326,6 +3326,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.scoped, Effect.provide(NodeHttpServerTestWithWsDeflate)),
);

it.effect("serves a usage snapshot to an authenticated session", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();

const bearerToken = yield* getAuthenticatedBearerSessionToken();
const usageUrl = yield* getHttpServerUrl("/api/usage/snapshot?sinceDate=2020-01-01");
const response = yield* fetchEffect(usageUrl, {
headers: { authorization: `Bearer ${bearerToken}` },
});
assert.equal(response.status, 200);

const body = yield* responseJsonEffect<{
readonly environmentId: string;
readonly costUsd: number;
readonly models: ReadonlyArray<unknown>;
readonly activity: { readonly turnsByHour: ReadonlyArray<number> };
readonly sources: ReadonlyArray<{ readonly provider: string }>;
}>(response);

// Asserts the contract shape rather than values: this reads whatever
// agent logs the host actually has, which is empty on CI and populated on
// a developer machine. Both must produce a well-formed snapshot.
assert.isString(body.environmentId);
assert.isAtLeast(body.costUsd, 0);
assert.isArray(body.models);
assert.equal(body.activity.turnsByHour.length, 24);
// Both slots always report, so the client can tell "nothing used" from
// "provider not installed on this host".
assert.deepEqual(body.sources.map((source) => source.provider).toSorted(), [
"claude",
"codex",
]);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("rejects an unauthenticated usage snapshot request", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();

const usageUrl = yield* getHttpServerUrl("/api/usage/snapshot");
const response = yield* fetchEffect(usageUrl);
assert.equal(response.status, 401);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ import {
persistServerRuntimeState,
} from "./serverRuntimeState.ts";
import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
import { usageHttpApiLayer } from "./usage/http.ts";
import * as UsageService from "./usage/UsageService.ts";
import * as NetService from "@t3tools/shared/Net";
import * as RelayClient from "@t3tools/shared/relayClient";
import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
Expand Down Expand Up @@ -431,6 +433,14 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(authHttpApiLayer),
Layer.provide(connectHttpApiLayer),
Layer.provide(orchestrationHttpApiLayer),
// Persistence is provided here rather than left as an outstanding
// requirement so the routes layer keeps the same shape it had before
// usage reporting existed. Layer memoization shares the one connection.
Layer.provide(
usageHttpApiLayer.pipe(
Layer.provide(UsageService.layer.pipe(Layer.provide(PersistenceLayerLive))),
),
),
Layer.provide(serverEnvironmentHttpApiLayer),
Layer.provide(environmentAuthenticatedAuthLayer),
),
Expand Down
140 changes: 140 additions & 0 deletions apps/server/src/usage/UsageService.ts
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<
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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);
157 changes: 157 additions & 0 deletions apps/server/src/usage/activityUsage.ts
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,

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 usage/activityUsage.ts:86

turnsByHour places each turn in the wrong hour bucket on any non-UTC host. The SQL uses strftime('%H', requested_at) on stored ISO-8601 timestamps ending in Z, so SQLite interprets them as UTC and the result is the UTC hour rather than local time. The contract documents these buckets as local-time hours, so the histogram is shifted (and near midnight turns can land on the wrong day) for every user outside UTC. Add the 'localtime' modifier, e.g. strftime('%H', requested_at, 'localtime'), so the buckets reflect local working hours.

Suggested change
CAST(strftime('%H', requested_at) AS INTEGER) AS hour,
CAST(strftime('%H', requested_at, 'localtime') AS INTEGER) AS hour,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/usage/activityUsage.ts around line 86:

`turnsByHour` places each turn in the wrong hour bucket on any non-UTC host. The SQL uses `strftime('%H', requested_at)` on stored ISO-8601 timestamps ending in `Z`, so SQLite interprets them as UTC and the result is the UTC hour rather than local time. The contract documents these buckets as local-time hours, so the histogram is shifted (and near midnight turns can land on the wrong day) for every user outside UTC. Add the `'localtime'` modifier, e.g. `strftime('%H', requested_at, 'localtime')`, so the buckets reflect local working hours.

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
Comment thread
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),
),
),
);
Loading
Loading