github.com/froe-run/froe-client-go

Go SDK for Froe: push shareable logs from your service


License
MIT
Install
go get github.com/froe-run/froe-client-go

Documentation

froe

Push selected logs from your Go service to a Froe instance. Clients and agents fetch them back with a read key over a plain REST API.

Install

go get github.com/froe-run/froe-client-go

Requires Go 1.24+. Standard library only, no dependencies.

Quickstart

import froe "github.com/froe-run/froe-client-go"

log := froe.New(froe.Options{Key: "fw_..."})
defer log.Close(context.Background()) // flushes what is still buffered

log.Info("payment ok", froe.Meta{"order": 42})
log.Warn("retrying payment", froe.Meta{"order": 42, "attempt": 2})
log.Error("payment failed", froe.Meta{"order": 42, "code": "card_declined"})

All six level methods take a message and a meta map, which may be nil: Trace, Debug, Info, Warn, Error, Fatal.

log.Trace("cache miss", froe.Meta{"key": "user:42"})
log.Fatal("out of memory, exiting", nil)

Options

froe.New(froe.Options{
    Key:                "fw_...",            // required, write key
    URL:                "https://froe.run",  // your Froe instance
    BatchSize:          50,                  // send after this many buffered entries
    FlushInterval:      2 * time.Second,     // or after this long, whichever comes first
    MaxBufferedEntries: 10000,               // memory ceiling, buffered plus queued; oldest drop past it
    RequestTimeout:     10 * time.Second,    // abort a hung send after this long
    HTTPClient:         myClient,            // custom transport
    Warn:               myWarnFunc,          // SDK diagnostics, default one line to stderr
})

Only Key is required; every other field falls back to the default shown. Warn is called from your logging goroutine and from the sender goroutine, so it must be safe for concurrent use, and it must not log back into the same client.

Give URL the address your instance answers on directly. The default transport refuses redirects instead of following them, because Go replays a 301, 302, or 303 POST as a bodiless GET, which arrives as a fetch carrying a write key and comes back 403. A redirect is warned about and names its own status. A HTTPClient you supply keeps its own policy.

Delivery guarantees

Log calls never block the caller and never panic. Entries are buffered in memory and sent as batches, strictly in order, when the buffer reaches BatchSize, when FlushInterval elapses, or when you call Flush. A batch that fails with a network error, a timeout, a 5xx, or a 429 stays queued and retries with exponential backoff (250ms * 4^failures, capped at 30 seconds; a 429 honors the server's Retry-After). Only another 4xx drops the batch, with one warning, because no retry can fix a request the server rejected as wrong. Every batch carries a per-batch Idempotency-Key and an exact body, both fixed across all its retries, so a retry of a batch the server already accepted never stores duplicates.

An entry whose message plus meta exceeds 64 KB, or whose level is not one of the six, is dropped at the call site with a warning; it never enters the buffer. Meta the encoder refuses (a NaN, an infinity, a channel) costs the entry its meta but not its message, which is warned about and shipped without it: a ratio over an empty sample must not silently delete the log line reporting it. MaxBufferedEntries is the one memory knob: it caps buffered entries plus queued batch entries together, and on overflow the oldest go first (whole queued batches, then the oldest buffer entries), with one warning per overflow episode.

Flush(ctx) makes a single ordered delivery pass, ignoring any backoff, and returns even while the server is down; it reports only a ctx error, so it never holds your shutdown hook hostage. Whatever it could not deliver stays queued for the next interval. Close(ctx) flushes once and stops the background sender.

In short: logs are telemetry, not durable storage. Nothing here is meant to replace your application's own logging or an audit trail.

slog handler

client := froe.New(froe.Options{Key: "fw_..."})
defer client.Close(context.Background())

log := slog.New(froe.NewHandler(client, nil))

log.Info("just a local log line")
log.Info("shipped to Froe", "froe", true, "order", 42)
log.With("froe", true).Info("also shipped to Froe")

By default only records carrying froe=true are forwarded, whether set on a child logger or passed per call. A severity level is not a sharing decision: your error logs are not automatically things you want a client outside your trust boundary to read. Set ForwardAll: true in HandlerOptions to forward the whole stream instead. The marker is read at the top level only, and never reaches the entry's meta.

HandlerOptions also takes Level (a slog.Leveler, default slog.LevelInfo). Record attrs become the entry's meta, with slog groups as nested JSON objects; the record's own timestamp carries over. An attr holding an error keeps its text, since an error has no exported field for the JSON encoder to find and would otherwise arrive as {}. slog's four named levels map onto Froe's six: below Debug is trace, above Error is fatal.

To keep your local logs and ship a subset, put the handler behind a fanout of your own, or give Froe its own slog.Logger beside the one writing to stderr.

Reading logs

Consumers with a read key fetch entries with GET /v1/logs on your Froe instance, filtering by level, since, until, q (substring match), and paging with limit and cursor. The full wire contract, including request and response shapes, is served at GET /v1 on any Froe instance.

Server-side limits

A Froe instance accepts at most 1000 entries per push batch and 64 KB per entry (message plus meta). The SDK chunks large flushes into batches of at most 1000 automatically; the per-entry limit is enforced at the call site as described above.