ADR 034: Streaming Deployment Logs¶
Status: Final Type: Feature Created: 2025-12-15 Related-ADRs: 018, 022, 025
Context¶
The hop deploy command can return immediately after initiating a deployment, with output like:
This "fire-and-forget" pattern creates several problems:
- No visibility: Users don't see build logs (dependency installation, compilation, errors)
- False confidence: The "success" message appears before the build actually completes
- Debugging difficulty: When builds fail, users must manually check server logs
- Poor UX: Users don't know if the app is actually running
This affects all deployment types supported by Hop3: - Python apps: pip install, virtualenv creation - Node.js apps: npm/yarn install, build scripts - Go apps: go mod download, go build - Ruby apps: bundle install - Docker apps: docker build - Static sites: asset processing
The CLI communicates with the server via JSON-RPC over HTTP. JSON-RPC's request-response model doesn't support streaming responses, which drives the fire-and-forget pattern.
Decision¶
Hop3 streams deployment logs through Server-Sent Events (SSE), with a synchronous request-response mode as the fallback when streaming is unavailable.
Synchronous Deployment (Fallback Mode)¶
The deploy command blocks until the build completes, capturing and returning all output in a single JSON-RPC response.
Server responsibilities:
- deploy_app() captures stdout/stderr from the build process
- The build output is returned as part of the JSON-RPC response
- The response includes success/failure status
CLI responsibilities: - Wait for the full response (with an appropriate timeout) - Display build output as it's received - Show a clear success/failure indication
# Fire-and-forget (insufficient)
def deploy_app(app_name: str) -> dict:
app = get_or_create_app(app_name)
app.deploy() # Returns immediately, build happens async
return {"status": "started"}
# Synchronous behavior
def deploy_app(app_name: str) -> dict:
app = get_or_create_app(app_name)
output = app.deploy() # Blocks until complete, captures output
return {
"status": "success" if output.success else "failed",
"output": output.logs,
"duration": output.duration,
}
Pros: - Works within the existing JSON-RPC framework - Output captured in a single response
Cons: - Long builds may hit HTTP/proxy timeouts - No incremental output during the build - The client must wait for the full response
Server-Sent Events Streaming (Primary Mode)¶
A streaming endpoint sends build logs in real-time using Server-Sent Events (SSE). This is the production path for both deploy and app logs, and is the answer to the in-band-streaming question left open by ADR 018.
Architecture:
CLI Server Build Process
│ │ │
│ POST /rpc {"method":"deploy"} │ │
│──────────────────────────────▶│ │
│ │ Start build, return stream_id │
│ {"stream_id": "abc123"} │◀─────────────────────────────────│
│◀──────────────────────────────│ │
│ │ │
│ GET /stream/abc123 │ │
│──────────────────────────────▶│ │
│ │ SSE: data: Installing deps... │
│ data: Installing deps... │◀─────────────────────────────────│
│◀──────────────────────────────│ │
│ │ SSE: data: Building app... │
│ data: Building app... │◀─────────────────────────────────│
│◀──────────────────────────────│ │
│ │ SSE: event: complete │
│ event: complete │◀─────────────────────────────────│
│◀──────────────────────────────│ │
New endpoint: GET /api/stream/{stream_id}
Returns an SSE stream with build output:
event: log
data: {"line": "Installing dependencies...", "timestamp": "2025-12-15T10:30:00Z"}
event: log
data: {"line": "Building application...", "timestamp": "2025-12-15T10:30:05Z"}
event: complete
data: {"status": "success", "duration": 45.2}
Server implementation:
# Build log capture
class BuildOutputStream:
"""Captures build output and streams to connected clients."""
def __init__(self, stream_id: str):
self.stream_id = stream_id
self.logs: list[str] = []
self.subscribers: list[asyncio.Queue] = []
self.complete = False
self.success = False
def write(self, line: str) -> None:
self.logs.append(line)
for queue in self.subscribers:
queue.put_nowait({"event": "log", "data": line})
def finish(self, success: bool) -> None:
self.complete = True
self.success = success
for queue in self.subscribers:
queue.put_nowait({"event": "complete", "data": {"status": "success" if success else "failed"}})
# Stream registry
_streams: dict[str, BuildOutputStream] = {}
# SSE endpoint
async def stream_logs(request: Request) -> StreamingResponse:
stream_id = request.path_params["stream_id"]
stream = _streams.get(stream_id)
if not stream:
raise HTTPException(404, "Stream not found")
async def generate():
queue = asyncio.Queue()
stream.subscribers.append(queue)
try:
# Send existing logs first
for line in stream.logs:
yield f"event: log\ndata: {json.dumps({'line': line})}\n\n"
# Stream new logs
while not stream.complete:
msg = await queue.get()
yield f"event: {msg['event']}\ndata: {json.dumps(msg['data'])}\n\n"
finally:
stream.subscribers.remove(queue)
return StreamingResponse(generate(), media_type="text/event-stream")
CLI implementation:
async def deploy_with_streaming(app_name: str) -> int:
# Start deployment, get stream ID
response = await rpc_call("deploy", {"app": app_name})
stream_id = response.get("stream_id")
if not stream_id:
# Fallback to synchronous batch output
print(response.get("output", ""))
return 0 if response.get("status") == "success" else 1
# Connect to SSE stream
async with httpx.AsyncClient() as client:
async with client.stream("GET", f"{server_url}/api/stream/{stream_id}") as resp:
async for line in resp.aiter_lines():
if line.startswith("data:"):
data = json.loads(line[5:])
if "line" in data:
print(data["line"])
elif "status" in data:
return 0 if data["status"] == "success" else 1
Build Cancellation¶
Build cancellation is out of scope: a deploy, once started, runs to completion regardless of the client connection.
- Builds continue even if the client disconnects
- The server-side build process runs to completion
- Logs are retained for later retrieval
Adding cancellation would require a POST /api/stream/{stream_id}/cancel endpoint, signal handling in the build process, and cleanup of partial builds.
SSH Tunneling¶
SSH tunneling is deprecated; the CLI communicates with the server over HTTP. This simplifies the streaming design, since it removes the need to handle SSH tunnel limitations.
Runtime Behavior¶
With SSE streaming, deployment logs are displayed in real-time as they happen:
- CLI sends deploy request with
streaming=True - Server creates a stream, starts deployment in background thread
- Server returns
stream_idimmediately - CLI connects to SSE endpoint (
GET /api/stream/{stream_id}) - Logs are streamed to CLI as they happen
- CLI displays
completeevent when done
SSH Tunnel Limitation: Streaming requires a direct HTTP connection. With ssh:// URLs the CLI falls back to batch mode (logs shown at the end).
Alternative Real-Time Monitoring¶
If streaming is unavailable, users can monitor deployment progress by SSHing to the server:
# Watch server logs during deployment
journalctl -u hop3-server -f
# Watch app-specific logs
tail -f /home/hop3/apps/<app_name>/log/*.log
# Watch uWSGI emperor logs
journalctl -u uwsgi-emperor -f
Consequences¶
Positive¶
- Users see build output in real-time (streaming) or after completion (synchronous fallback)
- Build failures are immediately visible
- Debugging deployments becomes much easier
- Better UX aligns with user expectations from other PaaS platforms
- Works over HTTP without SSH tunneling
Negative¶
- Synchronous fallback: long builds may hit timeouts
- Streaming: more complex server architecture
- SSE requires maintaining connection state
- Build logs consume memory until the stream closes
Neutral¶
- Builds continue server-side regardless of client connection
- Log retention policy will need definition
- Web UI can use same streaming endpoint
Example Usage¶
Python App¶
$ hop deploy -v myflask
> Starting deployment for app 'myflask'
-> Using builder: 'LocalBuilder'
--> Creating virtualenv...
--> Installing from requirements.txt
Collecting Flask==3.0.0
Collecting gunicorn==21.2.0
Successfully installed Flask-3.0.0 gunicorn-21.2.0
-> Build successful. Artifact: /home/hop3/apps/myflask/venv
-> Using deployment strategy: 'uwsgi'
> Deployment for 'myflask' finished successfully.
✓ App 'myflask' deployed successfully.
Node.js App¶
$ hop deploy -v myexpress
> Starting deployment for app 'myexpress'
-> Using builder: 'LocalBuilder'
--> Running: npm install
added 57 packages in 3.2s
-> Build successful. Artifact: /home/hop3/apps/myexpress/node_modules
-> Using deployment strategy: 'uwsgi'
> Deployment for 'myexpress' finished successfully.
✓ App 'myexpress' deployed successfully.
Go App¶
$ hop deploy -v mygin
> Starting deployment for app 'mygin'
-> Using builder: 'LocalBuilder'
--> Running: go mod download
--> Running: go build -o app
-> Build successful. Artifact: /home/hop3/apps/mygin/src/app
-> Using deployment strategy: 'uwsgi'
> Deployment for 'mygin' finished successfully.
✓ App 'mygin' deployed successfully.
Docker App¶
$ hop deploy -v mydocker
> Starting deployment for app 'mydocker'
-> Using builder: 'DockerBuilder'
--> Running: docker build -t hop3/mydocker:latest .
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/python:3.12-slim
#3 [1/4] FROM docker.io/library/python:3.12-slim
#4 [2/4] COPY requirements.txt .
#5 [3/4] RUN pip install -r requirements.txt
#6 [4/4] COPY . .
-> Build successful. Artifact: hop3/mydocker:latest
-> Using deployment strategy: 'docker-compose'
> Deployment for 'mydocker' finished successfully.
✓ App 'mydocker' deployed successfully.
Failed Build (Python)¶
$ hop deploy myapp
> Starting deployment for app 'myapp'
-> Using builder: 'LocalBuilder'
--> Installing from requirements.txt
ERROR: Could not find a version that satisfies the requirement nonexistent-package
✗ Deployment failed (5.1s)
Error: Dependency installation failed
Failed Build (Docker)¶
$ hop deploy mydocker
> Starting deployment for app 'mydocker'
-> Using builder: 'DockerBuilder'
--> Running: docker build -t hop3/mydocker:latest .
#1 [internal] load build definition from Dockerfile
#2 ERROR: failed to solve: python:3.99: not found
> Docker build failed with exit code 1:
-> Build output:
ERROR: failed to solve: python:3.99: image not found
✗ Deployment failed
Error: Docker build failed: ERROR: failed to solve: python:3.99: image not found
References¶
- ADR 018: CLI Architecture
- ADR 022: Build-Deploy Plugin System
- Server-Sent Events specification: https://html.spec.whatwg.org/multipage/server-sent-events.html
- httpx SSE client: https://www.python-httpx.org/
Related ADRs: ADR 018: CLI-Server Communication, ADR 022: Build and Deployment Plugin System, ADR 025: CLI User Experience Improvements