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) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/axiom/&expanded=true) | [](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) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/betterstack/&expanded=true) | [](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) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/datadog/&expanded=true) | [](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) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/gcplogging/&expanded=true) | [](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) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/newrelic/&expanded=true) | [](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])
+ }
+}