diff --git a/.changeset/dull-snakes-unite.md b/.changeset/dull-snakes-unite.md new file mode 100644 index 0000000000..b71a1794db --- /dev/null +++ b/.changeset/dull-snakes-unite.md @@ -0,0 +1,7 @@ +--- +'@e2b/python-sdk': minor +'e2b': minor +'@e2b/cli': minor +--- + +Expose sandbox metrics diff --git a/apps/web/src/app/(docs)/docs/sandbox/installing-beta-sdks/page.mdx b/apps/web/src/app/(docs)/docs/sandbox/installing-beta-sdks/page.mdx index 590c593f1c..69bdca3634 100644 --- a/apps/web/src/app/(docs)/docs/sandbox/installing-beta-sdks/page.mdx +++ b/apps/web/src/app/(docs)/docs/sandbox/installing-beta-sdks/page.mdx @@ -1,6 +1,6 @@ # Installing beta SDKs -To use features like [sandbox persistence](/docs/sandbox/persistence) and [metrics](/docs/sandbox/metrics), you need to install the beta version of the SDKs. +To use features like [sandbox persistence](/docs/sandbox/persistence) you need to install the beta version of the SDKs. ### Code Interpreter SDK diff --git a/apps/web/src/app/(docs)/docs/sandbox/metrics/page.mdx b/apps/web/src/app/(docs)/docs/sandbox/metrics/page.mdx index 395c386f53..f1ac71785f 100644 --- a/apps/web/src/app/(docs)/docs/sandbox/metrics/page.mdx +++ b/apps/web/src/app/(docs)/docs/sandbox/metrics/page.mdx @@ -1,22 +1,10 @@ # Sandbox metrics - -This feature is in a private beta. - - -The sandbox metrics allows you to get information about the sandbox's CPU and memory usage. - -## Installation - -To get sandbox metrics, you need to install the beta version of the SDKs and CLI: - -- [Installing beta SDKs](/docs/sandbox/installing-beta-sdks) - -- [Installing beta CLI](/docs/cli#beta-cli) +The sandbox metrics allows you to get information about the sandbox's CPU, memory and disk usage. ## Getting sandbox metrics -Getting the metrics of a sandbox returns an array of timestamped metrics containing CPU and memory usage information. -The metrics are collected at the start of the sandbox, then every 2 seconds, and finally right before the sandbox is deleted. +Getting the metrics of a sandbox returns an array of timestamped metrics containing CPU, memory and disk usage information. +The metrics are collected every 5 seconds. ### Getting sandbox metrics using the SDKs @@ -27,6 +15,9 @@ import { Sandbox } from '@e2b/code-interpreter' const sbx = await Sandbox.create() console.log('Sandbox created', sbx.sandboxId) +// Wait for a few seconds to collect some metrics +await new Promise((resolve) => setTimeout(resolve, 10_000)) + const metrics = await sbx.getMetrics() // $HighlightLine // You can also get the metrics by sandbox ID: @@ -34,30 +25,38 @@ const metrics = await sbx.getMetrics() // $HighlightLine console.log('Sandbox metrics:', metrics) +// Sandbox metrics: // [ // { +// timestamp: 2025-07-28T08:04:05.000Z, +// cpuUsedPct: 20.33, // cpuCount: 2, -// cpuUsedPct: 50.05, -// memTotalMiB: 484, -// memUsedMiB: 37, -// timestamp: '2025-01-23T23:44:12.222Z' +// memUsed: 32681984, // in bytes +// memTotal: 507592704, // in bytes +// diskUsed: 1514856448, // in bytes +// diskTotal: 2573185024 // in bytes // }, // { +// timestamp: 2025-07-28T08:04:10.000Z, +// cpuUsedPct: 0.2, // cpuCount: 2, -// cpuUsedPct: 4.5, -// memTotalMiB: 484, -// memUsedMiB: 37, -// timestamp: '2025-01-23T23:44:13.220Z' +// memUsed: 33316864, // in bytes +// memTotal: 507592704, // in bytes +// diskUsed: 1514856448, // in bytes +// diskTotal: 2573185024 // in bytes // } // ] - ``` ```python +from time import sleep from e2b_code_interpreter import Sandbox sbx = Sandbox() print('Sandbox created', sbx.sandbox_id) +# Wait for a few seconds to collect some metrics +sleep(10) + metrics = sbx.get_metrics() # $HighlightLine # You can also get the metrics by sandbox ID: @@ -65,21 +64,26 @@ metrics = sbx.get_metrics() # $HighlightLine print('Sandbox metrics', metrics) +# Sandbox metrics # [ -# SandboxMetrics(timestamp=datetime.datetime( -# 2025, 1, 23, 23, 58, 42, 84050, tzinfo=tzutc()), -# cpu_count=2, -# cpu_used_pct=50.07, -# mem_total_mib=484 -# mem_used_mib=37, -# ), -# SandboxMetrics(timestamp=datetime.datetime( -# 2025, 1, 23, 23, 58, 44, 84845, tzinfo=tzutc()), -# cpu_count=2, -# cpu_used_pct=4.75, -# mem_total_mib=484 -# mem_used_mib=38, -# ), +# SandboxMetric( +# cpu_count=2, +# cpu_used_pct=13.97, +# disk_total=2573185024, # in bytes +# disk_used=1514856448, # in bytes +# mem_total=507592704, # in bytes +# mem_used=30588928, # in bytes +# timestamp=datetime.datetime(2025, 7, 28, 8, 8, 15, tzinfo=tzutc()), +# ), +# SandboxMetric( +# cpu_count=2, +# cpu_used_pct=0.1, +# disk_total=2573185024, # in bytes +# disk_used=1514856448, # in bytes +# mem_total=507592704, # in bytes +# mem_used=31084544, # in bytes +# timestamp=datetime.datetime(2025, 7, 28, 8, 8, 20, tzinfo=tzutc()), +# ), # ] ``` @@ -91,11 +95,13 @@ e2b sandbox metrics # $HighlightLine # Metrics for sandbox # -# [2025-01-23 00:58:58.829Z] { cpuCount: 2, cpuUsedPct: 50.21, logger: '', memTotalMiB: 484, memUsedMiB: 38, timestamp: '2025-01-23T00:58:58.829638869Z' } -# [2025-01-23 00:59:03.814Z] { cpuCount: 2, cpuUsedPct: 5.16, logger: '', memTotalMiB: 484, memUsedMiB: 37, timestamp: '2025-01-23T00:59:03.814028031Z' } -# [2025-01-23 00:59:08.815Z] { cpuCount: 2, cpuUsedPct: 1.6, logger: '', memTotalMiB: 484, memUsedMiB: 37, timestamp: '2025-01-23T00:59:08.815933749Z' } +# [2025-07-25 14:05:55Z] CPU: 8.27% / 2 Cores | Memory: 31 / 484 MiB | Disk: 1445 / 2453 MiB +# [2025-07-25 14:06:00Z] CPU: 0.5% / 2 Cores | Memory: 32 / 484 MiB | Disk: 1445 / 2453 MiB +# [2025-07-25 14:06:05Z] CPU: 0.1% / 2 Cores | Memory: 32 / 484 MiB | Disk: 1445 / 2453 MiB +# [2025-07-25 14:06:10Z] CPU: 0.3% / 2 Cores | Memory: 32 / 484 MiB | Disk: 1445 / 2453 MiB ``` -## Limitations while in beta -- It may take a second or more to get the metrics after the sandbox is created. Until the logs are collected from the sandbox, you will get an empty array. \ No newline at end of file + + It may take a second or more to get the first metrics after the sandbox is created. Until the first metrics are collected from the sandbox, you will get an empty array. + diff --git a/packages/cli/src/commands/sandbox/index.ts b/packages/cli/src/commands/sandbox/index.ts index 538ad5bffa..3c61df8481 100644 --- a/packages/cli/src/commands/sandbox/index.ts +++ b/packages/cli/src/commands/sandbox/index.ts @@ -5,6 +5,7 @@ import { listCommand } from './list' import { killCommand } from './kill' import { spawnCommand } from './spawn' import { logsCommand } from './logs' +import { metricsCommand } from './metrics' export const sandboxCommand = new commander.Command('sandbox') .description('work with sandboxes') @@ -14,3 +15,4 @@ export const sandboxCommand = new commander.Command('sandbox') .addCommand(killCommand) .addCommand(spawnCommand) .addCommand(logsCommand) + .addCommand(metricsCommand) diff --git a/packages/cli/src/commands/sandbox/logs.ts b/packages/cli/src/commands/sandbox/logs.ts index 846259e430..4c5ee90f31 100644 --- a/packages/cli/src/commands/sandbox/logs.ts +++ b/packages/cli/src/commands/sandbox/logs.ts @@ -8,45 +8,7 @@ import { asBold, asTimestamp, withUnderline } from 'src/utils/format' import { listSandboxes } from './list' import { wait } from 'src/utils/wait' import { handleE2BRequestError } from '../../utils/errors' - -const maxRuntime = 24 * 60 * 60 * 1000 // 24 hours in milliseconds - -function getShortID(sandboxID: string) { - return sandboxID.split('-')[0] -} - -function waitForSandboxEnd(sandboxID: string) { - let isRunning = true - - async function monitor() { - const startTime = new Date().getTime() - - // eslint-disable-next-line no-constant-condition - while (true) { - const currentTime = new Date().getTime() - const elapsedTime = currentTime - startTime // Time elapsed in milliseconds - - // Check if 24 hours (in milliseconds) have passed - if (elapsedTime >= maxRuntime) { - break - } - - const response = await listSandboxes() - const sandbox = response.find( - (s) => s.sandboxID === getShortID(sandboxID) - ) - if (!sandbox) { - isRunning = false - break - } - await wait(5000) - } - } - - monitor() - - return () => isRunning -} +import { waitForSandboxEnd, formatEnum, Format, getShortID } from './utils' enum LogLevel { DEBUG = 'DEBUG', @@ -76,17 +38,6 @@ function isLevelIncluded(level: LogLevel, allowedLevel?: LogLevel) { } } -function formatEnum(e: { [key: string]: string }) { - return Object.values(e) - .map((level) => asBold(level)) - .join(', ') -} - -enum LogFormat { - JSON = 'json', - PRETTY = 'pretty', -} - function cleanLogger(logger?: string) { if (!logger) { return '' @@ -112,8 +63,8 @@ export const logsCommand = new commander.Command('logs') .option('-f, --follow', 'keep streaming logs until the sandbox is closed') .option( '--format ', - `specify format for printing logs (${formatEnum(LogFormat)})`, - LogFormat.PRETTY + `specify format for printing logs (${formatEnum(Format)})`, + Format.PRETTY ) .option( '--loggers [loggers]', @@ -126,7 +77,7 @@ export const logsCommand = new commander.Command('logs') opts?: { level: string follow: boolean - format: LogFormat + format: Format loggers?: string[] } ) => { @@ -136,8 +87,8 @@ export const logsCommand = new commander.Command('logs') throw new Error(`Invalid log level: ${level}`) } - const format = opts?.format.toLowerCase() as LogFormat | undefined - if (format && !Object.values(LogFormat).includes(format)) { + const format = opts?.format.toLowerCase() as Format | undefined + if (format && !Object.values(Format).includes(format)) { throw new Error(`Invalid log format: ${format}`) } @@ -149,7 +100,7 @@ export const logsCommand = new commander.Command('logs') let isFirstRun = true let firstLogsPrinted = false - if (format === LogFormat.PRETTY) { + if (format === Format.PRETTY) { console.log(`\nLogs for sandbox ${asBold(sandboxID)}:`) } @@ -178,7 +129,7 @@ export const logsCommand = new commander.Command('logs') const isRunning = await isRunningPromise if (!isRunning && logs.length === 0 && isFirstRun) { - if (format === LogFormat.PRETTY) { + if (format === Format.PRETTY) { console.log( `\nStopped printing logs — sandbox ${withUnderline( 'not found' @@ -189,7 +140,7 @@ export const logsCommand = new commander.Command('logs') } if (!isRunning) { - if (format === LogFormat.PRETTY) { + if (format === Format.PRETTY) { console.log( `\nStopped printing logs — sandbox is ${withUnderline( 'closed' @@ -219,7 +170,7 @@ function printLog( timestamp: string, line: string, allowedLevel: LogLevel | undefined, - format: LogFormat | undefined, + format: Format | undefined, allowedLoggers?: string[] | undefined ) { const log = JSON.parse(line) @@ -266,7 +217,7 @@ function printLog( delete log['envID'] delete log['sandboxID'] - if (format === LogFormat.JSON) { + if (format === Format.JSON) { console.log( JSON.stringify({ timestamp: new Date(timestamp).toISOString(), diff --git a/packages/cli/src/commands/sandbox/metrics.ts b/packages/cli/src/commands/sandbox/metrics.ts new file mode 100644 index 0000000000..d67ff6ab62 --- /dev/null +++ b/packages/cli/src/commands/sandbox/metrics.ts @@ -0,0 +1,144 @@ +import * as chalk from 'chalk' +import * as commander from 'commander' + +import { asBold, asTimestamp, withUnderline } from 'src/utils/format' +import { wait } from 'src/utils/wait' +import { listSandboxes } from './list' +import { formatEnum, getShortID, Format } from './utils' +import { Sandbox } from 'e2b' + +export const metricsCommand = new commander.Command('metrics') + .description('show metrics for sandbox') + .argument( + '', + `show metrics for sandbox specified by ${asBold('')}` + ) + .alias('mt') + .option('-f, --follow', 'keep streaming metrics until the sandbox is closed') + .option( + '--format ', + `specify format for printing metrics (${formatEnum(Format)})`, + Format.PRETTY + ) + .action( + async ( + sandboxID: string, + opts?: { + follow: boolean + format: Format + } + ) => { + try { + const format = opts?.format.toLowerCase() as Format | undefined + if (format && !Object.values(Format).includes(format)) { + throw new Error(`Invalid log format: ${format}`) + } + + let start: Date | undefined + let isFirstRun = true + let firstMetricsPrinted = false + + if (format === Format.PRETTY) { + console.log(`\nMetrics for sandbox ${asBold(sandboxID)}:`) + } + + const isRunningPromise = listSandboxes() + .then((r) => r.find((s) => s.sandboxID === getShortID(sandboxID))) + .then((s) => !!s) + + do { + const metrics = await Sandbox.getMetrics(sandboxID, { start }) + + if (metrics.length !== 0 && !firstMetricsPrinted) { + firstMetricsPrinted = true + process.stdout.write('\n') + } + + for (const metric of metrics) { + if (start && metric.timestamp <= start) { + // Skip the metric if it has the same timestamp as the last one + continue + } + start = metric.timestamp + + printMetric(metric.timestamp, JSON.stringify(metric), format) + } + + const isRunning = await isRunningPromise + + if (!isRunning && metrics.length === 0 && isFirstRun) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing metrics — sandbox ${withUnderline( + 'not found' + )}` + ) + } + break + } + + if (!isRunning) { + if (format === Format.PRETTY) { + console.log( + `\nStopped printing metrics — sandbox is ${withUnderline( + 'closed' + )}` + ) + } + break + } + + await wait(400) + isFirstRun = false + } while (opts?.follow) + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +function printMetric( + timestamp: Date, + line: string, + format: Format | undefined +) { + const metric = JSON.parse(line) + const level = chalk.default.green() + + if (format === Format.JSON) { + console.log( + JSON.stringify({ + timestamp: timestamp.toISOString(), + ...metric, + }) + ) + } else { + const time = `[${timestamp + .toISOString() + .replace(/\.\d{3}Z/, 'Z') + .replace(/T/, ' ')}]` + delete metric['timestamp'] + const multipleCores = metric.cpuCount > 1 + metric.cpuCount += 0 + console.log( + `${asTimestamp(time)} ${level} ` + + asBold('CPU') + + `: ${metric.cpuUsedPct.toString().padStart(5)}% / ${metric.cpuCount + .toString() + .padStart(2)} Core${multipleCores && 's'} | ` + + asBold('Memory') + + `: ${(metric.memUsed >>> 20).toString().padStart(5)} / ${( + metric.memTotal >>> 20 + ) + .toString() + .padEnd(5)} MiB | ` + + asBold('Disk') + + `: ${(metric.diskUsed >>> 20).toString().padStart(5)} / ${( + metric.diskTotal >>> 20 + ) + .toString() + .padEnd(5)} MiB` + ) + } +} diff --git a/packages/cli/src/commands/sandbox/utils.ts b/packages/cli/src/commands/sandbox/utils.ts new file mode 100644 index 0000000000..1f2fc6ab1e --- /dev/null +++ b/packages/cli/src/commands/sandbox/utils.ts @@ -0,0 +1,55 @@ +import { listSandboxes } from './list' +import { wait } from '../../utils/wait' +import {asBold} from '../../utils/format' + + +export function formatEnum(e: { [key: string]: string }) { + return Object.values(e) + .map((level) => asBold(level)) + .join(', ') +} + +export enum Format { + JSON = 'json', + PRETTY = 'pretty', +} + + +const maxRuntime = 24 * 60 * 60 * 1000 // 24 hours in milliseconds + +export function waitForSandboxEnd(sandboxID: string) { + let isRunning = true + + async function monitor() { + const startTime = new Date().getTime() + + // eslint-disable-next-line no-constant-condition + while (true) { + const currentTime = new Date().getTime() + const elapsedTime = currentTime - startTime // Time elapsed in milliseconds + + // Check if 24 hours (in milliseconds) have passed + if (elapsedTime >= maxRuntime) { + break + } + + const response = await listSandboxes() + const sandbox = response.find( + (s) => s.sandboxID === getShortID(sandboxID) + ) + if (!sandbox) { + isRunning = false + break + } + await wait(5000) + } + } + + monitor() + + return () => isRunning +} + +export function getShortID(sandboxID: string) { + return sandboxID.split('-')[0] +} diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 25747aedb0..f02f8ffb73 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -699,6 +699,7 @@ export interface paths { get: { parameters: { query?: { + level?: components["schemas"]["LogLevel"]; /** @description Index of the starting build log that should be returned with the template */ logsOffset?: number; }; @@ -733,6 +734,49 @@ export interface paths { patch?: never; trace?: never; }; + "/templates/{templateID}/files/{hash}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get an upload link for a tar file containing build layer files */ + get: { + parameters: { + query?: never; + header?: never; + path: { + hash: string; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The upload link where to upload the tar file */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateBuildFileUpload"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v2/sandboxes": { parameters: { query?: never; @@ -781,10 +825,107 @@ export interface paths { patch?: never; trace?: never; }; + "/v2/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Create a new template */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildRequestV2"]; + }; + }; + responses: { + /** @description The build was requested successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Template"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v2/templates/{templateID}/builds/{buildID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Start the build */ + post: { + parameters: { + query?: never; + header?: never; + path: { + buildID: components["parameters"]["buildID"]; + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateBuildStartV2"]; + }; + }; + responses: { + /** @description The build has started */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + BuildLogEntry: { + /** @description Log level of the entry */ + level: components["schemas"]["LogLevel"]; + /** @description Log message content */ + message: string; + /** + * Format: date-time + * @description Timestamp of the log entry + */ + timestamp: string; + }; /** * Format: int32 * @description CPU cores for the sandbox @@ -879,6 +1020,11 @@ export interface components { /** @description Identifier of the template from which is the sandbox created */ templateID: string; }; + /** + * @description State of the sandbox + * @enum {string} + */ + LogLevel: "debug" | "info" | "warn" | "error"; /** * Format: int32 * @description Memory for the sandbox in MB @@ -1084,6 +1230,16 @@ export interface components { * @description CPU usage percentage */ cpuUsedPct: number; + /** + * Format: int64 + * @description Total disk space in bytes + */ + diskTotal: number; + /** + * Format: int64 + * @description Disk used in bytes + */ + diskUsed: number; /** * Format: int64 * @description Total memory in bytes @@ -1186,6 +1342,11 @@ export interface components { TemplateBuild: { /** @description Identifier of the build */ buildID: string; + /** + * @description Build logs structured + * @default [] + */ + logEntries: components["schemas"]["BuildLogEntry"][]; /** * @description Build logs * @default [] @@ -1201,6 +1362,12 @@ export interface components { /** @description Identifier of the template */ templateID: string; }; + TemplateBuildFileUpload: { + /** @description Whether the file is already present in the cache */ + present: boolean; + /** @description Url where the file should be uploaded to */ + url?: string; + }; TemplateBuildRequest: { /** @description Alias of the template */ alias?: string; @@ -1215,6 +1382,49 @@ export interface components { /** @description Identifier of the team */ teamID?: string; }; + TemplateBuildRequestV2: { + /** @description Alias of the template */ + alias: string; + cpuCount?: components["schemas"]["CPUCount"]; + memoryMB?: components["schemas"]["MemoryMB"]; + /** @description Identifier of the team */ + teamID?: string; + }; + TemplateBuildStartV2: { + /** + * @description Whether the whole build should be forced to run regardless of the cache + * @default false + */ + force: boolean; + /** @description Image to use as a base for the template build */ + fromImage: string; + /** @description Ready check command to execute in the template after the build */ + readyCmd?: string; + /** @description Start command to execute in the template after the build */ + startCmd?: string; + /** + * @description List of steps to execute in the template build + * @default [] + */ + steps: components["schemas"]["TemplateStep"][]; + }; + /** @description Step in the template build process */ + TemplateStep: { + /** + * @description Arguments for the step + * @default [] + */ + args: string[]; + /** @description Hash of the files used in the step */ + filesHash?: string; + /** + * @description Whether the step should be forced to run regardless of the cache + * @default false + */ + force: boolean; + /** @description Type of the step */ + type: string; + }; TemplateUpdateRequest: { /** @description Whether the template is public or only accessible by the team */ public?: boolean; diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 7aa151a7c7..a0469fa983 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -41,7 +41,7 @@ export type { } from './sandbox/commands' export type { SandboxOpts } from './sandbox' -export type { SandboxInfo } from './sandbox/sandboxApi' +export type { SandboxInfo, SandboxMetrics } from './sandbox/sandboxApi' export { Sandbox } import { Sandbox } from './sandbox' export default Sandbox diff --git a/packages/js-sdk/src/logs.ts b/packages/js-sdk/src/logs.ts index 87422b95f8..5d6d51a2b0 100644 --- a/packages/js-sdk/src/logs.ts +++ b/packages/js-sdk/src/logs.ts @@ -13,6 +13,10 @@ export interface Logger { * Info level logging method. */ info?: (...args: any[]) => void + /** + * Warn level logging method. + */ + warn?: (...args: any[]) => void /** * Error level logging method. */ diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 8dca724847..eae898942f 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -10,8 +10,10 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api' import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' import { Filesystem } from './filesystem' -import { SandboxApi } from './sandboxApi' +import { SandboxApi, SandboxMetricsOpts } from './sandboxApi' import { getSignature } from './signature' +import { compareVersions } from 'compare-versions' +import { SandboxError } from '../errors' /** * Options for creating a new Sandbox. @@ -520,6 +522,35 @@ export class Sandbox extends SandboxApi { }) } + /** + * Get the metrics of the sandbox. + * + * @param opts connection options. + * + * @returns List of sandbox metrics containing CPU, memory and disk usage information. + */ + async getMetrics(opts?: Pick) { + if (this.envdApi.version) { + if (compareVersions(this.envdApi.version, '0.1.5') < 0) { + throw new SandboxError( + 'You need to update the template to use the new SDK. ' + + 'You can do this by running `e2b template build` in the directory with the template.' + ) + } + + if (compareVersions(this.envdApi.version, '0.2.4') < 0) { + this.connectionConfig.logger?.warn?.( + 'Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics.' + ) + } + } + + return await Sandbox.getMetrics(this.sandboxId, { + ...this.connectionConfig, + ...opts, + }) + } + private fileUrl(path?: string, username?: string) { const url = new URL('/files', this.envdApiUrl) diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 2e75cdbec8..1c35795456 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -21,6 +21,17 @@ export interface SandboxListOpts extends SandboxApiOpts { query?: { metadata?: Record } } +export interface SandboxMetricsOpts extends SandboxApiOpts { + /** + * Start time for the metrics, defaults to the start of the sandbox + */ + start?: string | Date + /** + * End time for the metrics, defaults to the current time + */ + end?: string | Date +} + /** * Information about a sandbox. */ @@ -80,33 +91,33 @@ export interface ListedSandbox { /** * Template ID alias. */ - alias?: string; + alias?: string /** * Template ID. */ - templateId: string; + templateId: string /** * Client ID. * @deprecated */ - clientId: string; + clientId: string /** * Sandbox state. */ - state: 'running' | 'paused'; + state: 'running' | 'paused' /** * Sandbox CPU count. */ - cpuCount: number; + cpuCount: number /** * Sandbox Memory size in MB. */ - memoryMB: number; + memoryMB: number /** * Saved sandbox metadata. @@ -116,14 +127,53 @@ export interface ListedSandbox { /** * Sandbox expected end time. */ - endAt: Date; + endAt: Date /** * Sandbox start time. */ - startedAt: Date; + startedAt: Date } +/** + * Sandbox resource usage metrics. + */ +export interface SandboxMetrics { + /** + * Timestamp of the metrics. + */ + timestamp: Date + + /** + * CPU usage in percentage. + */ + cpuUsedPct: number + + /** + * Number of CPU cores. + */ + cpuCount: number + + /** + * Memory usage in bytes. + */ + memUsed: number + + /** + * Total memory available in bytes. + */ + memTotal: number + + /** + * Used disk space in bytes. + */ + diskUsed: number + + /** + * Total disk space available in bytes. + */ + diskTotal: number +} export class SandboxApi { protected constructor() {} @@ -202,7 +252,7 @@ export class SandboxApi { return ( res.data?.map((sandbox: components['schemas']['ListedSandbox']) => ({ - sandboxId: sandbox.sandboxID, + sandboxId: sandbox.sandboxID, templateId: sandbox.templateID, clientId: sandbox.clientID, state: sandbox.state, @@ -262,6 +312,50 @@ export class SandboxApi { } } + /** + * Get the metrics of the sandbox. + * + * @param sandboxId sandbox ID. + * @param opts sandbox metrics options. + * + * @returns List of sandbox metrics containing CPU, memory and disk usage information. + */ + static async getMetrics( + sandboxId: string, + opts?: SandboxMetricsOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.GET('/sandboxes/{sandboxID}/metrics', { + params: { + path: { + sandboxID: sandboxId, + start: opts?.start, + end: opts?.end, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + return ( + res.data?.map((metric: components['schemas']['SandboxMetric']) => ({ + timestamp: new Date(metric.timestamp), + cpuUsedPct: metric.cpuUsedPct, + cpuCount: metric.cpuCount, + memUsed: metric.memUsed, + memTotal: metric.memTotal, + diskUsed: metric.diskUsed, + diskTotal: metric.diskTotal, + })) ?? [] + ) + } + /** * Set the timeout of the specified sandbox. * After the timeout expires the sandbox will be automatically killed. @@ -346,7 +440,7 @@ export class SandboxApi { sandboxId: res.data!.sandboxID, sandboxDomain: res.data!.domain || undefined, envdVersion: res.data!.envdVersion, - envdAccessToken: res.data!.envdAccessToken + envdAccessToken: res.data!.envdAccessToken, } } diff --git a/packages/js-sdk/tests/sandbox/metrics.test.ts b/packages/js-sdk/tests/sandbox/metrics.test.ts new file mode 100644 index 0000000000..b675432436 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/metrics.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' + +import { SandboxMetrics } from '../../src' +import {sandboxTest, isDebug, wait} from '../setup.js' + +sandboxTest.skipIf(isDebug)('sbx metrics', async ({ sandbox }) => { + let metrics: SandboxMetrics[] + for (let i = 0; i < 10; i++) { + metrics = await sandbox.getMetrics() + if (metrics.length > 0) { + break + } + await wait(1_000) + } + + expect(metrics.length).toBeGreaterThan(0) + const metric = metrics[0] + expect(metric.diskTotal).toBeDefined() + expect(metric.diskUsed).toBeDefined() + expect(metric.memTotal).toBeDefined() + expect(metric.memUsed).toBeDefined() + expect(metric.cpuUsedPct).toBeDefined() + expect(metric.cpuCount).toBeDefined() +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index c0c99d8754..f95a5429d4 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -42,7 +42,7 @@ NotEnoughSpaceException, TemplateException, ) -from .sandbox.sandbox_api import SandboxInfo +from .sandbox.sandbox_api import SandboxInfo, SandboxMetrics from .sandbox.commands.main import ProcessInfo from .sandbox.commands.command_handle import ( CommandResult, @@ -84,6 +84,7 @@ "TemplateException", # Sandbox API "SandboxInfo", + "SandboxMetrics", "ProcessInfo", # Command handle "CommandResult", diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 5dc96d9c3f..640f8f106f 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -1,10 +1,12 @@ """Contains all the data models used in inputs/outputs""" +from .build_log_entry import BuildLogEntry from .created_access_token import CreatedAccessToken from .created_team_api_key import CreatedTeamAPIKey from .error import Error from .identifier_masking_details import IdentifierMaskingDetails from .listed_sandbox import ListedSandbox +from .log_level import LogLevel from .new_access_token import NewAccessToken from .new_sandbox import NewSandbox from .new_team_api_key import NewTeamAPIKey @@ -29,17 +31,23 @@ from .team_user import TeamUser from .template import Template from .template_build import TemplateBuild +from .template_build_file_upload import TemplateBuildFileUpload from .template_build_request import TemplateBuildRequest +from .template_build_request_v2 import TemplateBuildRequestV2 +from .template_build_start_v2 import TemplateBuildStartV2 from .template_build_status import TemplateBuildStatus +from .template_step import TemplateStep from .template_update_request import TemplateUpdateRequest from .update_team_api_key import UpdateTeamAPIKey __all__ = ( + "BuildLogEntry", "CreatedAccessToken", "CreatedTeamAPIKey", "Error", "IdentifierMaskingDetails", "ListedSandbox", + "LogLevel", "NewAccessToken", "NewSandbox", "NewTeamAPIKey", @@ -62,8 +70,12 @@ "TeamUser", "Template", "TemplateBuild", + "TemplateBuildFileUpload", "TemplateBuildRequest", + "TemplateBuildRequestV2", + "TemplateBuildStartV2", "TemplateBuildStatus", + "TemplateStep", "TemplateUpdateRequest", "UpdateTeamAPIKey", ) diff --git a/packages/python-sdk/e2b/api/client/models/build_log_entry.py b/packages/python-sdk/e2b/api/client/models/build_log_entry.py new file mode 100644 index 0000000000..7d352ea12d --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/build_log_entry.py @@ -0,0 +1,79 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +from ..models.log_level import LogLevel + +T = TypeVar("T", bound="BuildLogEntry") + + +@_attrs_define +class BuildLogEntry: + """ + Attributes: + level (LogLevel): State of the sandbox + message (str): Log message content + timestamp (datetime.datetime): Timestamp of the log entry + """ + + level: LogLevel + message: str + timestamp: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + level = self.level.value + + message = self.message + + timestamp = self.timestamp.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "level": level, + "message": message, + "timestamp": timestamp, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + level = LogLevel(d.pop("level")) + + message = d.pop("message") + + timestamp = isoparse(d.pop("timestamp")) + + build_log_entry = cls( + level=level, + message=message, + timestamp=timestamp, + ) + + build_log_entry.additional_properties = d + return build_log_entry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/log_level.py b/packages/python-sdk/e2b/api/client/models/log_level.py new file mode 100644 index 0000000000..bb5bcd9051 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/log_level.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class LogLevel(str, Enum): + DEBUG = "debug" + ERROR = "error" + INFO = "info" + WARN = "warn" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py index 3ac132ae24..ed0f637f86 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_metric.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_metric.py @@ -16,6 +16,8 @@ class SandboxMetric: Attributes: cpu_count (int): Number of CPU cores cpu_used_pct (float): CPU usage percentage + disk_total (int): Total disk space in bytes + disk_used (int): Disk used in bytes mem_total (int): Total memory in bytes mem_used (int): Memory used in bytes timestamp (datetime.datetime): Timestamp of the metric entry @@ -23,6 +25,8 @@ class SandboxMetric: cpu_count: int cpu_used_pct: float + disk_total: int + disk_used: int mem_total: int mem_used: int timestamp: datetime.datetime @@ -33,6 +37,10 @@ def to_dict(self) -> dict[str, Any]: cpu_used_pct = self.cpu_used_pct + disk_total = self.disk_total + + disk_used = self.disk_used + mem_total = self.mem_total mem_used = self.mem_used @@ -45,6 +53,8 @@ def to_dict(self) -> dict[str, Any]: { "cpuCount": cpu_count, "cpuUsedPct": cpu_used_pct, + "diskTotal": disk_total, + "diskUsed": disk_used, "memTotal": mem_total, "memUsed": mem_used, "timestamp": timestamp, @@ -60,6 +70,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: cpu_used_pct = d.pop("cpuUsedPct") + disk_total = d.pop("diskTotal") + + disk_used = d.pop("diskUsed") + mem_total = d.pop("memTotal") mem_used = d.pop("memUsed") @@ -69,6 +83,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: sandbox_metric = cls( cpu_count=cpu_count, cpu_used_pct=cpu_used_pct, + disk_total=disk_total, + disk_used=disk_used, mem_total=mem_total, mem_used=mem_used, timestamp=timestamp, diff --git a/packages/python-sdk/e2b/api/client/models/template_build.py b/packages/python-sdk/e2b/api/client/models/template_build.py index 1d5691957d..a6247eccf4 100644 --- a/packages/python-sdk/e2b/api/client/models/template_build.py +++ b/packages/python-sdk/e2b/api/client/models/template_build.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -7,6 +7,10 @@ from ..models.template_build_status import TemplateBuildStatus from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.build_log_entry import BuildLogEntry + + T = TypeVar("T", bound="TemplateBuild") @@ -15,6 +19,7 @@ class TemplateBuild: """ Attributes: build_id (str): Identifier of the build + log_entries (list['BuildLogEntry']): Build logs structured logs (list[str]): Build logs status (TemplateBuildStatus): Status of the template template_id (str): Identifier of the template @@ -22,6 +27,7 @@ class TemplateBuild: """ build_id: str + log_entries: list["BuildLogEntry"] logs: list[str] status: TemplateBuildStatus template_id: str @@ -31,6 +37,11 @@ class TemplateBuild: def to_dict(self) -> dict[str, Any]: build_id = self.build_id + log_entries = [] + for log_entries_item_data in self.log_entries: + log_entries_item = log_entries_item_data.to_dict() + log_entries.append(log_entries_item) + logs = self.logs status = self.status.value @@ -44,6 +55,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update( { "buildID": build_id, + "logEntries": log_entries, "logs": logs, "status": status, "templateID": template_id, @@ -56,9 +68,18 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.build_log_entry import BuildLogEntry + d = dict(src_dict) build_id = d.pop("buildID") + log_entries = [] + _log_entries = d.pop("logEntries") + for log_entries_item_data in _log_entries: + log_entries_item = BuildLogEntry.from_dict(log_entries_item_data) + + log_entries.append(log_entries_item) + logs = cast(list[str], d.pop("logs")) status = TemplateBuildStatus(d.pop("status")) @@ -69,6 +90,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: template_build = cls( build_id=build_id, + log_entries=log_entries, logs=logs, status=status, template_id=template_id, diff --git a/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py b/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py new file mode 100644 index 0000000000..a7d4e44a04 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_file_upload.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildFileUpload") + + +@_attrs_define +class TemplateBuildFileUpload: + """ + Attributes: + present (bool): Whether the file is already present in the cache + url (Union[Unset, str]): Url where the file should be uploaded to + """ + + present: bool + url: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + present = self.present + + url = self.url + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "present": present, + } + ) + if url is not UNSET: + field_dict["url"] = url + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + present = d.pop("present") + + url = d.pop("url", UNSET) + + template_build_file_upload = cls( + present=present, + url=url, + ) + + template_build_file_upload.additional_properties = d + return template_build_file_upload + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py b/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py new file mode 100644 index 0000000000..714109487b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_request_v2.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateBuildRequestV2") + + +@_attrs_define +class TemplateBuildRequestV2: + """ + Attributes: + alias (str): Alias of the template + cpu_count (Union[Unset, int]): CPU cores for the sandbox + memory_mb (Union[Unset, int]): Memory for the sandbox in MB + team_id (Union[Unset, str]): Identifier of the team + """ + + alias: str + cpu_count: Union[Unset, int] = UNSET + memory_mb: Union[Unset, int] = UNSET + team_id: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + alias = self.alias + + cpu_count = self.cpu_count + + memory_mb = self.memory_mb + + team_id = self.team_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "alias": alias, + } + ) + if cpu_count is not UNSET: + field_dict["cpuCount"] = cpu_count + if memory_mb is not UNSET: + field_dict["memoryMB"] = memory_mb + if team_id is not UNSET: + field_dict["teamID"] = team_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + alias = d.pop("alias") + + cpu_count = d.pop("cpuCount", UNSET) + + memory_mb = d.pop("memoryMB", UNSET) + + team_id = d.pop("teamID", UNSET) + + template_build_request_v2 = cls( + alias=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + team_id=team_id, + ) + + template_build_request_v2.additional_properties = d + return template_build_request_v2 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py b/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py new file mode 100644 index 0000000000..6ab77c7d7c --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_build_start_v2.py @@ -0,0 +1,114 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.template_step import TemplateStep + + +T = TypeVar("T", bound="TemplateBuildStartV2") + + +@_attrs_define +class TemplateBuildStartV2: + """ + Attributes: + from_image (str): Image to use as a base for the template build + force (Union[Unset, bool]): Whether the whole build should be forced to run regardless of the cache Default: + False. + ready_cmd (Union[Unset, str]): Ready check command to execute in the template after the build + start_cmd (Union[Unset, str]): Start command to execute in the template after the build + steps (Union[Unset, list['TemplateStep']]): List of steps to execute in the template build + """ + + from_image: str + force: Union[Unset, bool] = False + ready_cmd: Union[Unset, str] = UNSET + start_cmd: Union[Unset, str] = UNSET + steps: Union[Unset, list["TemplateStep"]] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from_image = self.from_image + + force = self.force + + ready_cmd = self.ready_cmd + + start_cmd = self.start_cmd + + steps: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.steps, Unset): + steps = [] + for steps_item_data in self.steps: + steps_item = steps_item_data.to_dict() + steps.append(steps_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "fromImage": from_image, + } + ) + if force is not UNSET: + field_dict["force"] = force + if ready_cmd is not UNSET: + field_dict["readyCmd"] = ready_cmd + if start_cmd is not UNSET: + field_dict["startCmd"] = start_cmd + if steps is not UNSET: + field_dict["steps"] = steps + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.template_step import TemplateStep + + d = dict(src_dict) + from_image = d.pop("fromImage") + + force = d.pop("force", UNSET) + + ready_cmd = d.pop("readyCmd", UNSET) + + start_cmd = d.pop("startCmd", UNSET) + + steps = [] + _steps = d.pop("steps", UNSET) + for steps_item_data in _steps or []: + steps_item = TemplateStep.from_dict(steps_item_data) + + steps.append(steps_item) + + template_build_start_v2 = cls( + from_image=from_image, + force=force, + ready_cmd=ready_cmd, + start_cmd=start_cmd, + steps=steps, + ) + + template_build_start_v2.additional_properties = d + return template_build_start_v2 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_step.py b/packages/python-sdk/e2b/api/client/models/template_step.py new file mode 100644 index 0000000000..45daaecd06 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_step.py @@ -0,0 +1,91 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="TemplateStep") + + +@_attrs_define +class TemplateStep: + """Step in the template build process + + Attributes: + type_ (str): Type of the step + args (Union[Unset, list[str]]): Arguments for the step + files_hash (Union[Unset, str]): Hash of the files used in the step + force (Union[Unset, bool]): Whether the step should be forced to run regardless of the cache Default: False. + """ + + type_: str + args: Union[Unset, list[str]] = UNSET + files_hash: Union[Unset, str] = UNSET + force: Union[Unset, bool] = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + args: Union[Unset, list[str]] = UNSET + if not isinstance(self.args, Unset): + args = self.args + + files_hash = self.files_hash + + force = self.force + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + } + ) + if args is not UNSET: + field_dict["args"] = args + if files_hash is not UNSET: + field_dict["filesHash"] = files_hash + if force is not UNSET: + field_dict["force"] = force + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = d.pop("type") + + args = cast(list[str], d.pop("args", UNSET)) + + files_hash = d.pop("filesHash", UNSET) + + force = d.pop("force", UNSET) + + template_step = cls( + type_=type_, + args=args, + files_hash=files_hash, + force=force, + ) + + template_step.additional_properties = d + return template_step + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index f928a40393..49ec991f31 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -63,6 +63,26 @@ class SandboxQuery: """Filter sandboxes by metadata.""" +@dataclass +class SandboxMetrics: + """Sandbox metrics.""" + + cpu_count: int + """Number of CPUs.""" + cpu_used_pct: float + """CPU usage percentage.""" + disk_total: int + """Total disk space in bytes.""" + disk_used: int + """Disk used in bytes.""" + mem_total: int + """Total memory in bytes.""" + mem_used: int + """Memory used in bytes.""" + timestamp: datetime + """Timestamp of the metric entry.""" + + class SandboxApiBase(ABC): _limits = Limits( max_keepalive_connections=10, diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 27f60b915d..69a5916587 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -1,14 +1,18 @@ +import datetime import logging import httpx -from typing import Dict, Optional, TypedDict, overload +from typing import Dict, Optional, TypedDict, overload, List + +from packaging.version import Version from typing_extensions import Unpack from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception -from e2b.exceptions import format_request_timeout_error +from e2b.exceptions import format_request_timeout_error, SandboxException from e2b.sandbox.main import SandboxSetup +from e2b.sandbox.sandbox_api import SandboxMetrics from e2b.sandbox.utils import class_method_variant from e2b.sandbox_async.filesystem.filesystem import Filesystem from e2b.sandbox_async.commands.command import Commands @@ -508,3 +512,85 @@ async def get_info( # type: ignore sandbox_id=self.sandbox_id, **config_dict, ) + + @overload + async def get_metrics( # type: ignore + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + async def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param api_key: E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + async def get_metrics( # type: ignore + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self._envd_version: + if Version(self._envd_version) < Version("0.1.5"): + raise SandboxException( + "Metrics are not supported in this version of the sandbox, please rebuild your template." + ) + + if Version(self._envd_version) < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + config_dict = self.connection_config.__dict__ + config_dict.pop("access_token", None) + config_dict.pop("api_url", None) + if request_timeout: + config_dict["request_timeout"] = request_timeout + + return await self._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **config_dict, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 6ef22bb763..200fab1312 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -1,3 +1,4 @@ +import datetime import urllib.parse from typing import Optional, Dict, List @@ -9,16 +10,22 @@ SandboxApiBase, SandboxQuery, ListedSandbox, + SandboxMetrics, ) -from e2b.exceptions import TemplateException +from e2b.exceptions import TemplateException, SandboxException from e2b.api import AsyncApiClient, SandboxCreateResponse -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api.client.models import ( + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + Error, +) from e2b.api.client.api.sandboxes import ( get_sandboxes_sandbox_id, post_sandboxes_sandbox_id_timeout, get_sandboxes, delete_sandboxes_sandbox_id, post_sandboxes, + get_sandboxes_sandbox_id_metrics, ) from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.api import handle_api_exception @@ -298,3 +305,64 @@ async def _create_sandbox( envd_version=res.parsed.envd_version, envd_access_token=res.parsed.envd_access_token, ) + + @classmethod + async def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + headers: Optional[Dict[str, str]] = None, + proxy: Optional[ProxyTypes] = None, + ) -> List[SandboxMetrics]: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + headers=headers, + request_timeout=request_timeout, + proxy=proxy, + ) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + async with AsyncApiClient( + config, + limits=cls._limits, + ) as api_client: + res = await get_sandboxes_sandbox_id_metrics.asyncio_detailed( + sandbox_id, + start=int(start.timestamp() * 1000) if start else None, + end=int(end.timestamp() * 1000) if end else None, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + # Check if res.parse is Error + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index b09cc8190c..bbe99b01bb 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -1,13 +1,17 @@ +import datetime import logging import httpx -from typing import Dict, Optional, overload +from typing import Dict, Optional, overload, List + +from packaging.version import Version from e2b.api.client.types import Unset from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception from e2b.exceptions import SandboxException, format_request_timeout_error from e2b.sandbox.main import SandboxSetup +from e2b.sandbox.sandbox_api import SandboxMetrics from e2b.sandbox.utils import class_method_variant from e2b.sandbox_sync.filesystem.filesystem import Filesystem from e2b.sandbox_sync.commands.command import Commands @@ -484,3 +488,85 @@ def get_info( # type: ignore sandbox_id=self.sandbox_id, **config_dict, ) + + @overload + def get_metrics( # type: ignore + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @overload + @staticmethod + def get_metrics( + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the sandbox specified by sandbox ID. + + :param sandbox_id: Sandbox ID + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param api_key: E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + ... + + @class_method_variant("_cls_get_metrics") + def get_metrics( # type: ignore + self, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + request_timeout: Optional[float] = None, + ) -> List[SandboxMetrics]: + """ + Get the metrics of the current sandbox. + + :param start: Start time for the metrics, defaults to the start of the sandbox + :param end: End time for the metrics, defaults to current time + :param request_timeout: Timeout for the request in **seconds** + + :return: List of sandbox metrics containing CPU, memory and disk usage information + """ + if self._envd_version: + if Version(self._envd_version) < Version("0.1.5"): + raise SandboxException( + "Metrics are not supported in this version of the sandbox, please rebuild your template." + ) + + if Version(self._envd_version) < Version("0.2.4"): + logger.warning( + "Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics." + ) + + config_dict = self.connection_config.__dict__ + config_dict.pop("access_token", None) + config_dict.pop("api_url", None) + if request_timeout: + config_dict["request_timeout"] = request_timeout + + return self._cls_get_metrics( + sandbox_id=self.sandbox_id, + start=start, + end=end, + **config_dict, + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index e817276dcf..109d3235c7 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -1,3 +1,4 @@ +import datetime import urllib.parse from typing import Optional, Dict, List @@ -8,16 +9,22 @@ SandboxApiBase, SandboxQuery, ListedSandbox, + SandboxMetrics, ) -from e2b.exceptions import TemplateException +from e2b.exceptions import TemplateException, SandboxException from e2b.api import ApiClient, SandboxCreateResponse -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api.client.models import ( + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + Error, +) from e2b.api.client.api.sandboxes import ( get_sandboxes_sandbox_id, post_sandboxes_sandbox_id_timeout, get_sandboxes, delete_sandboxes_sandbox_id, post_sandboxes, + get_sandboxes_sandbox_id_metrics, ) from e2b.connection_config import ConnectionConfig, ProxyTypes from e2b.api import handle_api_exception @@ -290,3 +297,63 @@ def _create_sandbox( envd_version=res.parsed.envd_version, envd_access_token=res.parsed.envd_access_token, ) + + @classmethod + def _cls_get_metrics( + cls, + sandbox_id: str, + start: Optional[datetime.datetime] = None, + end: Optional[datetime.datetime] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + headers: Optional[Dict[str, str]] = None, + proxy: Optional[ProxyTypes] = None, + ) -> List[SandboxMetrics]: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + headers=headers, + proxy=proxy, + ) + + if config.debug: + # Skip getting the metrics in debug mode + return [] + + with ApiClient( + config, + limits=cls._limits, + ) as api_client: + res = get_sandboxes_sandbox_id_metrics.sync_detailed( + sandbox_id, + start=int(start.timestamp() * 1000) if start else None, + end=int(end.timestamp() * 1000) if end else None, + client=api_client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + # Convert to typed SandboxMetrics objects + return [ + SandboxMetrics( + cpu_count=metric.cpu_count, + cpu_used_pct=metric.cpu_used_pct, + disk_total=metric.disk_total, + disk_used=metric.disk_used, + mem_total=metric.mem_total, + mem_used=metric.mem_used, + timestamp=metric.timestamp, + ) + for metric in res.parsed + ] diff --git a/packages/python-sdk/tests/async/sandbox_async/test_metrics.py b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py new file mode 100644 index 0000000000..3d1e30a11d --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_metrics.py @@ -0,0 +1,26 @@ +import asyncio + +import pytest + +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_sbx_metrics(async_sandbox: AsyncSandbox): + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(10): + metrics = await async_sandbox.get_metrics() + if len(metrics) > 0: + break + await asyncio.sleep(1) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py new file mode 100644 index 0000000000..03c5eb92ca --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_metrics.py @@ -0,0 +1,26 @@ +import time + +import pytest + +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_sbx_metrics(sandbox: Sandbox): + # Wait for the sandbox to have some metrics + metrics = [] + for _ in range(10): + metrics = sandbox.get_metrics() + if len(metrics) > 0: + break + time.sleep(1) + + assert len(metrics) > 0 + + metric = metrics[0] + assert metric.cpu_count is not None + assert metric.cpu_used_pct is not None + assert metric.mem_used is not None + assert metric.mem_total is not None + assert metric.disk_used is not None + assert metric.disk_total is not None