Help us shape the future of Go! Take the 2026 Go Developer Survey

logs

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package logs ships task logs from the agent to durable storage and serves them back via the API, so logs remain available after the task pod is gone.

Index

Constants

This section is empty.

Variables

View Source
var ErrObjectNotFound = errors.New("log object not found")

ErrObjectNotFound reports that an object-store sink holds no stored log for a Ref. Backends translate their own not-found signal (S3 NoSuchKey, GCS ErrObjectNotExist, a missing in-memory entry) into this sentinel so the read path maps it uniformly.

View Source
var ErrUnsafeRef = errors.New("unsafe log reference")

ErrUnsafeRef reports a Ref whose fields cannot be used as path segments.

Functions

func EncodeLine

func EncodeLine(ev Event) string

EncodeLine serializes an Event to the same JSON line used for storage, so the live-tail channel carries the full event (level + stream + timestamp), not just the message text. On the unlikely marshal error it falls back to the raw message, so a line is never dropped.

func RefineLevel

func RefineLevel(line, fallback string) string

RefineLevel returns the severity indicated by a log line's own text when it carries a clear level token, otherwise the supplied fallback. It lets the line content correct a level that was derived only from the source stream — so an "ERROR …" line printed to stdout is colored error (not info), and an "INFO …" line written to stderr is colored info (not error). When the text gives no signal, the stream-derived fallback stands, so behavior is unchanged for plain output. The returned values match the Event.Level vocabulary the UI colors by (error/warning/info/debug); callers control the contract, this only sharpens the level value.

Types

type DiskSink

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

DiskSink writes logs to ${root}/{tenant}/{dag}/{run}/{task}/{try}.log.

func NewDiskSink

func NewDiskSink(dir string) *DiskSink

NewDiskSink builds a DiskSink rooted at dir.

func (*DiskSink) AppendEvent

func (d *DiskSink) AppendEvent(ref Ref, ev Event) error

AppendEvent adds one event to the attempt's log file. The disk sink already opens with O_APPEND, so this is a plain open-write-close that preserves the streamed content — the marker lands after whatever the agent wrote (#861).

func (*DiskSink) Open

func (d *DiskSink) Open(ref Ref) (LogWriter, error)

Open creates the log file (appending if it exists) and returns a buffered writer.

func (*DiskSink) Prune

func (d *DiskSink) Prune(now time.Time, retention time.Duration) error

Prune deletes log files whose last modification is older than retention, reclaiming space for completed runs. A missing root is not an error.

func (*DiskSink) Read

func (d *DiskSink) Read(ref Ref) (io.ReadCloser, error)

Read opens the log file for reading.

type Event

type Event struct {
	Time    time.Time `json:"ts"`
	Level   string    `json:"level"`
	Stream  string    `json:"stream"`
	Message string    `json:"msg"`
}

Event is one structured log line: when it was emitted, its severity level and source stream (stdout/stderr), and the message text. Logs are stored as JSONL (one Event per line) so the UI's drill-down viewer can color by real level instead of guessing.

func DecodeLine

func DecodeLine(line string) Event

DecodeLine parses a stored log line into an Event. Lines that are not JSON (legacy plain-text logs written before structured storage) decode as a stdout message with a level inferred from the text, so the reader serves both formats with sensible coloring.

type GCSConfig

type GCSConfig struct {
	// Bucket is the target bucket. Required.
	Bucket string
	// CredentialsFile is a path to a service-account JSON key. Empty (recommended)
	// uses Application Default Credentials — GKE Workload Identity keyless.
	CredentialsFile string
}

GCSConfig configures the Google Cloud Storage object store backing an ObjectSink. Auth is keyless-first (ADR 0035): leave CredentialsFile empty to use Application Default Credentials, which resolves GKE Workload Identity (or the metadata server on a GCE node) with no key material on disk. A credentials file is a discouraged escape hatch for local development and clusters without an identity broker.

