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
89 changes: 89 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const layerService = (input: {
) => Effect.Effect<Response, HttpClientError.HttpClientError>;
readonly environment?: NodeJS.ProcessEnv;
readonly platform?: NodeJS.Platform;
readonly hostname?: string;
}) =>
layerCursorUsageAccounts(
CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)),
Expand All @@ -157,6 +158,7 @@ const layerService = (input: {
),
Layer.provideMerge(NodeServices.layer),
Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")),
Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")),
Layer.provideMerge(ServerSettings.layerTest(input.settings)),
Layer.provideMerge(
Layer.succeed(
Expand Down Expand Up @@ -465,6 +467,93 @@ describe("Codex usage history", () => {
});

describe("UsageService", () => {
it.live("keeps the first host identity across recreation and explicit override removal", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));
for (const [hostname, override, expected] of [
["container-a", undefined, "container-a"],
["container-b", undefined, "container-a"],
["container-c", "physical-host", "physical-host"],
["container-d", undefined, "container-a"],
] as const) {
const summary = yield* Effect.gen(function* () {
const service = yield* UsageService.make;
return yield* service.readSummary(WINDOW);
}).pipe(
Effect.scoped,
Effect.provide(
layerService({
prefix: "usage-host-id",
home,
settings,
hostname,
environment: override === undefined ? {} : { T3CODE_HOST_ID: override },
}),
),
);
const source = summary.sources.find((source) => source.fingerprint.provider === "claude");
assert.strictEqual(source?.fingerprint.hostId, expected);
}
}).pipe(Effect.scoped),
);

it.live("concurrent initialization keeps one host identity across service recreation", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));
const prefix = "usage-host-id-race";
const hostIdPath = NodePath.join(home, prefix, "usage-host-id");
const bothRead = yield* Deferred.make<void>();
let reads = 0;
const summaries = yield* Effect.forEach(
["container-a", "container-b"],
(hostname) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
let firstRead = true;
const service = yield* UsageService.make.pipe(
Effect.provideService(FileSystem.FileSystem, {
...fileSystem,
readFileString: (path, encoding) =>
Effect.gen(function* () {
if (path !== hostIdPath || !firstRead) {
return yield* fileSystem.readFileString(path, encoding);
}
firstRead = false;
// Both instances observe the missing file before either can create it.
const result = yield* Effect.exit(fileSystem.readFileString(path, encoding));
reads += 1;
if (reads === 2) yield* Deferred.succeed(bothRead, undefined);
yield* Deferred.await(bothRead);
return yield* result;
}),
}),
);
return yield* service.readSummary(WINDOW);
}).pipe(
Effect.scoped,
Effect.provide(layerService({ prefix, home, settings, hostname })),
),
{ concurrency: "unbounded" },
);
const persisted = yield* Effect.promise(() => NodeFSP.readFile(hostIdPath, "utf8"));
const hostId = persisted.trim();
assert.include(["container-a", "container-b"], hostId);
const restarted = yield* Effect.gen(function* () {
const service = yield* UsageService.make;
return yield* service.readSummary(WINDOW);
}).pipe(
Effect.scoped,
Effect.provide(layerService({ prefix, home, settings, hostname: "container-c" })),
);
for (const summary of [...summaries, restarted]) {
const source = summary.sources.find((source) => source.fingerprint.provider === "claude");
assert.strictEqual(source?.fingerprint.hostId, hostId);
}
}).pipe(Effect.scoped),
);

it.live(
"repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers",
() =>
Expand Down
35 changes: 32 additions & 3 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as NodeOS from "node:os";

import {
type ProviderInstanceConfig,
ProviderInstanceId,
Expand Down Expand Up @@ -159,6 +157,7 @@ export const make = Effect.gen(function* () {
const settingsService = yield* ServerSettings.ServerSettingsService;
const httpClient = yield* HttpClient.HttpClient;
const hostEnvironment = yield* HostProcess.Environment;
const hostname = yield* HostProcess.Hostname;
const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();

const fileCache: ScanCache = new Map();
Expand All @@ -181,6 +180,37 @@ export const make = Effect.gen(function* () {
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);
const hostIdPath = path.join(config.baseDir, "usage-host-id");
const explicitHostId = hostEnvironment["T3CODE_HOST_ID"]?.trim();
const hostId =
explicitHostId ||
(yield* fileSystem.readFileString(hostIdPath).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() =>
Effect.scoped(
Effect.gen(function* () {
yield* fileSystem.makeDirectory(config.baseDir, { recursive: true });
const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({
directory: config.baseDir,
prefix: "usage-host-id.",
});
const tempFile = path.join(tempDirectory, "identity");
yield* fileSystem.writeFileString(tempFile, `${hostname}\n`);
// Publish complete contents without replacing another initializer's identity.
yield* fileSystem.link(tempFile, hostIdPath).pipe(
Effect.catchIf(
(error) => error.reason._tag === "AlreadyExists",
() => Effect.void,
),
);
return yield* fileSystem.readFileString(hostIdPath);
}),
),
),
Effect.map((value) => value.trim() || hostname),
Effect.orElseSucceed(() => hostname),
));
let rates: RateTable = new Map();
let ratesFetchedAtMs: number | null = null;
let ratesLastFailedAtMs: number | null = null;
Expand Down Expand Up @@ -668,7 +698,6 @@ export const make = Effect.gen(function* () {
const startedAtMs = yield* Clock.currentTimeMillis;
yield* ensureScanCacheLoaded;

const hostId = NodeOS.hostname();
const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`);
if (Option.isNone(windowStart)) {
return yield* new UsageReadError({
Expand Down
4 changes: 4 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Use absolute paths or `~/` paths in the account's environment settings; relative
depend on each project's working directory and cannot be reliably discovered by Usage. Accounts
sharing a history directory count once.

The server saves its first hostname in `<T3 home>/usage-host-id`, so a recreated container keeps
its usage identity when T3 home is persisted. Set `T3CODE_HOST_ID` to choose the identity
explicitly.

When your app and server support different providers, usage totals may cover only the providers
your app understands. Update the app to include newly supported providers.

Expand Down
Loading