diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 9d1a644..7bb6b91 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -162,6 +162,7 @@ gtag('config', '${gaMeasurementId}');`,
items: [
{ text: 'Axiom', link: '/transports/axiom' },
{ text: 'Datadog', link: '/transports/datadog' },
+ { text: 'New Relic', link: '/transports/newrelic' },
{ text: 'Google Cloud Logging', link: '/transports/gcplogging' },
{ text: 'Sentry', link: '/transports/sentry' },
],
diff --git a/docs/src/transports/_partials/transport-list.md b/docs/src/transports/_partials/transport-list.md
index 95e7150..a58eaf8 100644
--- a/docs/src/transports/_partials/transport-list.md
+++ b/docs/src/transports/_partials/transport-list.md
@@ -26,6 +26,7 @@ Managed log services. Async + batched by default; site-aware where applicable.
| [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. |
| [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/v2) | New Relic Log API. Zone-aware URL, Api-Key header, NDJSON encoder. |
| [Sentry](/transports/sentry) | [](https://github.com/loglayer/loglayer-go/releases?q=transports/sentry/&expanded=true) | [](https://pkg.go.dev/go.loglayer.dev/transports/sentry/v2) | Forwards entries to a `sentry.Logger`. Routes fatal/panic through `LFatal` so loglayer's core controls termination. |
diff --git a/docs/src/transports/newrelic.md b/docs/src/transports/newrelic.md
new file mode 100644
index 0000000..148b74a
--- /dev/null
+++ b/docs/src/transports/newrelic.md
@@ -0,0 +1,82 @@
+---
+title: New Relic Transport
+description: Ships log entries to New Relic Log API as NDJSON.
+---
+
+# New Relic Transport
+
+
+
+The `newrelic` transport ships log entries to the [New Relic Log API](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/) as NDJSON. Authentication is via the `Api-Key` header from `Config.APIKey` by default. `X-License-Key` is optionally supported for accounts that require it. Built on `transports/http` for async batching.
+
+```sh
+go get go.loglayer.dev/transports/newrelic
+```
+
+## Basic Usage
+
+```go
+import (
+ "go.loglayer.dev/v2"
+ "go.loglayer.dev/transports/newrelic/v2"
+)
+
+tr := newrelic.New(newrelic.Config{
+ APIKey: "your-new-relic-api-key",
+ Zone: newrelic.ZoneUS, // optional; ZoneUS is the default
+})
+
+log := loglayer.New(loglayer.Config{Transport: tr})
+log.Info("hello")
+
+defer tr.Close() // flushes pending entries
+```
+
+## Config
+
+| Field | Type | Default | Description |
+|--------------|---------------------|---------|-------------|
+| `APIKey` | `string` | (required) | New Relic user key for the `Api-Key` header |
+| `LicenseKey` | `string` | empty | Optional `X-License-Key` header value |
+| `Zone` | `newrelic.IntakeZone` | `ZoneUS` | Region selection |
+| `URL` | `string` | derived from `Zone` | Custom endpoint override |
+| `Hostname` | `string` | empty | Per-entry `hostname` attribute |
+| `AllowInsecureURL` | `bool` | false | Permit non-https `Config.URL` |
+| `HTTP` | `httptr.Config` | batching defaults | HTTP transport overrides |
+
+`Config.HTTP.URL` and `Config.HTTP.Encoder` cannot be set (the transport sets them itself). Custom headers go through `Config.HTTP.Headers`.
+
+### Zone
+
+| Constant | Region | Endpoint |
+|------------|---------------|----------------------------------------------|
+| `ZoneUS` | US (default) | `https://log-api.newrelic.com/log/v1` |
+| `ZoneEU` | EU | `https://log-api.eu.newrelic.com/log/v1` |
+
+### API Key and License Key
+
+The `APIKey` and `LicenseKey` fields are tagged `json:"-"` and redacted in `Config.String()` to prevent accidental exposure when the config is logged or passed through a JSON transport.
+
+## Log Format
+
+Each entry serializes as a JSON object with `timestamp`, `message`, `loglevel`, and optionally `hostname`. All user fields and metadata merge as root-level attributes. Lines are newline-delimited (NDJSON).
+
+```json
+{"hostname":"prod-web-01","loglevel":"warning","message":"high latency","requestId":"abc","timestamp":"2026-04-26T12:00:00.000Z"}
+```
+
+### Level Mapping
+
+| LogLayer Level | New Relic level |
+|------------------|-----------------|
+| `LogLevelTrace` | `debug` |
+| `LogLevelDebug` | `debug` |
+| `LogLevelInfo` | `info` |
+| `LogLevelWarn` | `warning` |
+| `LogLevelError` | `error` |
+| `LogLevelFatal` | `critical` |
+| `LogLevelPanic` | `critical` |
+
+## Fatal Behavior
+
+
diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md
index 5ff6a97..5c50c26 100644
--- a/docs/src/whats-new.md
+++ b/docs/src/whats-new.md
@@ -7,6 +7,12 @@ description: Latest features and improvements in LogLayer for Go.
- See the [main `CHANGELOG.md`](https://github.com/loglayer/loglayer-go/blob/main/CHANGELOG.md) for the auto-generated per-release log.
+## May 10, 2026
+
+`transports/newrelic`:
+
+Initial release. New [New Relic transport](/transports/newrelic).
+
## May 06, 2026
`transports/axiom`:
diff --git a/go.work b/go.work
index 2f90d7c..e36cdb7 100644
--- a/go.work
+++ b/go.work
@@ -12,6 +12,7 @@ use (
./transports/datadog
./transports/gcplogging
./transports/lumberjack
+ ./transports/newrelic
./transports/http
./transports/logrus
./transports/otellog
diff --git a/monorel.toml b/monorel.toml
index 4ac861e..561abe4 100644
--- a/monorel.toml
+++ b/monorel.toml
@@ -64,6 +64,11 @@ tag_prefix = "transports/lumberjack"
path = "transports/lumberjack"
changelog = "transports/lumberjack/CHANGELOG.md"
+[packages."transports/newrelic"]
+tag_prefix = "transports/newrelic"
+path = "transports/newrelic"
+changelog = "transports/newrelic/CHANGELOG.md"
+
[packages."transports/otellog"]
tag_prefix = "transports/otellog"
path = "transports/otellog"
diff --git a/scripts/foreach-module.sh b/scripts/foreach-module.sh
index 19e8ca2..343c0e1 100755
--- a/scripts/foreach-module.sh
+++ b/scripts/foreach-module.sh
@@ -39,6 +39,7 @@ ALL_MODULES=(
transports/datadog
transports/gcplogging
transports/lumberjack
+ transports/newrelic
transports/http
transports/logrus
transports/otellog
@@ -81,6 +82,7 @@ SHIPPED_MODULES=(
transports/datadog
transports/gcplogging
transports/lumberjack
+ transports/newrelic
transports/http
transports/logrus
transports/otellog
@@ -172,6 +174,7 @@ case "$op" in
transports/datadog
transports/gcplogging
transports/lumberjack
+ transports/newrelic
transports/http
transports/logrus
transports/otellog
diff --git a/transports/axiom/go.sum b/transports/axiom/go.sum
index 2d17142..02003ff 100644
--- a/transports/axiom/go.sum
+++ b/transports/axiom/go.sum
@@ -40,8 +40,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-go.loglayer.dev/v2 v2.0.1 h1:B7oYpkfMky0UG/N8IdKeIiiTi6h7eyj5NvRyEth9DHI=
-go.loglayer.dev/v2 v2.0.1/go.mod h1:+BWhs5AyICvCLBz07qHnCE12W34tArUWfXOba0Ct/QI=
+go.loglayer.dev/v2 v2.1.0 h1:8/fq8Z1NNLtjfKgaPn6S0Hy7zWPnkjn9Gj3YgEDFk4w=
+go.loglayer.dev/v2 v2.1.0/go.mod h1:+BWhs5AyICvCLBz07qHnCE12W34tArUWfXOba0Ct/QI=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
diff --git a/transports/newrelic/CHANGELOG.md b/transports/newrelic/CHANGELOG.md
new file mode 100644
index 0000000..bb5dc4c
--- /dev/null
+++ b/transports/newrelic/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+
diff --git a/transports/newrelic/README.md b/transports/newrelic/README.md
new file mode 100644
index 0000000..c2af80a
--- /dev/null
+++ b/transports/newrelic/README.md
@@ -0,0 +1,17 @@
+# go.loglayer.dev/transports/newrelic
+
+[](https://pkg.go.dev/go.loglayer.dev/transports/newrelic/v2)
+
+New Relic Log API transport for LogLayer. Built on `transports/http` with a New Relic-specific NDJSON encoder, zone-aware URL, and `Api-Key` header. Also supports `X-License-Key` for accounts that require it.
+
+## Install
+
+```sh
+go get go.loglayer.dev/transports/newrelic
+```
+
+## Documentation
+
+Full reference and examples:
+
+Main library:
diff --git a/transports/newrelic/errors.go b/transports/newrelic/errors.go
new file mode 100644
index 0000000..a0c0d39
--- /dev/null
+++ b/transports/newrelic/errors.go
@@ -0,0 +1,20 @@
+package newrelic
+
+import "errors"
+
+// ErrAPIKeyRequired is returned by Build (and panicked by New) when
+// Config.APIKey is empty.
+var ErrAPIKeyRequired = errors.New("loglayer/transports/newrelic: Config.APIKey is required")
+
+// ErrHTTPOverrideForbidden is returned by Build (and panicked by New)
+// when Config.HTTP.URL or Config.HTTP.Encoder is non-zero. The New Relic
+// transport sets these itself (URL from the fixed Log API endpoint,
+// Encoder from the package's NDJSON builder); a value supplied via the
+// embedded HTTP config would be silently dropped, which used to surprise
+// callers. The Encoder cannot be customized.
+var ErrHTTPOverrideForbidden = errors.New("loglayer/transports/newrelic: Config.HTTP.URL and Config.HTTP.Encoder are managed by this package and must be left zero")
+
+// ErrInsecureURL is returned by Build (and panicked by New) when
+// Config.URL has a non-https scheme. The New Relic API key would be sent
+// in cleartext over http; refuse rather than ship credentials in the open.
+var ErrInsecureURL = errors.New("loglayer/transports/newrelic: Config.URL must use https")
diff --git a/transports/newrelic/example_test.go b/transports/newrelic/example_test.go
new file mode 100644
index 0000000..a701cdd
--- /dev/null
+++ b/transports/newrelic/example_test.go
@@ -0,0 +1,23 @@
+package newrelic_test
+
+import (
+ "go.loglayer.dev/transports/newrelic/v2"
+ "go.loglayer.dev/v2"
+)
+
+// New ships log entries to the New Relic Log API. APIKey is required; Zone
+// selects the regional endpoint (defaults to ZoneUS). The transport spawns a
+// worker goroutine; call Close on shutdown to flush pending entries.
+func ExampleNew() {
+ t := newrelic.New(newrelic.Config{
+ APIKey: "your-new-relic-api-key",
+ Zone: newrelic.ZoneUS,
+ })
+ defer t.Close()
+
+ log := loglayer.New(loglayer.Config{
+ Transport: t,
+ DisableFatalExit: true,
+ })
+ log.Info("served")
+}
diff --git a/transports/newrelic/go.mod b/transports/newrelic/go.mod
new file mode 100644
index 0000000..391e393
--- /dev/null
+++ b/transports/newrelic/go.mod
@@ -0,0 +1,14 @@
+module go.loglayer.dev/transports/newrelic/v2
+
+go 1.25.0
+
+replace go.loglayer.dev/v2 => ../..
+
+replace go.loglayer.dev/transports/http/v2 => ../http
+
+require (
+ github.com/goccy/go-json v0.10.6
+ go.loglayer.dev/transports/http/v2 v2.0.0-00010101000000-000000000000
+ go.loglayer.dev/v2 v2.0.1
+ go.uber.org/goleak v1.3.0
+)
diff --git a/transports/newrelic/go.sum b/transports/newrelic/go.sum
new file mode 100644
index 0000000..574ae62
--- /dev/null
+++ b/transports/newrelic/go.sum
@@ -0,0 +1,12 @@
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/transports/newrelic/goleak_test.go b/transports/newrelic/goleak_test.go
new file mode 100644
index 0000000..69fea13
--- /dev/null
+++ b/transports/newrelic/goleak_test.go
@@ -0,0 +1,23 @@
+package newrelic_test
+
+import (
+ "testing"
+
+ "go.uber.org/goleak"
+)
+
+// TestMain wraps the suite in goleak.VerifyTestMain to catch goroutine
+// leaks. The New Relic transport wraps an HTTP transport that spawns
+// a worker goroutine per Transport via the internal async worker; tests
+// must call tr.Close() to shut it down.
+//
+// httptest's connection-shutdown goroutines occasionally outlive the
+// test cleanup, so we ignore the http.(*Server).Shutdown stacks. Any
+// other unexpected goroutine fails the suite.
+func TestMain(m *testing.M) {
+ goleak.VerifyTestMain(m,
+ goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
+ goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
+ goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
+ )
+}
diff --git a/transports/newrelic/newrelic.go b/transports/newrelic/newrelic.go
new file mode 100644
index 0000000..9a064d8
--- /dev/null
+++ b/transports/newrelic/newrelic.go
@@ -0,0 +1,240 @@
+// Package newrelic sends log entries to the New Relic Logs API.
+//
+// Wraps transports/http with New Relic-specific defaults:
+// - Log API endpoint (https://log-api.newrelic.com/log/v1)
+// - Api-Key header from Config.APIKey
+// - NDJSON encoder emitting New Relic's expected log shape
+// (timestamp, message, loglevel, hostname, and arbitrary attributes)
+//
+// API reference: https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/
+//
+// See https://go.loglayer.dev for usage guides and the full API reference.
+package newrelic
+
+import (
+ "bytes"
+ "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"
+)
+
+const defaultURL = "https://log-api.newrelic.com/log/v1"
+
+// IntakeZone identifies the New Relic log API region.
+type IntakeZone string
+
+const (
+ // ZoneUS is the default (US) New Relic log API region.
+ ZoneUS IntakeZone = "US"
+ // ZoneEU is the EU New Relic log API region.
+ ZoneEU IntakeZone = "EU"
+)
+
+// URL returns the Log API endpoint for the zone.
+func (z IntakeZone) URL() string {
+ switch z {
+ case ZoneEU:
+ return "https://log-api.eu.newrelic.com/log/v1"
+ default:
+ return defaultURL
+ }
+}
+
+// Config holds New Relic transport configuration.
+type Config struct {
+ transport.BaseConfig
+
+ // APIKey is the New Relic user key. Required. The Api-Key header is
+ // set from this value on every request.
+ //
+ // Tagged json:"-" so that log.WithMetadata(cfg).Info(...) through
+ // any JSON-emitting transport won't ship the key in the rendered log.
+ APIKey string `json:"-"`
+
+ // LicenseKey is the New Relic ingest license key. When set, the
+ // X-License-Key header is included. Optional; use it when your
+ // account requires it (some New Relic accounts enforce license keys
+ // instead of, or alongside, API keys).
+ //
+ // Tagged json:"-" for the same defense-in-depth reasons as APIKey.
+ LicenseKey string `json:"-"`
+
+ // Zone selects the New Relic region. Defaults to ZoneUS. Ignored
+ // when URL is set.
+ Zone IntakeZone
+
+ // URL overrides the Zone-derived Log API endpoint. Use it for on-prem
+ // deployments or for testing against a mock endpoint. When set, Zone
+ // is ignored.
+ URL string
+
+ // Hostname maps to the "hostname" attribute on each log entry.
+ // The application or host name identifying this source. Optional.
+ Hostname string
+
+ // AllowInsecureURL permits Config.URL to use a non-https scheme. The
+ // API key is sent in the Api-Key header on every request; without
+ // this flag, Build refuses a non-https URL to keep the key off the
+ // wire in cleartext. Set true only when an on-prem forwarder
+ // terminates TLS upstream and a private network carries the cleartext
+ // hop. The Zone-derived endpoints are always https and unaffected.
+ AllowInsecureURL bool
+
+ // HTTP overrides batching, client, error handling, and any other
+ // transports/http settings. The URL, Encoder, Api-Key header, and
+ // X-License-Key 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 API key.
+// Both APIKey and LicenseKey are replaced with a fixed mask regardless
+// of length.
+//
+// Note: Go's fmt verbs %+v and %#v intentionally bypass Stringer and
+// always print struct fields. Code that uses those verbs against
+// Config will see the raw keys. Reserve %+v / %#v for debugger-style
+// inspection, never for production logs. The json:"-" tags on APIKey
+// and LicenseKey prevent the JSON-via-transport leak path; this method
+// covers the fmt.Sprintf path; %+v / %#v are explicitly out of scope.
+func (c Config) String() string {
+ apiKey := c.APIKey
+ if apiKey != "" {
+ apiKey = "***redacted***"
+ }
+ licenseKey := c.LicenseKey
+ if licenseKey != "" {
+ licenseKey = "***redacted***"
+ }
+ return fmt.Sprintf(
+ "newrelic.Config{APIKey:%q LicenseKey:%q Zone:%q URL:%q Hostname:%q}",
+ apiKey, licenseKey, c.Zone, c.URL, c.Hostname,
+ )
+}
+
+// Transport wraps a transports/http.Transport with New Relic-specific
+// encoding and defaults.
+type Transport struct {
+ *httptr.Transport
+}
+
+// New constructs a New Relic Transport. Panics if Config.APIKey is empty.
+// Use Build for an error-returning variant.
+func New(cfg Config) *Transport {
+ t, err := Build(cfg)
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+// Build constructs a New Relic Transport like New but returns
+// ErrAPIKeyRequired instead of panicking when cfg.APIKey is empty. Use
+// this when the API key is loaded at runtime (e.g. from an environment
+// variable) and you want to handle the missing-config case explicitly.
+func Build(cfg Config) (*Transport, error) {
+ if cfg.APIKey == "" {
+ return nil, ErrAPIKeyRequired
+ }
+
+ httpCfg := cfg.HTTP
+ httpCfg.BaseConfig = cfg.BaseConfig
+ if cfg.HTTP.URL != "" || cfg.HTTP.Encoder != nil {
+ return nil, ErrHTTPOverrideForbidden
+ }
+
+ if cfg.URL != "" {
+ if !cfg.AllowInsecureURL {
+ parsed, err := url.Parse(cfg.URL)
+ if err != nil || !strings.EqualFold(parsed.Scheme, "https") {
+ return nil, ErrInsecureURL
+ }
+ }
+ httpCfg.URL = cfg.URL
+ } else {
+ httpCfg.URL = cfg.Zone.URL()
+ }
+ httpCfg.Encoder = newEncoder(cfg)
+
+ // Clone Headers so we don't mutate the caller's map.
+ merged := make(map[string]string, len(cfg.HTTP.Headers)+2)
+ for k, v := range cfg.HTTP.Headers {
+ merged[k] = v
+ }
+ merged["Api-Key"] = cfg.APIKey
+ if cfg.LicenseKey != "" {
+ merged["X-License-Key"] = cfg.LicenseKey
+ }
+ httpCfg.Headers = merged
+
+ httpT, err := httptr.Build(httpCfg)
+ if err != nil {
+ return nil, err
+ }
+ return &Transport{Transport: httpT}, nil
+}
+
+// Close drains the queue and stops the background worker.
+// Safe to call multiple times.
+func (t *Transport) Close() error {
+ return t.Transport.Close()
+}
+
+// GetLoggerInstance returns nil; the New Relic transport has no underlying logger.
+func (t *Transport) GetLoggerInstance() any { return nil }
+
+// newEncoder produces the NDJSON encoder for New Relic's Log API format.
+// Each entry is serialized as a JSON object with timestamp, message, loglevel,
+// optional hostname, and user fields/metadata merged as root-level attributes.
+// Lines are joined with "\n" for NDJSON.
+func newEncoder(cfg Config) httptr.Encoder {
+ return httptr.EncoderFunc(func(entries []httptr.Entry) ([]byte, string, error) {
+ var buf bytes.Buffer
+ for i, e := range entries {
+ obj := make(map[string]any, 4+len(e.Data))
+ obj["timestamp"] = e.Time.UTC().Format("2006-01-02T15:04:05.000Z07:00")
+ obj["message"] = transport.JoinMessages(e.Messages)
+ obj["loglevel"] = loglevelFor(e.Level)
+ if cfg.Hostname != "" {
+ obj["hostname"] = cfg.Hostname
+ }
+ transport.MergeIntoMap(obj, e.Data, e.Metadata, e.Schema.MetadataFieldName)
+ line, err := json.Marshal(obj)
+ if err != nil {
+ return nil, "", fmt.Errorf("newrelic: marshal entry %d: %w", i, err)
+ }
+ buf.Write(line)
+ if i < len(entries)-1 {
+ buf.WriteByte('\n')
+ }
+ }
+ return buf.Bytes(), "application/json", nil
+ })
+}
+
+// loglevelFor maps a loglayer LogLevel to New Relic's loglevel string.
+// New Relic recognizes: debug, info, warning, error, critical.
+// Trace folds into "debug"; Fatal and Panic map to "critical".
+func loglevelFor(l loglayer.LogLevel) string {
+ switch l {
+ case loglayer.LogLevelTrace, loglayer.LogLevelDebug:
+ return "debug"
+ case loglayer.LogLevelInfo:
+ return "info"
+ case loglayer.LogLevelWarn:
+ return "warning"
+ case loglayer.LogLevelError:
+ return "error"
+ case loglayer.LogLevelFatal, loglayer.LogLevelPanic:
+ return "critical"
+ default:
+ return "info"
+ }
+}
diff --git a/transports/newrelic/newrelic_test.go b/transports/newrelic/newrelic_test.go
new file mode 100644
index 0000000..400014b
--- /dev/null
+++ b/transports/newrelic/newrelic_test.go
@@ -0,0 +1,544 @@
+package newrelic_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ httptr "go.loglayer.dev/transports/http/v2"
+ "go.loglayer.dev/transports/newrelic/v2"
+ "go.loglayer.dev/v2"
+ "go.loglayer.dev/v2/transport"
+)
+
+func newCapture() *capture { return &capture{} }
+
+type capture struct {
+ mu sync.Mutex
+ bodies [][]byte
+ headers []http.Header
+ hits int
+}
+
+func (c *capture) handler(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ c.mu.Lock()
+ c.bodies = append(c.bodies, body)
+ c.headers = append(c.headers, r.Header.Clone())
+ c.hits++
+ c.mu.Unlock()
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func (c *capture) lastBody() []byte {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(c.bodies) == 0 {
+ return nil
+ }
+ return c.bodies[len(c.bodies)-1]
+}
+
+func (c *capture) lastHeaders() http.Header {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(c.headers) == 0 {
+ return nil
+ }
+ return c.headers[len(c.headers)-1]
+}
+
+// testCfg returns a Config wired against httptest server with
+// AllowInsecureURL (since httptest uses http://).
+func testCfg(srv *httptest.Server, fields ...func(*newrelic.Config)) newrelic.Config {
+ cfg := newrelic.Config{
+ APIKey: "k",
+ URL: srv.URL,
+ HTTP: httptr.Config{
+ BatchSize: 10,
+ BatchInterval: time.Hour,
+ },
+ AllowInsecureURL: true,
+ }
+ for _, f := range fields {
+ f(&cfg)
+ }
+ return cfg
+}
+
+func TestNewRelic_BasicSend(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("hello from newrelic")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ if cap.hits != 1 {
+ t.Fatalf("expected 1 request, got %d", cap.hits)
+ }
+
+ hdrs := cap.lastHeaders()
+ if got := hdrs.Get("Api-Key"); got != "k" {
+ t.Errorf("Api-Key header: got %q, want %q", got, "k")
+ }
+ if got := hdrs.Get("Content-Type"); got != "application/json" {
+ t.Errorf("Content-Type: got %q, want %q", got, "application/json")
+ }
+
+ // Parse NDJSON body
+ body := cap.lastBody()
+ lines := bytes.Split(body, []byte{'\n'})
+ if len(lines) != 1 {
+ t.Fatalf("expected 1 NDJSON line, got %d", len(lines))
+ }
+ var obj map[string]any
+ if err := json.Unmarshal(lines[0], &obj); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+ if obj["message"] != "hello from newrelic" {
+ t.Errorf("message: got %v", obj["message"])
+ }
+ if obj["loglevel"] != "info" {
+ t.Errorf("loglevel: got %v", obj["loglevel"])
+ }
+ if _, hasTimestamp := obj["timestamp"]; !hasTimestamp {
+ t.Error("missing timestamp field")
+ }
+}
+
+func TestNewRelic_LevelMapping(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+
+ log.Trace("t")
+ log.Debug("d")
+ log.Info("i")
+ log.Warn("w")
+ log.Error("e")
+ func() {
+ defer func() { _ = recover() }()
+ log.Fatal("f")
+ }()
+ func() {
+ defer func() { _ = recover() }()
+ log.Panic("p")
+ }()
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ body := cap.lastBody()
+ lines := bytes.Split(body, []byte{'\n'})
+ if len(lines) != 7 {
+ t.Fatalf("expected 7 lines, got %d: %q", len(lines), body)
+ }
+
+ wantLevels := []string{"debug", "debug", "info", "warning", "error", "critical", "critical"}
+ for i, want := range wantLevels {
+ var obj map[string]any
+ json.Unmarshal(lines[i], &obj)
+ if obj["loglevel"] != want {
+ t.Errorf("line %d loglevel: got %v, want %s", i, obj["loglevel"], want)
+ }
+ }
+}
+
+func TestNewRelic_HostnameInBody(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv, func(cfg *newrelic.Config) {
+ cfg.Hostname = "prod-web-01"
+ }))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("hello")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ var obj map[string]any
+ json.Unmarshal(cap.lastBody(), &obj)
+ if obj["hostname"] != "prod-web-01" {
+ t.Errorf("hostname: got %v, want prod-web-01", obj["hostname"])
+ }
+}
+
+func TestNewRelic_FieldsAndMetadata(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log = log.WithFields(loglayer.Fields{"requestId": "abc-123"})
+ log.WithMetadata(loglayer.Metadata{"durationMs": 42}).Info("served")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ var obj map[string]any
+ json.Unmarshal(cap.lastBody(), &obj)
+ if obj["requestId"] != "abc-123" {
+ t.Errorf("requestId: got %v", obj["requestId"])
+ }
+ if obj["durationMs"] != float64(42) {
+ t.Errorf("durationMs: got %v", obj["durationMs"])
+ }
+}
+
+func TestNewRelic_LicenseKeyHeader(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv, func(cfg *newrelic.Config) {
+ cfg.LicenseKey = "license-key"
+ }))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("hello")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ hdrs := cap.lastHeaders()
+ if got := hdrs.Get("X-License-Key"); got != "license-key" {
+ t.Errorf("X-License-Key: got %q, want license-key", got)
+ }
+}
+
+func TestNewRelic_BatchingBySize(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv, func(cfg *newrelic.Config) {
+ cfg.HTTP.BatchSize = 3
+ }))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("a")
+ log.Info("b")
+ log.Info("c")
+ log.Info("d")
+ log.Info("e")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ // With batch size 3: first 3 flush on size, last 2 flush on Close
+ if cap.hits < 2 {
+ t.Fatalf("expected at least 2 batches, got %d", cap.hits)
+ }
+
+ cap.mu.Lock()
+ defer cap.mu.Unlock()
+
+ lines := bytes.Split(cap.bodies[0], []byte{'\n'})
+ if len(lines) != 3 {
+ t.Errorf("batch 0 lines: got %d, want 3", len(lines))
+ }
+
+ lastLines := bytes.Split(cap.bodies[cap.hits-1], []byte{'\n'})
+ if len(lastLines) != 2 {
+ t.Errorf("final batch lines: got %d, want 2", len(lastLines))
+ }
+}
+
+func TestNewRelic_TimestampFormat(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("time check")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ var obj map[string]any
+ json.Unmarshal(cap.lastBody(), &obj)
+ ts, ok := obj["timestamp"].(string)
+ if !ok {
+ t.Fatalf("timestamp not a string: %v", obj["timestamp"])
+ }
+ _, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ t.Errorf("timestamp %q is not valid RFC3339: %v", ts, err)
+ }
+ if !strings.HasSuffix(ts, "Z") {
+ t.Errorf("timestamp should end with Z (UTC): %q", ts)
+ }
+}
+
+func TestNewRelic_LevelFiltering(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv, func(cfg *newrelic.Config) {
+ cfg.BaseConfig = transport.BaseConfig{ID: "newrelic", Level: loglayer.LogLevelError}
+ }))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("dropped")
+ log.Warn("dropped")
+ log.Error("kept")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ if cap.hits != 1 {
+ t.Fatalf("expected 1 request (error only), got %d", cap.hits)
+ }
+ var obj map[string]any
+ json.Unmarshal(cap.lastBody(), &obj)
+ if obj["loglevel"] != "error" {
+ t.Errorf("loglevel: got %v, want error", obj["loglevel"])
+ }
+}
+
+func TestNewRelic_NonMapMetadataNestedUnderKey(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+
+ type ev struct {
+ Op string `json:"op"`
+ }
+ log.WithMetadata(ev{Op: "load"}).Info("did")
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ var obj map[string]any
+ json.Unmarshal(cap.lastBody(), &obj)
+ meta, ok := obj["metadata"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected nested metadata object, got %T: %v", obj["metadata"], obj["metadata"])
+ }
+ if meta["op"] != "load" {
+ t.Errorf("metadata.op: got %v", meta["op"])
+ }
+}
+
+func TestNewRelic_CustomHeaders(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv, func(cfg *newrelic.Config) {
+ cfg.HTTP.Headers = map[string]string{"X-Custom-Header": "custom-value"}
+ }))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("hello")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ hdrs := cap.lastHeaders()
+ if got := hdrs.Get("X-Custom-Header"); got != "custom-value" {
+ t.Errorf("X-Custom-Header: got %q, want custom-value", got)
+ }
+ if got := hdrs.Get("Api-Key"); got != "k" {
+ t.Errorf("Api-Key: got %q, want k", got)
+ }
+}
+
+func TestNewRelic_ZoneURLs(t *testing.T) {
+ if got := newrelic.IntakeZone("US").URL(); got != "https://log-api.newrelic.com/log/v1" {
+ t.Errorf("US URL: got %q", got)
+ }
+ if got := newrelic.IntakeZone("EU").URL(); got != "https://log-api.eu.newrelic.com/log/v1" {
+ t.Errorf("EU URL: got %q", got)
+ }
+ if got := newrelic.IntakeZone("").URL(); got != "https://log-api.newrelic.com/log/v1" {
+ t.Errorf("empty zone (default US): got %q", got)
+ }
+}
+
+func TestNewRelic_URLOverride(t *testing.T) {
+ cap := newCapture()
+ srv := httptest.NewServer(http.HandlerFunc(cap.handler))
+ defer srv.Close()
+
+ tr := newrelic.New(testCfg(srv))
+ defer tr.Close()
+
+ log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true})
+ log.Info("to override")
+
+ if err := tr.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ if cap.hits != 1 {
+ t.Fatalf("expected 1 request to override URL, got %d", cap.hits)
+ }
+}
+
+func TestNewRelic_ConfigStringRedactsKeys(t *testing.T) {
+ cfg := newrelic.Config{
+ APIKey: "deadbeef-secret",
+ LicenseKey: "license-secret",
+ Hostname: "myhost",
+ }
+
+ s := cfg.String()
+ if strings.Contains(s, "deadbeef-secret") {
+ t.Errorf("APIKey leaked through String(): %s", s)
+ }
+ if strings.Contains(s, "license-secret") {
+ t.Errorf("LicenseKey leaked through String(): %s", s)
+ }
+ if !strings.Contains(s, "redacted") {
+ t.Errorf("String() should mark values as redacted: %s", s)
+ }
+ if !strings.Contains(s, "myhost") {
+ t.Errorf("hostname should be visible: %s", s)
+ }
+}
+
+func TestNewRelic_ConfigKeysTaggedJSONIgnore(t *testing.T) {
+ typ := reflect.TypeOf(newrelic.Config{})
+ for _, name := range []string{"APIKey", "LicenseKey"} {
+ field, ok := typ.FieldByName(name)
+ if !ok {
+ t.Fatalf("Config.%s field not found", name)
+ }
+ got := field.Tag.Get("json")
+ if got != "-" {
+ t.Errorf("Config.%s json tag: got %q, want \"-\"", name, got)
+ }
+ }
+}
+
+func TestNewRelic_Build_ErrAPIKeyRequired(t *testing.T) {
+ _, err := newrelic.Build(newrelic.Config{})
+ if !errors.Is(err, newrelic.ErrAPIKeyRequired) {
+ t.Errorf("Build with empty APIKey: got %v, want ErrAPIKeyRequired", err)
+ }
+}
+
+func TestNewRelic_New_PanicsWithoutAPIKey(t *testing.T) {
+ defer func() {
+ r := recover()
+ if r == nil {
+ t.Fatal("expected panic when APIKey missing")
+ }
+ err, ok := r.(error)
+ if !ok || !errors.Is(err, newrelic.ErrAPIKeyRequired) {
+ t.Errorf("panic value: got %v, want ErrAPIKeyRequired", r)
+ }
+ }()
+ _ = newrelic.New(newrelic.Config{})
+}
+
+func TestNewRelic_Build_ErrHTTPOverrideForbidden(t *testing.T) {
+ _, err := newrelic.Build(newrelic.Config{
+ APIKey: "k",
+ HTTP: httptr.Config{
+ URL: "https://example.com",
+ },
+ })
+ if !errors.Is(err, newrelic.ErrHTTPOverrideForbidden) {
+ t.Errorf("Build with HTTP.URL set: got %v, want ErrHTTPOverrideForbidden", err)
+ }
+
+ enc := httptr.EncoderFunc(func(_ []httptr.Entry) ([]byte, string, error) {
+ return nil, "", nil
+ })
+ _, err = newrelic.Build(newrelic.Config{
+ APIKey: "k",
+ HTTP: httptr.Config{
+ Encoder: enc,
+ },
+ })
+ if !errors.Is(err, newrelic.ErrHTTPOverrideForbidden) {
+ t.Errorf("Build with HTTP.Encoder set: got %v, want ErrHTTPOverrideForbidden", err)
+ }
+}
+
+func TestNewRelic_Build_RejectsInsecureURL(t *testing.T) {
+ _, err := newrelic.Build(newrelic.Config{
+ APIKey: "k",
+ URL: "http://example.com",
+ })
+ if !errors.Is(err, newrelic.ErrInsecureURL) {
+ t.Errorf("Build with http URL: got %v, want ErrInsecureURL", err)
+ }
+}
+
+func TestNewRelic_Build_AllowsInsecureURLWithOptIn(t *testing.T) {
+ tr, err := newrelic.Build(newrelic.Config{
+ APIKey: "k",
+ URL: "http://example.com",
+ AllowInsecureURL: true,
+ })
+ if err != nil {
+ t.Fatalf("AllowInsecureURL=true should pass: %v", err)
+ }
+ _ = tr.Close()
+}
+
+func TestNewRelic_ConfigString(t *testing.T) {
+ cfg := newrelic.Config{
+ APIKey: "secret-api-key",
+ LicenseKey: "secret-license-key",
+ Hostname: "web-prod-1",
+ }
+
+ s := fmt.Sprintf("%v", cfg)
+ if strings.Contains(s, "secret-api-key") {
+ t.Errorf("APIKey leaked via %%v: %s", s)
+ }
+ if strings.Contains(s, "secret-license-key") {
+ t.Errorf("LicenseKey leaked via %%v: %s", s)
+ }
+}