Unlike the S3 backend, GCS needs no region, endpoint, or path-style toggle — the native SDK addresses the global storage.googleapis.com API and locates the bucket by name. This is why GCS is a first-class provider here rather than an S3-interop endpoint: the interop endpoint requires HMAC keys and cannot use Workload Identity, so routing GCS through the S3 client would forfeit keyless.

type GCSStore

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

GCSStore is an ObjectStore backed by Google Cloud Storage.

func NewGCSStore

func NewGCSStore(ctx context.Context, cfg GCSConfig) (*GCSStore, error)

NewGCSStore builds a GCSStore from cfg. With no CredentialsFile it constructs the client on the keyless ADC chain; with one set it detects credentials from that file via the modern auth library (not the deprecated WithCredentialsFile option). Client construction may touch the credential chain but makes no bucket call; a missing bucket or denied permission surfaces on the first Put/Get.

func (*GCSStore) Get

func (g *GCSStore) Get(ctx context.Context, key string) (io.ReadCloser, error)

Get reads the object under key, translating GCS's ErrObjectNotExist into ErrObjectNotFound so the read path maps a missing log to a 404, matching the S3 backend.

func (*GCSStore) Put

func (g *GCSStore) Put(ctx context.Context, key string, r io.Reader) error

Put writes the object under key. The GCS writer streams to the API, but a task attempt's log is already fully buffered by the caller, so io.Copy adds no unbounded cost. Close finalizes the object; an error there means the write did not commit.

type LogWriter

type LogWriter interface {
	WriteEvent(ev Event) error
	Close() error
}

LogWriter appends structured log events for one task attempt and flushes on Close.

type MarkerSink

type MarkerSink interface {
	AppendEvent(ref Ref, ev Event) error
}

