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) | [![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. | | [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/v2.svg)](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) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/sentry/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/sentry/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/sentry/v2.svg)](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 + +[![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/newrelic/v2.svg)](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) + } +}