Skip to content

Commit

Permalink
Add CLI command for printing logs from sandbox (#327)
Browse files Browse the repository at this point in the history
This PR adds the CLI command for printing logs for any sandbox you
spawned.

Tasks:
- [x] Add flag for specifying format (json)
  - [x] Update docs
- [x] Add flag for filtering level
  - [x] Update docs
- [x] Add flag for "following"
  - [x] Update docs
  - [x] Test
- [x] Filter internal and "user" — rename logger field
  - [x] Test usability
  - [x] Update docs
  • Loading branch information
ValentaTomas committed May 1, 2024
2 parents d2cb61e + 407358f commit 4a43675
Show file tree
Hide file tree
Showing 20 changed files with 1,352 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/strong-ghosts-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@e2b/cli": minor
---

Add command for printing sandbox logs
6 changes: 6 additions & 0 deletions .changeset/wet-chairs-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@e2b/python-sdk": minor
"e2b": minor
---

Add logs endpoint to API
48 changes: 48 additions & 0 deletions apps/docs/src/app/cli/commands/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,51 @@ Immediately kill a running sandbox.
e2b sandbox kill <sandboxID>
```

## `sandbox logs`
Starts printing logs from the specified sandbox.
If the sandbox is running new logs will be streamed to the terminal.

The timestamps are in the UTC format.

This command is useful if you need to debug a running sandbox or check logs from a sandbox that was already closed.

```bash
e2b sandbox logs <sandboxID>
```

<Note>
You can use `e2b sandbox list` to get a list of running sandboxes and their IDs that can be used with `e2b sandbox logs <sandboxID>` command.
</Note>

#### **Arguments**

<Options>
<Option type="<sandboxID>">
Specify the ID of the sandbox you want to get logs from.
</Option>
</Options>

#### **Options**

<Options>
<Option name="level" type="--level">
Filter logs by level — allowed values are `DEBUG`, `INFO`, `WARN`, `ERROR`.
The logs with the higher levels will be also shown.

Default value is `DEBUG`.
</Option>
<Option type="-f, --follow">
Enable streaming logs until the sandbox is closed.
</Option>
<Option name="format" type="--format">
Specify format for printing logs — allowed values are `pretty`, `json`.

Default value is `pretty`.
</Option>
<Option name="loggers" type="--loggers">
Specify enabled loggers — allowed values are `process`, `filesystem`, `terminal`, `network`.
You can specify multiple loggers by separating them with a comma.

Default value is `process,filesystem`.
</Option>
</Options>
2 changes: 2 additions & 0 deletions packages/cli/src/commands/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { connectCommand } from './connect'
import { listCommand } from './list'
import { killCommand } from './kill'
import { spawnCommand } from './spawn'
import { logsCommand } from './logs'

export const sandboxCommand = new commander.Command('sandbox').description('work with sandboxes')
.alias('sbx')
.addCommand(connectCommand)
.addCommand(listCommand)
.addCommand(killCommand)
.addCommand(spawnCommand)
.addCommand(logsCommand)
277 changes: 277 additions & 0 deletions packages/cli/src/commands/sandbox/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import * as commander from 'commander'
import * as e2b from 'e2b'
import * as util from 'util'
import * as chalk from 'chalk'

import { client, ensureAPIKey } from 'src/api'
import { asBold, asTimestamp, withUnderline } from 'src/utils/format'
import { listSandboxes } from './list'
import { wait } from 'src/utils/wait'

const getSandboxLogs = e2b.withAPIKey(
client.api.path('/sandboxes/{sandboxID}/logs').method('get').create(),
)

const maxRuntime = 24 * 60 * 60 * 1000 // 24 hours in milliseconds

function getLongID(sandboxID: string, clientID?: string) {
if (clientID) {
return `${sandboxID}-${clientID}`
}
return sandboxID
}

function waitForSandboxEnd(apiKey: string, 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({ apiKey })
const sandbox = response.find(s => getLongID(s.sandboxID, s.clientID) === sandboxID)
if (!sandbox) {
isRunning = false
break
}
await wait(5000)
}
}

monitor()

return () => isRunning
}

enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}

function isLevelIncluded(level: LogLevel, allowedLevel?: LogLevel) {
if (!allowedLevel) {
return true
}

switch (allowedLevel) {
case LogLevel.DEBUG:
return true
case LogLevel.INFO:
return level === LogLevel.INFO || level === LogLevel.WARN || level === LogLevel.ERROR
case LogLevel.WARN:
return level === LogLevel.WARN || level === LogLevel.ERROR
case LogLevel.ERROR:
return level === LogLevel.ERROR
}
}

function formatEnum(e: { [key: string]: string }) {
return Object.values(e).map(level => asBold(level)).join(', ')
}

enum LogFormat {
JSON = 'json',
PRETTY = 'pretty',
}

enum LoggerService {
PROCESS = 'process',
FILESYSTEM = 'filesystem',
TERMINAL = 'terminal',
NETWORK = 'network',
}


function cleanLogger(logger?: string) {
if (!logger) {
return ''
}

return logger.replaceAll('Svc', '')
}

export const logsCommand = new commander.Command('logs')
.description('show logs for sandbox')
.argument('<sandboxID>', `show logs for sandbox specified by ${asBold('<sandboxID>')}`)
.alias('lg')
.option('--level <level>', `filter logs by level (${formatEnum(LogLevel)}). The logs with the higher levels will be also shown.`, LogLevel.INFO)
.option('-f, --follow', 'keep streaming logs until the sandbox is closed')
.option('--format <format>', `specify format for printing logs (${formatEnum(LogFormat)})`, LogFormat.PRETTY)
.option('--loggers [loggers]', `filter logs by loggers. The available loggers are ${formatEnum(LoggerService)}. Specify multiple loggers by separating them with a comma.`, (value) => {
const loggers = value.split(',').map(s => s.trim()) as LoggerService[]
// Check if all loggers are valid
loggers.forEach(s => {
if (!Object.values(LoggerService).includes(s)) {
console.error(`Invalid logger used as argument: "${s}"\nValid loggers are ${formatEnum(LoggerService)}`)
process.exit(1)
}
})
return loggers
}, [LoggerService.PROCESS, LoggerService.FILESYSTEM])
.action(async (sandboxID: string, opts?: {
level: string,
follow: boolean,
format: LogFormat,
loggers: LoggerService[] | boolean,
}) => {
try {
const level = opts?.level.toUpperCase() as LogLevel | undefined
if (level && !Object.values(LogLevel).includes(level)) {
throw new Error(`Invalid log level: ${level}`)
}

const format = opts?.format.toLowerCase() as LogFormat | undefined
if (format && !Object.values(LogFormat).includes(format)) {
throw new Error(`Invalid log format: ${format}`)
}

const apiKey = ensureAPIKey()

const getIsRunning = opts?.follow ? waitForSandboxEnd(apiKey, sandboxID) : () => false

let start: number | undefined
let isFirstRun = true
let firstLogsPrinted = false

if (format === LogFormat.PRETTY) {
console.log(`\nLogs for sandbox ${asBold(sandboxID)}:`)
}

const isRunningPromise = listSandboxes({ apiKey }).then(r => r.find(s => getLongID(s.sandboxID, s.clientID) === sandboxID)).then(s => !!s)

do {
try {
const logs = await listSandboxLogs({ apiKey, sandboxID, start })

if (logs.length !== 0 && firstLogsPrinted === false) {
firstLogsPrinted = true
process.stdout.write('\n')
}

for (const log of logs) {
printLog(log.timestamp, log.line, level, format, opts?.loggers)
}

const isRunning = await isRunningPromise

if (!isRunning && logs.length === 0 && isFirstRun) {
if (format === LogFormat.PRETTY) {
console.log(`\nStopped printing logs — sandbox ${withUnderline('not found')}`)
}
break
}

if (!isRunning) {
if (format === LogFormat.PRETTY) {
console.log(`\nStopped printing logs — sandbox is ${withUnderline('closed')}`)
}
break
}

const lastLog = logs.length > 0 ? logs[logs.length - 1] : undefined
if (lastLog) {
// TODO: Use the timestamp from the last log instead of the current time?
start = new Date(lastLog.timestamp).getTime() + 1
}
} catch (e) {
if (e instanceof getSandboxLogs.Error) {
const error = e.getActualType()
if (error.status === 401) {
throw new Error(
`Error getting sandbox logs - (${error.status}) bad request: ${error}`,
)
}
if (error.status === 404) {
throw new Error(
`Error getting sandbox logs - (${error.status}) not found: ${error}`,
)
}
if (error.status === 500) {
throw new Error(
`Error getting sandbox logs - (${error.status}) server error: ${error}`,
)
}
}
throw e
}

await wait(400)
isFirstRun = false
} while (getIsRunning() && opts?.follow)
} catch (err: any) {
console.error(err)
process.exit(1)
}
})

function printLog(timestamp: string, line: string, allowedLevel: LogLevel | undefined, format: LogFormat | undefined, allowedLoggers?: LoggerService[] | boolean) {
const log = JSON.parse(line)
let level = log['level'].toUpperCase()

log.logger = cleanLogger(log.logger)

// Check if the current logger startsWith any of the allowed loggers. If there are no specified loggers, print logs from all loggers.
if (allowedLoggers !== true && Array.isArray(allowedLoggers) && !allowedLoggers.some(allowedLogger => log.logger.startsWith(allowedLogger))) {
return
}

if (!isLevelIncluded(level, allowedLevel)) {
return
}

switch (level) {
case LogLevel.DEBUG:
level = chalk.default.bgWhite(level)
break
case LogLevel.INFO:
level = chalk.default.bgGreen(level) + ' '
break
case LogLevel.WARN:
level = chalk.default.bgYellow(level) + ' '
break
case LogLevel.ERROR:
level = chalk.default.white(chalk.default.bgRed(level))
break
}

delete log['traceID']
delete log['instanceID']
delete log['source_type']
delete log['teamID']
delete log['source']
delete log['service']
delete log['envID']
delete log['sandboxID']

if (format === LogFormat.JSON) {
console.log(JSON.stringify({
timestamp: new Date(timestamp).toISOString(),
level,
...log,
}))
} else {
const time = `[${new Date(timestamp).toISOString().replace(/T/, ' ').replace(/\..+/, '')}]`
delete log['level']
console.log(`${asTimestamp(time)} ${level} ` + util.inspect(log, { colors: true, depth: null, maxArrayLength: Infinity, sorted: true, compact: true, breakLength: Infinity }))
}
}

export async function listSandboxLogs({
apiKey,
sandboxID,
start,
}: { apiKey: string, sandboxID: string, start?: number }): Promise<e2b.components['schemas']['SandboxLog'][]> {
const response = await getSandboxLogs(apiKey, { sandboxID, start })
return response.data.logs
}
9 changes: 9 additions & 0 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export function asPrimary(content: string) {
return chalk.default.hex(primaryColor)(content)
}

export function asTimestamp(content: string) {
return chalk.default.blue(content)
}

export function asSandboxTemplate(pathInTemplate?: string) {
return chalk.default.green(pathInTemplate)
}
Expand All @@ -62,6 +66,11 @@ export function asHeadline(content: string) {
return chalk.default.underline(asPrimary(asBold(content)))
}

export function withUnderline(content: string) {
return chalk.default.underline(content)

}

export function listAliases(aliases: string[] | undefined) {
if (!aliases) return undefined
return aliases.join(' | ')
Expand Down
Loading

0 comments on commit 4a43675

Please sign in to comment.