MarkerSink appends a single event to an attempt's existing log while PRESERVING prior content: O_APPEND on disk, read-modify-write on an object store (which has no native append, so Open+Close there would overwrite the whole object). A reaper uses it to add a terminal marker to a killed task's log without clobbering the streamed output (#861). DiskSink and ObjectSink both implement it; a caller holding a Sink type-asserts to reach it.

type MemoryTailer

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

MemoryTailer is an in-process Tailer for Dexaflow Lite: it fans task log lines to live subscribers over Go channels, with no Redis. It is valid only within a single process (Lite runs the agent gRPC and the read API together); a multi-replica deployment must use RedisTailer instead (ADR 0026).

func NewMemoryTailer

func NewMemoryTailer() *MemoryTailer

NewMemoryTailer builds an empty in-process tailer.

func (*MemoryTailer) Publish

func (t *MemoryTailer) Publish(_ context.Context, ref Ref, line string) error

Publish delivers one line to every live subscriber of the task. Delivery is non-blocking: a full subscriber buffer drops the line rather than stalling the publisher. Holding the lock across the sends keeps it safe against a concurrent cancel closing a channel.

func (*MemoryTailer) Subscribe

func (t *MemoryTailer) Subscribe(ctx context.Context, ref Ref) (lines <-chan string, cancel func())

Subscribe returns a channel of live log lines for the task and a cancel function. The channel is closed on cancel or when the context is done.

type ObjectSink

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

ObjectSink stores each task attempt as a single object in an ObjectStore, keyed the same way the disk sink lays out files ({prefix}/{tenant}/{dag}/{run}/{task}/{try}.log). Object stores have no append, so a writer accumulates the attempt's events and rewrites the object incrementally (by size and on a time cadence) with a final rewrite on Close, so a control plane killed mid-attempt leaves a partial object rather than nothing; the live-tail path (a separate Tailer) still streams lines as they arrive. It is opt-in — the disk sink stays the default (see NewDurableSink).

func NewObjectSink

func NewObjectSink(ctx context.Context, store ObjectStore, prefix string, logger *slog.Logger) *ObjectSink

NewObjectSink builds an ObjectSink writing to store under an optional key prefix. ctx bounds the store operations issued by the writers and readers the sink hands out (the Sink interface is context-free by design); pass the server's lifecycle context.

logger receives the warnings the write path cannot return — a failed incremental flush is retried, not surfaced to the agent, so the log line is the ONLY evidence it happened. It is injected rather than taken from slog.Default() so the sink honors the handler (format, level, destination) its owner configured without depending on that owner having reassigned a process-wide global — leoflow-server does call slog.SetDefault, which is what covers the call sites nothing injects into, but an embedder need not, and a library reaching for its caller's global is the wrong seam either way. nil falls back to slog.Default(), deliberately: making the parameter mandatory would not pin the production hand-off, it would just move the trap from "warnings go somewhere unwatched" to "nil dereference on a flush path that only runs while the process is already shutting down". What pins the hand-off is a test that reaches this constructor the way the server does, via NewDurableSink (#918).

func (*ObjectSink) AppendEvent

func (o *ObjectSink) AppendEvent(ref Ref, ev Event) error

AppendEvent adds one event to the attempt's stored object WITHOUT clobbering it. Object stores have no append, so a plain Open+Close would Put a marker-only object over the agent's streamed log; instead this reads the existing object (tolerating a not-yet-written one), appends the event as a JSONL line, and Puts the combined object back (#861).

Read-modify-write with no locking, which is safe because of WHEN it runs, not because nothing else can write the key: a live attempt's writer does flush the same object incrementally. The only caller is the agent-lost reaper, and what separates the two writers is the agent-lost threshold — 90s of heartbeat silence (executor.defaultAgentLostThreshold), not "minutes" and not the life of the pod. Normally that is enough, because an agent that has stopped heartbeating for 90s has stopped streaming too.

The exception, and it IS reachable: heartbeats can be refused while the log stream survives. An attempt past max_attempt_credential_lifetime stops getting its token renewed, so its heartbeats start failing authentication, while StreamLogs — authenticated once at Open and never re-checked — keeps receiving lines and flushing them. Agent-lost then fires at the 90s mark, this function appends the marker, and the live writer's very next flush Puts its own buffer straight over it. Each side Puts the whole object it last read, so the loser loses whole flushes, not one line. Anything that shortens the gap between the two writers — a lower threshold, a caller that is not the reaper — widens this from an exception into the normal case (#918).

func (*ObjectSink) Open

func (o *ObjectSink) Open(ref Ref) (LogWriter, error)

Open validates the ref and returns a writer that keeps the attempt's object current as lines arrive (see objectWriter) and performs the last flush on Close.

func (*ObjectSink) Read

func (o *ObjectSink) Read(ref Ref) (io.ReadCloser, error)

Read fetches the stored object for the ref. A missing object surfaces as ErrObjectNotFound.

type ObjectStore

type ObjectStore interface {
	Put(ctx context.Context, key string, r io.Reader) error
	Get(ctx context.Context, key string) (io.ReadCloser, error)
}

ObjectStore is the minimal object-store API the ObjectSink needs: write a whole object under a key, and read one back. It is deliberately tiny so the sink is testable against an in-memory fake, and so any backend can satisfy it with its own native SDK — the S3 backend (AWS S3, MinIO, Ceph RGW via aws-sdk-go-v2) and the GCS backend (Google Cloud Storage via its native SDK). Each provider keeps its own keyless auth path this way. Implementations target a single, pre-configured bucket.

type Pruner

type Pruner interface {
	Prune(now time.Time, retention time.Duration) error
}

Pruner is implemented by sinks that reclaim their own expired storage: the disk sink deletes old files. An object-store sink deliberately does not implement it — bucket lifecycle policy owns retention there — so the janitor skips pruning for sinks that manage their own lifecycle.

type RedisTailer

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

RedisTailer fans task log lines out over Redis pub/sub so the API can stream them live to the UI without polling.

func NewRedisTailer

func NewRedisTailer(client *redis.Client) *RedisTailer

NewRedisTailer builds a RedisTailer over the given go-redis client.

func (*RedisTailer) Publish

func (t *RedisTailer) Publish(ctx context.Context, ref Ref, line string) error

Publish sends one log line to the task's tail channel.

func (*RedisTailer) Subscribe

func (t *RedisTailer) Subscribe(ctx context.Context, ref Ref) (lines <-chan string, cancel func())

Subscribe returns a channel of live log lines for the task and a cancel function that ends the subscription. The line channel closes when the subscription is canceled or the context is done.

type Ref

type Ref struct {
	TenantID  string
	DagID     string
	RunID     string
	TaskID    string
	TryNumber int
}

Ref identifies a task instance's log stream and maps to its storage location.

func (Ref) Channel

func (r Ref) Channel() string

Channel is the Redis pub/sub channel a task attempt's live log lines are published on. Both the agent-facing writer and the read API derive it from the same Ref, so they meet without an extra lookup.

type S3Config

type S3Config struct {
	// Bucket is the target bucket. Required.
	Bucket string
	// Region is the store region. Required by AWS S3; ignored by some stores.
	Region string
	// Endpoint overrides the S3 endpoint (MinIO, Ceph RGW). Empty = AWS default.
	Endpoint string
	// ForcePathStyle uses path-style addressing (needed by MinIO and some stores).
	ForcePathStyle bool
	// AccessKeyID / SecretAccessKey are static credentials. Empty = keyless.
	AccessKeyID     string
	SecretAccessKey string
}

S3Config configures the S3-compatible object store backing an ObjectSink. It targets AWS S3 and any S3-compatible store (MinIO, Ceph RGW). Google Cloud Storage is NOT reached through here — it has its own native backend (GCSStore) because the S3 interop endpoint requires HMAC keys and cannot use Workload Identity, which would forfeit keyless auth. Keyless is the default (ADR 0035): leave AccessKeyID/SecretAccessKey empty to use the ambient credential chain (IRSA / instance profile / env). Static keys are a discouraged escape hatch for dev and stores without an identity broker.

type S3Store

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

S3Store is an ObjectStore backed by an S3-compatible API.

func NewS3Store

func NewS3Store(ctx context.Context, cfg S3Config) (*S3Store, error)

NewS3Store builds an S3Store from cfg. It loads the ambient AWS configuration (region + keyless credential chain) and, when set, overrides the endpoint (for MinIO / Ceph RGW), forces path-style addressing, and installs static credentials. No network call is made here; credential resolution and any failure surface on the first Put/Get.

func (*S3Store) Get

func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, error)

Get reads the object under key, translating S3's NoSuchKey into ErrObjectNotFound so the read path maps a missing log to a 404.

func (*S3Store) Put

func (s *S3Store) Put(ctx context.Context, key string, r io.Reader) error

Put writes the object under key. Request signing needs a length-known, seekable payload; the object sink already hands a *bytes.Reader, so the common path passes it straight through with no extra copy. A non-seekable reader is materialized once as a fallback so the interface stays honest for other callers.

type Sink

type Sink interface {
	Open(ref Ref) (LogWriter, error)
	Read(ref Ref) (io.ReadCloser, error)
}

Sink stores and retrieves task logs.

func NewDurableSink

func NewDurableSink(ctx context.Context, backend, dir string, store ObjectStore, prefix string, logger *slog.Logger) (Sink, error)

NewDurableSink selects the durable log sink from configuration. The default — an empty or "disk" backend — returns a DiskSink rooted at dir, so Lite and every deployment that does not opt in keep the exact on-disk behavior. The "s3" and "gcs" backends return an ObjectSink over store (which the caller builds with the matching native SDK from the configured bucket/credentials) and require a non-nil store. An unknown backend is rejected rather than silently falling back. logger is the process's configured logger, carried to the object sink so its retry warnings honor that contract (see NewObjectSink); the disk sink ignores it.

type Tailer

type Tailer interface {
	Publish(ctx context.Context, ref Ref, line string) error
	Subscribe(ctx context.Context, ref Ref) (<-chan string, func())
}

Tailer publishes and subscribes to a task's live log lines for the UI's live tail. RedisTailer is the production implementation.

Jump to

Keyboard shortcuts

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