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.
go get github.com/froe-run/froe-client-go
Requires Go 1.24+. Standard library only, no dependencies.
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)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.
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.
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.
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.
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.