# Log Streaming (/docs/log-streaming)

Log streaming lets you access and process logs of a running process in real time, or retrieve a snapshot of all logs produced so far. Use streaming for long-running or background commands. Use snapshots when you only need the current output.

Every process started through the process module writes its output to a cursor-addressed log ledger. Each log frame carries a channel (stdout and stderr for commands, pty for PTY processes), the data, and a cursor you can use to resume reading from where you left off. See [log frames](#log-frames) for the full list of fields and channels.

<LogStreamingVisual />

For entrypoint log streaming, see [entrypoint process](/docs/process-execution#entrypoint-process). To stream logs while sending input to a running command, see [execute interactive commands](/docs/process-execution#execute-interactive-commands).

## Use cases [#use-cases]

Log streaming is used when an application needs the output of a process while the process still runs.

- **Live output in a UI.** Build, test, or training output appears in a web application as the process writes it. Each frame carries a timestamp, so the UI shows when the output was written. A [PTY process](/docs/pty) sends its merged terminal output on one channel, escape sequences included.

- **Agent feedback.** An agent reads the output of a long command as it arrives and stops the command early on an error. A stream sends new output as it is written and ends once the process exits. Its state event reports the exit code, signal, and reason of the process.

- **Reconnects.** A client that loses its connection resumes from its last cursor. No frame is delivered twice, and no retained frame is missed. An SSE client sends the ID of the last event it received, and the daemon uses that ID as the cursor.

- **Server logs.** The output of a background web server or worker is followed for as long as the process runs. When the server writes nothing, the daemon sends a keepalive every 15 seconds. The CLI follows the logs until the process exits, then exits with the exit code of the process. If the CLI timeout passes first, the CLI stops following and the process keeps running.

- **Error handling.** Stdout and stderr arrive on separate channels, so an application handles errors apart from regular output. Order is kept within a channel, not across channels. The CLI writes stdout frames to its standard output and stderr frames to its standard error.

## Stream logs [#stream-logs]

Stream the logs of a process. A stream replays the retained output from the beginning or from a cursor, then new output as it is written. It ends once the process exits and all output is delivered. While the process is silent, the daemon sends a keepalive every 15 seconds.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.process.start(
    'for i in {1..5}; do echo "Step $i"; echo "Error $i" >&2; sleep 1; done'
)

for event in handle.stream_logs():
    if event.type == "log":
        print(f"[{event.frame.channel.upper()}]: {event.frame.data}", end="")
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `stream_logs()` does not reopen a dropped connection.
- [Resume](#resume-a-stream) from the cursor of the last event instead.
</Callout>

</TabItem>
<TabItem label="Python (async)" icon="seti:python">

```python
import asyncio

from daytona import AsyncDaytona

async def main() -> None:
    async with AsyncDaytona() as daytona:
        sandbox = await daytona.v2.create()

        handle = await sandbox.process.start(
            'for i in {1..5}; do echo "Step $i"; echo "Error $i" >&2; sleep 1; done'
        )

        async for event in handle.stream_logs():
            if event.type == "log":
                print(f"[{event.frame.channel.upper()}]: {event.frame.data}", end="")

asyncio.run(main())
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `stream_logs()` does not reopen a dropped connection.
- [Resume](#resume-a-stream) from the cursor of the last event instead.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.process.start(
  'for i in {1..5}; do echo "Step $i"; echo "Error $i" >&2; sleep 1; done',
)

for await (const event of handle.streamLogs()) {
  if (event.type === 'log') {
    process.stdout.write(`[${event.frame.channel.toUpperCase()}]: ${event.frame.data}`)
  }
}
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `streamLogs()` does not reopen a dropped connection.
- [Resume](#resume-a-stream) from the cursor of the last event instead.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# Run a command and stream its stdout and stderr until it exits
daytona sandbox exec {sandboxId} -- bash -c 'for i in {1..5}; do echo "Step $i"; echo "Error $i" >&2; sleep 1; done'

# Follow the logs of a running process in real time (SSE)
daytona sandbox process logs {sandboxId} {processId} --follow
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- Pass the global `--timeout` flag to follow longer than its default of `10m`.
- `--follow` supports only `table` and `json` output.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Start a process
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "exec",
  "shellCommand": "for i in {1..5}; do echo \"Step $i\"; echo \"Error $i\" >&2; sleep 1; done"
}'

# Follow process logs in real time (SSE)
curl -N '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?follow=true' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `{toolboxProxyUrl}` is the `toolbox_proxy_url` field of the [sandbox](/docs/sandboxes#get-a-sandbox).
- The `limit` parameter does not apply to a follow stream.
</Callout>

</TabItem>
</Tabs>

### Stream events [#stream-events]

Handle the events of a log stream. Each event carries a cursor, also sent as the event ID, so a client can resume after any event. A stream ends after its end event or its error event.

| **Event**     | **Payload**                                                              | **Meaning**                                                                                                                                |
| ------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **`log`**     | A [log frame](#log-frames)                                               | One chunk of process output.                                                                                                               |
| **`state`**   | The process record with `exitCode`, `signal`, `reason`, and `cursor`     | The process reached the `terminal` state. Sent once. Frames written before exit can still follow it.                                       |
| **`warning`** | `cursor`, `message`, `firstAvailableCursor`                              | The requested position was evicted by [log retention](#log-retention). The stream resumes from `firstAvailableCursor` on its own.          |
| **`eof`**     | `cursor`                                                                 | The process is terminal and every frame has been delivered. The stream closes after this event.                                            |
| **`error`**   | `cursor`, `message`                                                      | The daemon failed to read the ledger. The stream closes after this event. The SDKs raise it as an error instead of yielding it.            |

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.process.start("for i in {1..3}; do echo \"Step $i\"; sleep 1; done")

# The generator returns after the eof event; a stream error is raised
for event in handle.stream_logs():
    if event.type == "log":
        print(f"[{event.frame.channel.upper()}]: {event.frame.data}", end="")
    elif event.type == "state":
        print(f"Exit code: {event.result.exit_code}, reason: {event.result.reason}")
    elif event.type == "warning":
        print(f"Older logs were evicted, resuming from {event.first_available_cursor}")
    elif event.type == "eof":
        print(f"Stream complete at cursor {event.cursor}")
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.process.start('for i in {1..3}; do echo "Step $i"; sleep 1; done')

// The iterator returns after the eof event; a stream error is thrown
for await (const event of handle.streamLogs()) {
  switch (event.type) {
    case 'log':
      process.stdout.write(`[${event.frame.channel.toUpperCase()}]: ${event.frame.data}`)
      break
    case 'state':
      console.log('Process finished:', event.result)
      break
    case 'warning':
      console.warn(`Older logs were evicted, resuming from ${event.firstAvailableCursor}`)
      break
    case 'eof':
      console.log('Stream complete at cursor', event.cursor)
      break
  }
}
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl -N '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?follow=true' \
  --header 'Authorization: Bearer YOUR_API_KEY'

# event: log
# id: djE6MQ
# data: {"cursor":"djE6MQ","channel":"stdout","timestamp":1757756400000,"data":"Step 1\n"}
#
# : keepalive
#
# event: state
# id: djE6Mw
# data: {"id":"p_...","kind":"exec","state":"terminal","exitCode":0,"reason":"exited","cursor":"djE6Mw",...}
#
# event: eof
# id: djE6Mw
# data: {"cursor":"djE6Mw"}
```

</TabItem>
</Tabs>

### Resume a stream [#resume-a-stream]

Resume a stream from a cursor. A stream opened with a cursor starts strictly after that position, so no frame is delivered twice. An SSE client that reconnects sends the ID of the last event it received, and the daemon uses that ID in place of the cursor. If retention evicted the position, the stream warns and continues from the oldest retained frame.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.process.start("for i in {1..5}; do echo \"Step $i\"; sleep 1; done")

# Stop early, remembering the cursor of the last frame processed
last_cursor = None
for event in handle.stream_logs():
    if event.type != "log":
        continue
    print(event.frame.data, end="")
    last_cursor = event.cursor
    if event.frame.data.startswith("Step 3"):
        break

# Resume strictly after the last frame seen
for event in handle.stream_logs(cursor=last_cursor):
    if event.type == "log":
        print(event.frame.data, end="")
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.process.start('for i in {1..5}; do echo "Step $i"; sleep 1; done')

// Stop early, remembering the cursor of the last event processed
let lastCursor: string | undefined
for await (const event of handle.streamLogs()) {
  lastCursor = event.cursor ?? lastCursor
  if (event.type !== 'log') continue
  process.stdout.write(event.frame.data)
  if (event.frame.data.startsWith('Step 3')) break
}

// Resume strictly after the last event seen
for await (const event of handle.streamLogs({ cursor: lastCursor })) {
  if (event.type === 'log') process.stdout.write(event.frame.data)
}
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# --output json prints one frame per line, each with its cursor
daytona sandbox process logs {sandboxId} {processId} --follow --cursor {cursor} --output json
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Resume with the cursor query parameter
curl -N '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?follow=true&cursor={cursor}' \
  --header 'Authorization: Bearer YOUR_API_KEY'

# Resume with the SSE Last-Event-ID header; it takes precedence over cursor
curl -N '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?follow=true' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Last-Event-ID: {cursor}'
```

</TabItem>
</Tabs>

## Retrieve all existing logs [#retrieve-all-existing-logs]

Read the logs a process has produced so far. A read returns one page and does not wait for new output. Output longer than one page needs [pagination](#paginate-logs).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
# Logs produced up to the current point in time
page = handle.logs()
for frame in page.items:
    print(f"[{frame.channel.upper()}]: {frame.data}", end="")
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A page holds up to `1000` frames by default.
- The daemon clamps the page size to the range `1` to `5000`.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
// Logs produced up to the current point in time
const page = await handle.logs()
for (const frame of page.items) {
  process.stdout.write(`[${frame.channel.toUpperCase()}]: ${frame.data}`)
}
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A page holds up to `1000` frames by default.
- The daemon clamps the page size to the range `1` to `5000`.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process logs {sandboxId} {processId}
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A page holds up to `1000` frames by default.
- The daemon clamps the page size to the range `1` to `5000`.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A page holds up to `1000` frames by default.
- The daemon clamps the page size to the range `1` to `5000`.
</Callout>

</TabItem>
</Tabs>

### Paginate logs [#paginate-logs]

Read logs page by page. Pass the next cursor of a page to read the page after it. An empty page means the read has caught up with the output; a process that is still running can produce more.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona
from daytona.v2 import LogFrame

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.process.start("seq 1 5000")
handle.wait()

frames: list[LogFrame] = []
cursor = None
while True:
    page = handle.logs(cursor=cursor, limit=500)
    frames.extend(page.items)
    if not page.items or page.next_cursor == cursor:
        break
    cursor = page.next_cursor

stdout = "".join(frame.data for frame in frames if frame.channel == "stdout")
print(f"{len(frames)} frames, {len(stdout)} bytes of stdout")
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `LogPage` has no end-of-log flag.
- Stop reading when a page is empty and the process has exited.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.process.start('seq 1 5000')
await handle.wait()

const frames = []
let cursor: string | undefined
for (;;) {
  const page = await handle.logs({ cursor, limit: 500 })
  frames.push(...page.items)
  if (page.items.length === 0 || page.nextCursor === cursor) break
  cursor = page.nextCursor
}

const stdout = frames.filter((frame) => frame.channel === 'stdout').map((frame) => frame.data).join('')
console.log(`${frames.length} frames, ${stdout.length} bytes of stdout`)
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `LogsPage` has no end-of-log flag.
- Stop reading when a page is empty and the process has exited.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# First page, at most 500 frames; JSON output includes nextCursor and eof
daytona sandbox process logs {sandboxId} {processId} --limit 500 --output json

# Following pages, until eof is true
daytona sandbox process logs {sandboxId} {processId} --limit 500 --cursor {nextCursor} --output json
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# First page, at most 500 frames
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?limit=500' \
  --header 'Authorization: Bearer YOUR_API_KEY'

# {
#   "frames": [
#     { "cursor": "djE6MQ", "channel": "stdout", "timestamp": 1757756400000, "data": "1\n2\n3\n" }
#   ],
#   "nextCursor": "djE6MQ",
#   "truncatedHead": false,
#   "eof": false
# }

# Following pages, until eof is true
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?cursor={nextCursor}&limit=500' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

### Binary output [#binary-output]

Read binary output without loss. The default text encoding replaces bytes that are not valid UTF-8. Base64 encoding returns the raw bytes of each frame as standard base64 with padding.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
import base64

from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.process.start("head -c 1024 /dev/urandom")
handle.wait()

page = handle.logs(encoding="base64")
raw = b"".join(
    base64.b64decode(frame.data)
    for frame in page.items
    if frame.channel == "stdout"
)
print(f"{len(raw)} bytes")
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.process.start('head -c 1024 /dev/urandom')
await handle.wait()

const page = await handle.logs({ encoding: 'base64' })
const raw = Buffer.concat(
  page.items
    .filter((frame) => frame.channel === 'stdout')
    .map((frame) => Buffer.from(frame.data, 'base64')),
)
console.log(`${raw.length} bytes`)
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# Frames carry base64 payloads; table output is not binary-safe, so use JSON
daytona sandbox process logs {sandboxId} {processId} --encoding base64 --output json
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Frames carry base64 payloads
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?encoding=base64' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

## Log frames [#log-frames]

| **Field**       | **Description**                                                                                                          |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `cursor`    | Opaque position of this frame. Pass it as `cursor` to read strictly after this frame. `""` and `start` mean the beginning. |
| `channel`   | Which stream the frame came from. See the channel table below.                                                           |
| `data`      | The payload, as UTF-8 text or as a standard base64 string with padding when `encoding` is `base64`.                     |
| `timestamp` | When the daemon recorded the frame, in Unix milliseconds.                                                                |

| **Channel**   | **Emitted by**                                                                            | **Content**                                                                                                                                     |
| ------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **`stdout`**  | `exec` and `code` processes                                                               | Standard output. `exec` processes keep `stdout` and `stderr` on separate pipes, so ordering is preserved within a channel, not across channels.  |
| **`stderr`**  | `exec` and `code` processes                                                               | Standard error.                                                                                                                                 |
| **`pty`**     | [PTY processes](/docs/pty)                                                             | The merged terminal byte stream, including escape sequences.                                                                                    |
| **`setup`**   | The daemon                                                                                | Progress messages emitted while the daemon provisions something the process needs, for example a language kernel before the first code execution. |
| **`display`** | [Code executions](/docs/code-execution)                         | A kernel side output (`display_data` or `update_display_data`) as a JSON object keyed by media type.                                            |
| **`execute_result`** | [Code executions](/docs/code-execution)                  | The kernel's primary result (`execute_result`) as a JSON object keyed by media type.                                                            |
| **`error`**   | [Code executions](/docs/code-execution)                         | A kernel exception as JSON: `{"ename", "evalue", "traceback"}`.                                                                                 |

## Log retention [#log-retention]

Control how long a process record and its logs are kept. The retention policy is set when the process starts. Independent of the policy, retained output is capped in size; output beyond the cap evicts the oldest frames. A page read from an evicted position fails with an error that names the oldest retained position; a stream warns and continues from that position.

| **Policy**          | **Default for**                                                                            | **Behavior**                                                                                                                                                         |
| ------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`until_cleanup`** | API requests that set `name`, [PTY processes](/docs/pty) started through the SDKs, and the entrypoint process | The record and logs stay until the process is deleted. A process that fails to start is downgraded to `on_exit_ttl` so failed records do not accumulate.            |
| **`on_exit_ttl`**   | API requests without `name`, and every SDK `process.start()` and `run()` call              | The record and logs are removed 10 minutes after the process exits. Reading them afterwards fails with `404 PROCESS_NOT_FOUND`.                                        |
| **`none`**          |                                                                                            | Logs are discarded the moment the process exits. The record stays for the same 10 minute window, so `wait()` still returns the exit result. `run()` never uses it. |

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona
from daytona.v2 import CursorExpiredError

daytona = Daytona()
sandbox = daytona.v2.create()

# Keep the logs of an unnamed process until delete()
handle = sandbox.process.start("seq 1 5000", keep_logs="until_cleanup")
handle.wait()

saved_cursor = "..."  # a cursor stored from an earlier read
try:
    page = handle.logs(cursor=saved_cursor)
except CursorExpiredError as e:
    # The position was evicted; restart from the oldest retained frame
    page = handle.logs(cursor=e.first_available_cursor)

handle.delete()
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'
import { CursorExpiredError } from '@daytona/sdk/v2'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

// Keep the logs of an unnamed process until delete()
const handle = await sandbox.process.start('seq 1 5000', { keepLogs: 'until_cleanup' })
await handle.wait()

const savedCursor = '...' // a cursor stored from an earlier read
let page
try {
  page = await handle.logs({ cursor: savedCursor })
} catch (error) {
  if (!(error instanceof CursorExpiredError)) throw error
  // The position was evicted; restart from the oldest retained frame
  page = await handle.logs({ cursor: error.firstAvailableCursor })
}

await handle.delete()
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Keep the logs until the record is deleted
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "exec",
  "shellCommand": "seq 1 5000",
  "keepLogs": "until_cleanup"
}'

# Read from an evicted position
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?cursor={cursor}' \
  --header 'Authorization: Bearer YOUR_API_KEY'

# HTTP 409
# {
#   "code": "CURSOR_EXPIRED",
#   "message": "the requested position was evicted by log retention",
#   "firstAvailableCursor": "djE6NDA5Ng",
#   "source": "DAYTONA_DAEMON"
# }

# Delete the record and its logs
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}' \
  --request DELETE \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

## Permissions [#permissions]

Log streaming runs through the toolbox of a sandbox. Access is governed by one project permission, which you can grant to an [organization role](/docs/organizations#roles) or an [API key](/docs/api-keys).

A [sandbox access token](/docs/sandboxes#create-an-access-token) with the toolbox:exec operation grants the same access to a single sandbox, without an API key. The sandbox read permission alone does not grant toolbox access.

| **Permission**        | **Grants**                   |
| --------------------- | ---------------------------- |
| **`write:sandboxes`** | Stream and read process logs |
