deploylog

package
v0.2.0-beta.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

Documentation

Overview

Package deploylog is the glue between internal/build.ProgressEvent (the progress callback all three real deploy-attempt trigger paths already thread through internal/deploy.Pipeline.Deploy) and two real consumers: a persisted, replayable row in telemetry.db (internal/telemetry's deploy_logs table) and any currently-connected SSE viewer watching that same attempt live. Before this package existed, build.SlogProgress was the only real consumer: output went to slog and was gone the moment the process moved on: since this product's core loop is unattended webhook deploys, that loss was the actual gap worth closing.

One Recorder is shared across every trigger path in a running control plane (see cmd/levelrail/main.go's wiring): internal/api.Router's SSE route and internal/webhook.Handler's own webhook-triggered attempts both need to publish to and read from the exact same in-memory subscriber set, not two independent ones, or a viewer connected through the HTTP API would never see a line a webhook-triggered build produced.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Event

type Event struct {
	Line   string
	Stream string
}

Event is one log line, the exact shape web/src/hooks/useDeployLogStream.ts's SSE contract prefers ({ "line": string, "stream": "stdout" | "stderr" }), fanned out live to a Snapshot subscriber and also what Snapshot's own lines-so-far slice is made of.

type LogStore

type LogStore interface {
	WriteDeployLogBatch(ctx context.Context, entries []telemetry.DeployLogEntry) error
}

LogStore is the narrow persistence surface Recorder needs. *telemetry.DB satisfies this structurally. nil is valid (see NewRecorder): without one configured, a Recorder still does live fan-out and in-memory lines-so-far snapshots correctly, it just never persists anything, so a viewer that connects after an attempt already finished (and was evicted from memory, see Finish) has nothing to replay. cmd/levelrail/main.go always configures a real one; this stays nil-tolerant purely so a router or test built without a telemetry store doesn't need a fake just to exist.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder turns one attempt's build.ProgressEvent stream into both a persisted log and a live fan-out. See the package doc comment for why exactly one Recorder must be shared across every trigger path in a running control plane.

func NewRecorder

func NewRecorder(store LogStore, logger *slog.Logger) *Recorder

NewRecorder builds a Recorder. store may be nil (see LogStore's own doc comment); logger defaults to slog.Default() if nil.

func (*Recorder) Finish

func (r *Recorder) Finish(ctx context.Context, attemptID string)

Finish flushes attemptID's remaining unflushed lines to the store, closes every live subscriber channel (the end-of-stream signal a Snapshot caller's read loop watches for, see internal/api/deploys.go's SSE handler), and evicts attemptID from memory. Must be called exactly once per attemptID, after internal/deploy.Pipeline.Deploy returns, success or failure; callers should defer this immediately after Start to guarantee it runs even if Deploy panics. A Snapshot call for attemptID after Finish returns false-ok, by design: the caller's job at that point is to fall back to the persisted store (QueryDeployLog) for a full replay, which Finish's own unconditional flush above guarantees is complete and available by the time Finish returns.

func (*Recorder) Progress

func (r *Recorder) Progress(attemptID string) func(build.ProgressEvent)

Progress returns a progress func bound to attemptID, suitable for passing straight to internal/deploy.Pipeline.Deploy in place of build.SlogProgress. Every event carrying output (ev.Log != "", i.e. not a bare step-lifecycle event like "step completed" or "step cached") becomes one Event: published immediately, synchronously, to every currently-registered live subscriber (never buffered or batched, so a live viewer sees output the moment it happens, not delayed by the persistence batching threshold below), and also appended to the attempt's unflushed persistence buffer, flushed to the store once that buffer reaches batchMaxLines. attemptID must have already been passed to Start; see Start's own doc comment for the no-op behavior otherwise.

Safe for concurrent use: internal/build.Client.Build invokes the progress func from more than one goroutine at once (BuildKit's own solve-status relay and the docker-image-load phase run concurrently, see build.go's own errgroup wiring), so every access to attemptID's state here is under Recorder's single mutex.

func (*Recorder) Snapshot

func (r *Recorder) Snapshot(attemptID string) (lines []Event, live <-chan Event, unsubscribe func(), ok bool)

Snapshot atomically returns every line recorded so far for attemptID plus a channel that receives every subsequent line, so a caller can never miss a line published between the snapshot and the channel registration (both happen under the same lock) and never see one twice. ok is false when attemptID isn't currently active: never Start()ed, or already Finish()ed and evicted. The caller's job in that case (see internal/api/deploys.go's SSE handler) is to fall back to the persisted store for a full replay.

unsubscribe must be called exactly once, typically deferred, once the caller stops reading live (the SSE client disconnected, or the request context was cancelled): it removes the channel from attemptID's subscriber list so Progress stops trying to deliver to it. Safe to call after Finish has already closed the channel and evicted the attempt (a no-op in that case, attemptID's state is simply gone).

func (*Recorder) Start

func (r *Recorder) Start(attemptID string)

Start registers attemptID as active, before any call to the func Progress returns and before any SSE viewer's Snapshot call can meaningfully attach to a live tail. A trigger handler must call this before invoking internal/deploy.Pipeline.Deploy with the progress func Progress(attemptID) returns; calling Progress before Start is a caller bug (the returned func would have nowhere to record into) and is treated as a no-op rather than a panic, the same "fail closed, not loudly, for an internal wiring mistake" choice this package makes throughout (see the store-error handling in the func Progress returns).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL