diff --git a/.changeset/tiny-wren.md b/.changeset/tiny-wren.md new file mode 100644 index 0000000..30ff7fe --- /dev/null +++ b/.changeset/tiny-wren.md @@ -0,0 +1,5 @@ +--- +"transports/betterstack": major +--- + +Initial release. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 46cbbe3..6a633d5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -161,6 +161,7 @@ gtag('config', '${gaMeasurementId}');`, text: 'Cloud', items: [ { text: 'Axiom', link: '/transports/axiom' }, + { text: 'Better Stack', link: '/transports/betterstack' }, { text: 'Datadog', link: '/transports/datadog' }, { text: 'Google Cloud Logging', link: '/transports/gcplogging' }, { text: 'New Relic', link: '/transports/newrelic' }, diff --git a/docs/src/public/llms-full.txt b/docs/src/public/llms-full.txt index cf2e404..449deef 100644 --- a/docs/src/public/llms-full.txt +++ b/docs/src/public/llms-full.txt @@ -21,15 +21,9 @@ go get go.loglayer.dev/transports/cli/v2 go get go.loglayer.dev/transports/testing/v2 go get go.loglayer.dev/transports/blank/v2 -# Wrappers around third-party loggers -go get go.loglayer.dev/transports/zerolog/v2 -go get go.loglayer.dev/transports/zap/v2 -go get go.loglayer.dev/transports/slog/v2 -go get go.loglayer.dev/transports/phuslu/v2 -go get go.loglayer.dev/transports/logrus/v2 -go get go.loglayer.dev/transports/charmlog/v2 - # Cloud (managed log services) +go get go.loglayer.dev/transports/axiom/v2 +go get go.loglayer.dev/transports/betterstack/v2 go get go.loglayer.dev/transports/datadog/v2 go get go.loglayer.dev/transports/gcplogging/v2 go get go.loglayer.dev/transports/sentry/v2 @@ -39,6 +33,14 @@ go get go.loglayer.dev/transports/http/v2 go get go.loglayer.dev/transports/lumberjack/v2 go get go.loglayer.dev/transports/otellog/v2 +# Wrappers around third-party loggers +go get go.loglayer.dev/transports/charmlog/v2 +go get go.loglayer.dev/transports/logrus/v2 +go get go.loglayer.dev/transports/phuslu/v2 +go get go.loglayer.dev/transports/slog/v2 +go get go.loglayer.dev/transports/zap/v2 +go get go.loglayer.dev/transports/zerolog/v2 + # Plugins go get go.loglayer.dev/plugins/redact/v2 go get go.loglayer.dev/plugins/sampling/v2 diff --git a/docs/src/public/llms.txt b/docs/src/public/llms.txt index fd01ac9..9b40535 100644 --- a/docs/src/public/llms.txt +++ b/docs/src/public/llms.txt @@ -364,6 +364,8 @@ lines := lib.Lines() // []lltest.LogLine; assert on Level, Messages, Data, Meta - `transports/blank`: user-supplied dispatch function **Cloud** (managed log services): +- `transports/axiom`: ships logs to Axiom via caller-supplied `*axiom.Client` +- `transports/betterstack`: ships logs to Better Stack via HTTP intake - `transports/datadog`: Datadog Logs HTTP intake - `transports/gcplogging`: wraps a caller-supplied `*logging.Logger` from `cloud.google.com/go/logging` - `transports/sentry`: wraps a caller-supplied `sentry.Logger` @@ -374,7 +376,7 @@ lines := lib.Lines() // []lltest.LogLine; assert on Level, Messages, Data, Meta - `transports/otellog`: emits to an OTel `log.Logger`; forwards `WithContext` for trace correlation **Wrappers around third-party loggers**: -- `transports/zerolog`, `transports/zap`, `transports/slog`, `transports/phuslu`, `transports/logrus`, `transports/charmlog` +- `transports/charmlog`, `transports/logrus`, `transports/phuslu`, `transports/slog`, `transports/zap`, `transports/zerolog` ## Available Plugins diff --git a/docs/src/transports/_partials/transport-list.md b/docs/src/transports/_partials/transport-list.md index ca231a8..580ba08 100644 --- a/docs/src/transports/_partials/transport-list.md +++ b/docs/src/transports/_partials/transport-list.md @@ -24,6 +24,7 @@ Managed log services. Async + batched by default; site-aware where applicable. | Name | Version | Go Reference | Description | |------|---------|--------------|-------------| | [Axiom](/transports/axiom) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/axiom/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/axiom/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/axiom/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/axiom/v2) | Ships logs to Axiom via caller-supplied `*axiom.Client`. NDJSON ingestion with configurable message field. | +| [Better Stack](/transports/betterstack) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/betterstack/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/betterstack/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/betterstack.svg)](https://pkg.go.dev/go.loglayer.dev/transports/betterstack) | Ships logs to Better Stack via HTTP intake. Source token auth, configurable timestamp field. | | [Datadog](/transports/datadog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/datadog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/datadog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/datadog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/datadog/v2) | Datadog Logs HTTP intake. Site-aware URL, DD-API-KEY header, status mapping. | | [Google Cloud Logging](/transports/gcplogging) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/gcplogging/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/gcplogging/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/gcplogging/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/gcplogging/v2) | Forwards entries to a caller-supplied `*logging.Logger` from `cloud.google.com/go/logging`. Severity mapping, root-level Entry skeleton, async + sync dispatch. | | [New Relic](/transports/newrelic) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/newrelic/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/newrelic/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/newrelic.svg)](https://pkg.go.dev/go.loglayer.dev/transports/newrelic) | New Relic Log Ingest API. Site-aware URL, api-key header, LogEvent encoding. | diff --git a/docs/src/transports/betterstack.md b/docs/src/transports/betterstack.md new file mode 100644 index 0000000..5c6d1b4 --- /dev/null +++ b/docs/src/transports/betterstack.md @@ -0,0 +1,208 @@ +--- +title: Better Stack Transport +description: Ship logs to Better Stack's HTTP intake API. +--- + +# Better Stack Transport + + + +Sends log entries to [Better Stack](https://betterstack.com) via their HTTP Logs Endpoint. Built on the [HTTP transport](/transports/http) with a Better Stack-specific encoder and bearer token authentication. + +```sh +go get go.loglayer.dev/transports/betterstack +``` + +## Getting a Source Token + +Better Stack identifies your log source with a **Source Token**. You need this to send logs. + +To get the source token: + +1. Sign in to Better Stack at [https://betterstack.com](https://betterstack.com). +2. Navigate to **Logs** → **Sources**. +3. Either copy an existing source's token or create a new log source and copy its token. +4. Store the token securely—treat it like a password. + +The source token is a secret. Load it from an environment variable or secret manager rather than hard-coding it in source. + +## Basic Usage + +```go +import ( + "os" + "go.loglayer.dev/v2" + "go.loglayer.dev/transports/betterstack" +) + +tr := betterstack.New(betterstack.Config{ + SourceToken: os.Getenv("BETTERSTACK_SOURCE_TOKEN"), +}) +defer tr.Close() + +log := loglayer.New(loglayer.Config{Transport: tr}) +log = log.WithFields(loglayer.Fields{"requestId": "abc"}) +log.WithMetadata(loglayer.Metadata{"durationMs": 42}).Info("served request") +``` + +The transport is async and batched (inherited from the HTTP transport, default 100 entries / 5 seconds). Always call `Close()` on shutdown to flush pending entries. + +## URL + +By default, logs are sent to Better Stack's public intake endpoint: + +| Setting | Value | +|--------------|---------------------------------------| +| **URL** | `https://in.logs.betterstack.com` | + +### On-prem / custom URL + +When testing against a mock endpoint or using a proxy, set `Config.URL` directly: + +```go +tr := betterstack.New(betterstack.Config{ + SourceToken: "fake-for-tests", + URL: "http://localhost:8080/logs", // test server URL +}) +``` + +The transport rejects non-HTTPS URLs by default. To point at an `httptest.Server` or a local plain-HTTP proxy, also set `AllowInsecureURL: true`: + +```go +srv := httptest.NewServer(http.HandlerFunc(...)) +defer srv.Close() + +tr := betterstack.New(betterstack.Config{ + SourceToken: "fake-for-tests", + URL: srv.URL, // http:// from httptest + AllowInsecureURL: true, // required for non-HTTPS URLs +}) +``` + +`AllowInsecureURL` is a test/debug ergonomic; leave it off for any URL that leaves your machine. + +## Config + +```go +type Config struct { + transport.BaseConfig + + SourceToken string // required + URL string // default https://in.logs.betterstack.com + AllowInsecureURL bool // permit non-HTTPS URLs (httptest, local proxies) + TimestampField string // field name for timestamp, default "dt" + + HTTP httptransport.Config // BatchSize, BatchInterval, Client timeout, OnError; URL, Encoder, and Headers cannot be overridden +} +``` + +### `SourceToken` + +Required. Set as the Bearer token in the `Authorization` header on every request (`Bearer `). `betterstack.New` panics with `betterstack.ErrSourceTokenRequired` when this is empty; use `betterstack.Build(cfg) (*Transport, error)` if you load the token from an environment variable and want to handle the missing-config case explicitly. + +### `URL` + +Optional. Overrides the default Better Stack intake endpoint. When set, the `AllowInsecureURL` config knob controls whether non-HTTPS URLs are permitted. + +### `AllowInsecureURL` + +Optional. When `true`, permits non-HTTPS URLs (useful for testing with `httptest.Server`). Defaults to `false` to prevent accidental plaintext logging in production. + +### `TimestampField` + +Optional. The field name used for timestamps in the log entry. Defaults to `"dt"` (Better Stack's convention). You can change this if your Better Stack source expects a different timestamp field name. + +### `HTTP` + +Embedded `httptransport.Config` for batching, client timeout, error handling, and other HTTP-layer concerns. The `URL`, `Encoder`, and `Authorization` header are set by the Better Stack wrapper and cannot be overridden via this field. + +```go +tr := betterstack.New(betterstack.Config{ + SourceToken: token, + HTTP: httptransport.Config{ + BatchSize: 500, + BatchInterval: 2 * time.Second, + Client: &http.Client{Timeout: 10 * time.Second}, + OnError: func(err error, entries []httptransport.Entry) { + metrics.Counter("betterstack.send.failed").Add(int64(len(entries))) + }, + }, +}) +``` + +See the [HTTP transport docs](/transports/http) for the full HTTP config surface. + +## Encoded Body Shape + +Each log entry becomes one object in a JSON array: + +```json +[ + { + "dt": "2026-05-06T14:30:00.123Z", + "level": "info", + "message": "served request", + "requestId": "abc", + "durationMs": 42 + } +] +``` + +- **Timestamp**: Controlled by `TimestampField` (default `"dt"`), formatted as ISO 8601 UTC. +- **Level**: String representation of the log level (trace, debug, info, warn, error, fatal, panic). +- **Message**: The log message string. +- **Fields**: Persistent fields from `WithFields()` and `Child()` are merged at the root level. +- **Metadata**: Map metadata merges at the root; non-map metadata is serialized as JSON under a `metadata` key (configurable via `loglayer.Config.MetadataFieldName`). + +Persistent fields (`WithFields`) and metadata (`WithMetadata`) follow the [core placement rules](/configuration#fieldskey): when `FieldsKey` is empty, fields merge at the root of each log object; when `MetadataFieldName` is empty, map metadata merges at the root and non-map metadata nests under `metadata`. Set either knob on `loglayer.Config` to nest under a configured key instead. + +## Level Mapping + +Better Stack supports these log levels. The transport maps loglayer levels: + +| LogLayer Level | Better Stack level | +|------------------|--------------------| +| `LogLevelTrace` | `"trace"` | +| `LogLevelDebug` | `"debug"` | +| `LogLevelInfo` | `"info"` | +| `LogLevelWarn` | `"warn"` | +| `LogLevelError` | `"error"` | +| `LogLevelFatal` | `"fatal"` | +| `LogLevelPanic` | `"panic"` | + +## API Limits + +Better Stack's intake has these limits: + +- Max body size per request: **5MB** +- Max single log entry: **1MB** +- Max entries per array: **1,000** + +The default `BatchSize` of 100 stays well under all of these. If you bump `BatchSize` for higher throughput, keep it under 1,000 and watch the body-size limit if your entries are large. + +## Closing + +`BetterStack.Transport` embeds `*httptransport.Transport`, so it has the same `Close() error` method. **Always call it on shutdown** so the in-flight batch is flushed: + +```go +tr := betterstack.New(...) +defer tr.Close() +``` + +After `Close`, subsequent log calls drop the entry and invoke the underlying HTTP transport's `OnError` with `httptransport.ErrClosed`. + +## Reaching the Underlying HTTP Transport + +`betterstack.Transport` embeds `*httptransport.Transport`, so any HTTP-transport method works on it directly: + +```go +tr := betterstack.New(...) +tr.Close() // from httptransport.Transport +tr.GetLoggerInstance() // from httptransport.Transport (returns nil) +``` + +## Fatal Behavior + + + +Same async caveat as the underlying [HTTP transport](/transports/http#fatal-behavior): set `DisableFatalExit: true` and call `tr.Close()` before `os.Exit(1)` if you need guaranteed delivery of the fatal entry. diff --git a/docs/src/transports/creating-transports.md b/docs/src/transports/creating-transports.md index 87f0089..8901823 100644 --- a/docs/src/transports/creating-transports.md +++ b/docs/src/transports/creating-transports.md @@ -309,3 +309,15 @@ Match the pattern the built-ins use ([`transports/structured`](https://github.co ## Testing For testing a custom transport, see [Testing Transports](/transports/testing-transports). It covers the direct buffer assertion pattern and the `RunContract` helper that drives the same 14-test contract suite every built-in wrapper passes. + +### Live Tests + +Transports that ship to third-party services (HTTP endpoints, cloud APIs) include build-tagged live tests (`//go:build livetest`) that hit the real API. These are **never documented** in the transport's VitePress docs — they're internal developer documentation only. + +Run them locally with the required credentials: + +```sh +SOME_SERVICE_TOKEN= go test -tags=livetest ./transports/yourservice/ +``` + +CI runs them automatically on pushes to `main`. The test code itself includes a comment block at the top explaining how to run it, so contributors can discover it from the source. diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md index 5c50c26..3350fb7 100644 --- a/docs/src/whats-new.md +++ b/docs/src/whats-new.md @@ -13,6 +13,10 @@ description: Latest features and improvements in LogLayer for Go. Initial release. New [New Relic transport](/transports/newrelic). +`transports/betterstack`: + +Initial release. New [Better Stack transport](/transports/betterstack). + ## May 06, 2026 `transports/axiom`: diff --git a/go.work b/go.work index 2bedbe1..7489941 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ use ( ./transports/axiom ./transports/blank + ./transports/betterstack ./transports/central ./transports/charmlog ./transports/cli diff --git a/lefthook.yml b/lefthook.yml index abfdf89..2544b15 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -70,6 +70,7 @@ pre-commit: stage_fixed: true pre-push: + parallel: true commands: # Catches go.mod / go.sum drift before the push reaches CI. CI # runs the same check; this just shifts the failure left so devs diff --git a/monorel.toml b/monorel.toml index 561abe4..bc30247 100644 --- a/monorel.toml +++ b/monorel.toml @@ -24,6 +24,11 @@ tag_prefix = "transports/blank" path = "transports/blank" changelog = "transports/blank/CHANGELOG.md" +[packages."transports/betterstack"] +tag_prefix = "transports/betterstack" +path = "transports/betterstack" +changelog = "transports/betterstack/CHANGELOG.md" + [packages."transports/charmlog"] tag_prefix = "transports/charmlog" path = "transports/charmlog" diff --git a/scripts/foreach-module.sh b/scripts/foreach-module.sh index 1c28250..681ae2f 100755 --- a/scripts/foreach-module.sh +++ b/scripts/foreach-module.sh @@ -32,6 +32,7 @@ ALL_MODULES=( . transports/axiom transports/blank + transports/betterstack transports/central transports/charmlog transports/cli @@ -75,6 +76,7 @@ SHIPPED_MODULES=( . transports/axiom transports/blank + transports/betterstack transports/central transports/charmlog transports/cli @@ -167,6 +169,7 @@ case "$op" in . transports/axiom transports/blank + transports/betterstack transports/central transports/charmlog transports/cli diff --git a/transports/betterstack/CHANGELOG.md b/transports/betterstack/CHANGELOG.md new file mode 100644 index 0000000..d06e599 --- /dev/null +++ b/transports/betterstack/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/transports/betterstack/README.md b/transports/betterstack/README.md new file mode 100644 index 0000000..771f2e1 --- /dev/null +++ b/transports/betterstack/README.md @@ -0,0 +1,55 @@ +# Better Stack transport for loglayer + +Send structured logs to [Better Stack](https://betterstack.com) from your Go applications. + +## Installation + +```bash +go get go.loglayer.dev/transports/betterstack +``` + +## Quick start + +```go +package main + +import ( + "log" + + "go.loglayer.dev/v2" + bs "go.loglayer.dev/transports/betterstack" +) + +func main() { + logger := loglayer.New(loglayer.Config{ + Transport: bs.New(bs.Config{ + SourceToken: "", + }), + }) + + logger.Info("Application started") +} +``` + +## Configuration + +```go +bs.New(bs.Config{ + SourceToken: "", // Required + URL: "https://in.logs.betterstack.com", // Optional, defaults to Better Stack's default endpoint + TimestampField: "dt", // Optional, custom timestamp field name +}) +``` + +## Payload format + +Each log entry is sent as a JSON object with: + +- `message`: The log message +- `level`: The log level (trace, debug, info, warn, error, fatal, panic) +- `dt`: ISO 8601 timestamp (configurable via TimestampField) +- All fields and metadata you add via `WithFields()` and `WithMetadata()` + +## Examples + +See [example_test.go](https://github.com/loglayer/loglayer-go/blob/main/transports/betterstack/example_test.go) for more examples. diff --git a/transports/betterstack/betterstack.go b/transports/betterstack/betterstack.go new file mode 100644 index 0000000..43a07d8 --- /dev/null +++ b/transports/betterstack/betterstack.go @@ -0,0 +1,180 @@ +// Package betterstack sends log entries to Better Stack's log management +// platform via their HTTP intake API. +// +// Wraps transports/http with Better Stack-specific defaults: +// - Authorization header from Config.SourceToken +// - Encoder that emits Better Stack's expected log shape (message, level, +// metadata fields, dt timestamp) +// +// API reference: https://betterstack.com/docs/logs/http-api +// +// See https://go.loglayer.dev for usage guides and the full API reference. +package betterstack + +import ( + "fmt" + "net/url" + "strings" + + "github.com/goccy/go-json" + + httptr "go.loglayer.dev/transports/http/v2" + "go.loglayer.dev/v2" + "go.loglayer.dev/v2/transport" +) + +// Config holds Better Stack transport configuration. +type Config struct { + transport.BaseConfig + + // SourceToken is the Better Stack source token for authentication. + // Required. + // + // Tagged json:"-" so that log.WithMetadata(cfg).Info(...) through + // any JSON-emitting transport won't ship the token in the rendered + // log. Direct field access by the transport's own Build() is unaffected. + SourceToken string `json:"-"` + + // URL is the Better Stack HTTP logs intake endpoint. Defaults to + // https://in.logs.betterstack.com. Use this for on-prem deployments + // or testing against a mock endpoint. + URL string + + // TimestampField is the field name for the timestamp. Defaults to "dt". + TimestampField string + + // AllowInsecureURL permits Config.URL to use a non-https scheme. + // The source token is sent in the Authorization header on every + // request; without this flag, Build refuses a non-https URL to keep + // the token off the wire in plaintext. Set true only when an on-prem + // forwarder terminates TLS upstream and a private network carries + // the cleartext hop. + AllowInsecureURL bool + + // HTTP overrides batching, client, error handling, and any other + // transports/http settings. The URL, Encoder, and Authorization header + // are set by this package and cannot be overridden via this field. + HTTP httptr.Config +} + +// String returns a redacted form of the config so that an accidental +// log.Info(cfg) (or fmt.Sprintf("%v", cfg)) can't ship the source token. +func (c Config) String() string { + masked := c + if masked.SourceToken != "" { + masked.SourceToken = "***redacted***" + } + return fmt.Sprintf( + "betterstack.Config{SourceToken:%q URL:%q TimestampField:%q}", + masked.SourceToken, masked.URL, masked.TimestampField, + ) +} + +// Transport wraps a transports/http.Transport with Better Stack-specific +// encoding and defaults. +type Transport struct { + *httptr.Transport +} + +// New constructs a Better Stack Transport. Panics if Config.SourceToken is empty. +func New(cfg Config) *Transport { + t, err := Build(cfg) + if err != nil { + panic(err) + } + return t +} + +// Build constructs a Better Stack Transport like New but returns +// ErrSourceTokenRequired instead of panicking when cfg.SourceToken is empty. +func Build(cfg Config) (*Transport, error) { + if cfg.SourceToken == "" { + return nil, ErrSourceTokenRequired(cfg.SourceToken) + } + if cfg.HTTP.URL != "" || cfg.HTTP.Encoder != nil { + return nil, fmt.Errorf("betterstack: HTTP.URL and HTTP.Encoder cannot be overridden") + } + + httpCfg := cfg.HTTP + httpCfg.BaseConfig = cfg.BaseConfig + + urlStr := cfg.URL + if urlStr == "" { + urlStr = "https://in.logs.betterstack.com" + } + + if !cfg.AllowInsecureURL { + u, err := url.Parse(urlStr) + if err != nil || !strings.EqualFold(u.Scheme, "https") { + return nil, fmt.Errorf("betterstack: URL must be https when AllowInsecureURL is false") + } + } + httpCfg.URL = urlStr + + timestampField := cfg.TimestampField + if timestampField == "" { + timestampField = "dt" + } + httpCfg.Encoder = newEncoder(timestampField) + + merged := make(map[string]string, len(httpCfg.Headers)+2) + for k, v := range httpCfg.Headers { + if strings.EqualFold(k, "authorization") || strings.EqualFold(k, "content-type") { + continue // betterstack sets these; ignore any user-provided values + } + merged[k] = v + } + merged["Authorization"] = fmt.Sprintf("Bearer %s", cfg.SourceToken) + merged["Content-Type"] = "application/json" + httpCfg.Headers = merged + + httpT, err := httptr.Build(httpCfg) + if err != nil { + return nil, err + } + return &Transport{Transport: httpT}, nil +} + +// newEncoder produces the JSON-array encoder for Better Stack's intake format. +func newEncoder(timestampField string) httptr.Encoder { + return httptr.EncoderFunc(func(entries []httptr.Entry) ([]byte, string, error) { + objs := make([]map[string]any, len(entries)) + for i, e := range entries { + obj := make(map[string]any, 3+len(e.Data)) + obj["message"] = transport.JoinMessages(e.Messages) + obj[timestampField] = e.Time.UTC().Format("2006-01-02T15:04:05.000Z") + + levelStr := statusFor(e.Level) + if levelStr != "" { + obj["level"] = levelStr + } + + transport.MergeIntoMap(obj, e.Data, e.Metadata, e.Schema.MetadataFieldName) + objs[i] = obj + } + body, err := json.Marshal(objs) + return body, "application/json", err + }) +} + +// statusFor maps a loglayer LogLevel to Better Stack's level string. +func statusFor(l loglayer.LogLevel) string { + switch l { + case loglayer.LogLevelTrace: + return "trace" + case loglayer.LogLevelDebug: + return "debug" + case loglayer.LogLevelInfo: + return "info" + case loglayer.LogLevelWarn: + return "warn" + case loglayer.LogLevelError: + return "error" + case loglayer.LogLevelFatal: + return "fatal" + case loglayer.LogLevelPanic: + return "panic" + default: + return "" + } +} diff --git a/transports/betterstack/betterstack_test.go b/transports/betterstack/betterstack_test.go new file mode 100644 index 0000000..0b35ed8 --- /dev/null +++ b/transports/betterstack/betterstack_test.go @@ -0,0 +1,391 @@ +package betterstack + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + httptr "go.loglayer.dev/transports/http/v2" + "go.loglayer.dev/v2" +) + +type capture struct { + mu sync.Mutex + bodies [][]byte + headers []http.Header +} + +func (c *capture) handler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + c.mu.Lock() + defer c.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + c.bodies = append(c.bodies, body) + c.headers = append(c.headers, r.Header.Clone()) +} + +func rewriteClient(srv *httptest.Server) *http.Client { + base := srv.Client().Transport + if base == nil { + base = http.DefaultTransport + } + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &urlRewriter{ + base: base, + target: srv.URL, + }, + } +} + +type urlRewriter struct { + base http.RoundTripper + target string +} + +func (u *urlRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + target := strings.TrimRight(u.target, "/") + req.URL.Path + parsed, err := http.NewRequestWithContext(req.Context(), req.Method, target, req.Body) + if err != nil { + return nil, err + } + parsed.Header = req.Header.Clone() + parsed.ContentLength = req.ContentLength + return u.base.RoundTrip(parsed) +} + +func TestBuild(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + + log.Info("test") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if len(cap.bodies) != 1 { + t.Fatalf("expected 1 batch, got %d", len(cap.bodies)) + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + if len(arr) != 1 { + t.Fatalf("expected 1 entry, got %d", len(arr)) + } + if arr[0]["message"] != "test" { + t.Errorf("message: got %q", arr[0]["message"]) + } +} + +func TestBuild_EmptySourceToken(t *testing.T) { + t.Parallel() + + cfg := Config{} + tr, err := Build(cfg) + + if tr != nil { + t.Errorf("expected nil transport, got %v", tr) + } + + var requiredErr ErrSourceTokenRequired + if !errors.As(err, &requiredErr) { + t.Fatalf("expected ErrSourceTokenRequired, got %T: %v", err, err) + } +} + +func TestEncoder_Shape(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + + log = log.WithFields(map[string]any{"userId": "123"}) + log.WithMetadata(map[string]any{"traceId": "abc"}).Info("test") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if len(cap.bodies) != 1 { + t.Fatalf("expected 1 batch, got %d", len(cap.bodies)) + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + if len(arr) != 1 { + t.Fatalf("expected 1 entry, got %d", len(arr)) + } + + obj := arr[0] + + if obj["message"] != "test" { + t.Errorf("message: got %q", obj["message"]) + } + if obj["level"] != "info" { + t.Errorf("level: got %q", obj["level"]) + } + if _, ok := obj["dt"].(string); !ok { + t.Error("expected dt field to be present") + } + if obj["userId"] != "123" { + t.Errorf("userId: got %v", obj["userId"]) + } + if obj["traceId"] != "abc" { + t.Errorf("traceId: got %v", obj["traceId"]) + } +} + +func TestEncoder_TimestampAlwaysPresent(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + + log.Info("test") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + obj := arr[0] + + if _, ok := obj["dt"]; !ok { + t.Error("expected dt field to be present") + } +} + +func TestEncoder_CustomTimestampField(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + TimestampField: "timestamp", + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + + log.Info("test") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + obj := arr[0] + + if _, ok := obj["timestamp"]; !ok { + t.Error("expected timestamp field in payload") + } +} + +func TestString_RedactsSourceToken(t *testing.T) { + t.Parallel() + + cfg := Config{ + SourceToken: "secret-token-123", + URL: "https://in.logs.betterstack.com", + } + + s := cfg.String() + + if strings.Contains(s, "secret-token-123") { + t.Error("String() should not expose the source token") + } + + if !strings.Contains(s, "***redacted***") { + t.Error("String() should show ***redacted*** for the token") + } +} + +func TestEncoder_AllLogLevels(t *testing.T) { + t.Parallel() + + levelMap := map[loglayer.LogLevel]string{ + loglayer.LogLevelTrace: "trace", + loglayer.LogLevelDebug: "debug", + loglayer.LogLevelInfo: "info", + loglayer.LogLevelWarn: "warn", + loglayer.LogLevelError: "error", + loglayer.LogLevelFatal: "fatal", + loglayer.LogLevelPanic: "panic", + } + + for level, expected := range levelMap { + level, expected := level, expected + + t.Run(level.String(), func(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + + switch level { + case loglayer.LogLevelTrace: + log.Trace("test") + case loglayer.LogLevelDebug: + log.Debug("test") + case loglayer.LogLevelInfo: + log.Info("test") + case loglayer.LogLevelWarn: + log.Warn("test") + case loglayer.LogLevelError: + log.Error("test") + case loglayer.LogLevelFatal: + log.Fatal("test") + case loglayer.LogLevelPanic: + defer func() { _ = recover() }() + log.Panic("test") + } + + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v", err) + } + obj := arr[0] + + if obj["level"] != expected { + t.Errorf("expected level '%s' for %v, got %q", expected, level, obj["level"]) + } + }) + } +} + +func TestBuild_HTTPHeadersMerge(t *testing.T) { + t.Parallel() + + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + cfg := Config{ + SourceToken: "test-token", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Hour, + Client: rewriteClient(srv), + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + "Authorization": "Bearer should-be-overridden", // should be replaced + "Content-Type": "should-also-be-overridden", // should be replaced + }, + }, + } + tr := New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Info("test") + + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + arrHeaders := cap.headers + if len(arrHeaders) != 1 { + t.Fatalf("expected 1 request headers, got %d", len(arrHeaders)) + } + + reqHeaders := arrHeaders[0] + + authHeader := reqHeaders.Get("Authorization") + contentTypeHeader := reqHeaders.Get("Content-Type") + + if authHeader != "Bearer test-token" { + t.Errorf("Expected Authorization header 'Bearer test-token', got %q", authHeader) + } + if contentTypeHeader != "application/json" { + t.Errorf("Expected Content-Type header 'application/json', got %q", contentTypeHeader) + } + + customHeader := reqHeaders.Get("X-Custom-Header") + if customHeader != "custom-value" { + t.Errorf("Expected X-Custom-Header 'custom-value' to be preserved, got %q", customHeader) + } +} diff --git a/transports/betterstack/errors.go b/transports/betterstack/errors.go new file mode 100644 index 0000000..dbc9276 --- /dev/null +++ b/transports/betterstack/errors.go @@ -0,0 +1,10 @@ +package betterstack + +import "fmt" + +// ErrSourceTokenRequired is returned when Config.SourceToken is empty. +type ErrSourceTokenRequired string + +func (e ErrSourceTokenRequired) Error() string { + return fmt.Sprintf("betterstack: source token required (got %q)", string(e)) +} diff --git a/transports/betterstack/example_test.go b/transports/betterstack/example_test.go new file mode 100644 index 0000000..5d94a24 --- /dev/null +++ b/transports/betterstack/example_test.go @@ -0,0 +1,20 @@ +package betterstack + +import ( + "go.loglayer.dev/v2" +) + +// ExampleNew shows how to create a Better Stack transport and ship logs. +func ExampleNew() { + t := New(Config{ + SourceToken: "your-source-token", + URL: "https://in.logs.betterstack.com", + }) + defer t.Close() + + log := loglayer.New(loglayer.Config{ + Transport: t, + DisableFatalExit: true, + }) + log.Info("served") +} diff --git a/transports/betterstack/go.mod b/transports/betterstack/go.mod new file mode 100644 index 0000000..4606193 --- /dev/null +++ b/transports/betterstack/go.mod @@ -0,0 +1,13 @@ +module go.loglayer.dev/transports/betterstack + +go 1.25.0 + +require ( + github.com/goccy/go-json v0.10.6 + go.loglayer.dev/transports/http/v2 v2.1.0 + go.loglayer.dev/v2 v2.0.1 +) + +replace go.loglayer.dev => ../.. + +replace go.loglayer.dev/transports/http => ../http diff --git a/transports/betterstack/go.sum b/transports/betterstack/go.sum new file mode 100644 index 0000000..04aba65 --- /dev/null +++ b/transports/betterstack/go.sum @@ -0,0 +1,8 @@ +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +go.loglayer.dev/transports/http/v2 v2.1.0 h1:+9kaawgxkbFYKvIliTWwSjj9WKUnG9Uzjm3dRmuCkRY= +go.loglayer.dev/transports/http/v2 v2.1.0/go.mod h1:OeIaQoUHcT3Qeb8HI8CaY4OFhQwwpC/Pb0yXGTrHxIM= +go.loglayer.dev/v2 v2.0.1 h1:B7oYpkfMky0UG/N8IdKeIiiTi6h7eyj5NvRyEth9DHI= +go.loglayer.dev/v2 v2.0.1/go.mod h1:+BWhs5AyICvCLBz07qHnCE12W34tArUWfXOba0Ct/QI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= diff --git a/transports/betterstack/livetest_test.go b/transports/betterstack/livetest_test.go new file mode 100644 index 0000000..f17706e --- /dev/null +++ b/transports/betterstack/livetest_test.go @@ -0,0 +1,91 @@ +//go:build livetest + +// Live test against the real Better Stack Logs intake. Compiled only with +// `-tags=livetest` so normal `go test ./...` runs ignore it. +// +// Run: +// +// BETTERSTACK_SOURCE_TOKEN= go test -tags=livetest -v -run TestLive_BetterStack_SendsLog ./transports/betterstack/ +// +// Optional environment variables: +// +// BETTERSTACK_URL Better Stack intake URL (default: https://in.logs.betterstack.com) +// +// To verify in Better Stack Logs Explorer, search for +// +// source:go-loglayer-livetest @livetest_id: +// +// Indexing typically takes 5-60 seconds. + +package betterstack_test + +import ( + "cmp" + "errors" + "os" + "sync" + "testing" + "time" + + betterstack "go.loglayer.dev/transports/betterstack" + httptr "go.loglayer.dev/transports/http/v2" + "go.loglayer.dev/v2/transport/transporttest" + "go.loglayer.dev/v2/utils/idgen" +) + +func TestLive_BetterStack_SendsLog(t *testing.T) { + sourceToken := os.Getenv("BETTERSTACK_SOURCE_TOKEN") + if sourceToken == "" { + t.Skip("BETTERSTACK_SOURCE_TOKEN not set; skipping live Better Stack test") + } + + url := cmp.Or(os.Getenv("BETTERSTACK_URL"), "https://in.logs.betterstack.com") + baseID := idgen.Random("") + + var ( + errMu sync.Mutex + sendErrs []error + errCount int + ) + tr := betterstack.New(betterstack.Config{ + SourceToken: sourceToken, + URL: url, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: time.Millisecond, // Bypass batching for live tests + OnError: func(err error, entries []httptr.Entry) { + errMu.Lock() + defer errMu.Unlock() + errCount++ + sendErrs = append(sendErrs, err) + }, + }, + }) + + ids := transporttest.SendLivetestVariants(tr, baseID) + + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + errMu.Lock() + defer errMu.Unlock() + if errCount > 0 { + for _, e := range sendErrs { + t.Logf("send error: %v", e) + var httpErr *httptr.HTTPError + if errors.As(e, &httpErr) { + switch httpErr.StatusCode { + case 401, 403: + t.Errorf("authentication failed (status %d) — check BETTERSTACK_SOURCE_TOKEN", httpErr.StatusCode) + } + } + } + t.Fatalf("Better Stack intake reported %d error(s); see logs above", errCount) + } + + t.Logf("Sent livetest entries to Better Stack (%s).", url) + for i, v := range transporttest.LivetestVariants { + t.Logf(" %s: source:go-loglayer-livetest @livetest_id:%s", v.Name, ids[i]) + } +}