Skip to content
Open
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
36 changes: 36 additions & 0 deletions packages/server/src/e2b/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { buildApp } from '../app';
import { Archiver } from '../archive/archiver';
Expand All @@ -12,6 +13,7 @@ import { objectKey } from '../archive/store';
import { loadConfig } from '../config';
import { migrateDb, openDb } from '../db/db';
import { findById, setDeadline } from '../db/ledger';
import { sandboxes } from '../db/schema';
import { getOrCreateSigningSecret } from '../db/secrets';
import { FAKE_BASE_IMAGE, FakeExecutor } from '../executor/fake';
import { CpuSampler } from '../host-metrics';
Expand Down Expand Up @@ -649,6 +651,40 @@ describe('E2B control plane', () => {
expect(stopped.json()).toEqual([]);
});

it('metrics with no start defaults to the sandbox creation, not a 1h span', async () => {
const t = testApp();
const { sandboxID } = await createSandbox(t, { timeout: 86400 });
// The SDK contract only bites once a sandbox outlives an hour with
// retained samples on both sides of that line. createdAt is immutable
// through the ledger (no helper ages a row), so the column is backdated
// directly; the deadline clock runs independently of it, and the
// timeout above keeps the sandbox live across the two-hour past.
const past = new Date(Date.now() - 2 * 3600_000).toISOString();
t.db
.update(sandboxes)
.set({ createdAt: past })
.where(eq(sandboxes.id, sandboxID))
.run();
const t0 = Date.now() - 90 * 60_000;
await sampleOnce(t.db, t.executor, new Date(t0), tickOpts());
await sampleOnce(t.db, t.executor, new Date(t0 + 80 * 60_000), tickOpts());

const res = await control(t, 'GET', `/sandboxes/${sandboxID}/metrics`);
expect(res.statusCode).toBe(200);
expect(res.json()).toHaveLength(2);

// The explicit spelling of the same intent agrees with the new default.
const explicit = await control(
t,
'GET',
`/sandboxes/${sandboxID}/metrics?start=${Math.floor(
Date.parse(past) / 1000,
)}`,
);
expect(explicit.statusCode).toBe(200);
expect(explicit.json()).toHaveLength(2);
});

it('metrics 404s an unknown sandbox in the control-plane dialect', async () => {
const t = testApp();
const res = await control(t, 'GET', '/sandboxes/no-such/metrics');
Expand Down
11 changes: 10 additions & 1 deletion packages/server/src/e2b/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,17 @@ export const e2bControlRoutes: FastifyPluginAsyncZod<E2bDeps> = async (
const measurable = row.state === 'active' || row.state === 'frozen';

const now = new Date();
// The E2B SDKs document an omitted start as "defaults to the start of
// the sandbox" (SandboxMetricsOpts.start, js and python alike), so this
// face anchors an omitted start at the ledger's createdAt — the shared
// one-hour span silently dropped every retained sample past an hour
// from an unqualified getMetrics(). start is therefore always defined
// here and the span below is inert; it stays because the resolver
// requires one and the native faces still mean it.
const { startIso, endIso, startMs, endMs } = resolveWindow(
start === undefined ? undefined : new Date(start * 1000).toISOString(),
start === undefined
? row.createdAt
: new Date(start * 1000).toISOString(),
end === undefined ? undefined : new Date(end * 1000).toISOString(),
3600_000,
now,
Expand Down