diff --git a/.changeset/calm-ivy.md b/.changeset/calm-ivy.md new file mode 100644 index 0000000..e551514 --- /dev/null +++ b/.changeset/calm-ivy.md @@ -0,0 +1,5 @@ +--- +"transports/newrelic": major +--- + +Initial release. diff --git a/.changeset/jolly-bear.md b/.changeset/jolly-bear.md deleted file mode 100644 index e0a31a0..0000000 --- a/.changeset/jolly-bear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"transports/axiom": major ---- - -Initial release. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 8b6d8ce..fd717c2 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -156,6 +156,8 @@ When adding a new transport, update the transport-list partial. Both the homepag 3. Add a sidebar entry in `docs/.vitepress/config.ts`. 4. Run `cd docs && bun run docs:build` and confirm clean. +Do not include a "Live Test" or similar section in transport/plugin docs. Live tests are development-time artifacts (build-tagged, env-var-gated) that belong in the code, not the docs. Users don't need to read about them. + ## Go Version Floors The main `go.loglayer.dev` module's Go floor is whatever the highest dep in its tree demands. Today that's **1.25** (driven by `golang.org/x/exp` via `charmbracelet/log` and `golang.org/x/sys`). Sub-modules — `transports/otellog`, `plugins/oteltrace`, `plugins/datadogtrace/livetest` — have their own go.mod files and their own floors. @@ -164,7 +166,7 @@ When adding a transport, plugin, or integration: 1. **If your dep would raise the main module's floor**, first ask whether splitting your code into its own go.mod would isolate the bump. Heavy SDK bindings (OpenTelemetry, vendor APIs) are good candidates for splitting; small libraries that nudge the floor by one minor version usually aren't. -2. **If you split**, mirror the structure used by `transports/otellog/`: own `go.mod` with `module go.loglayer.dev/`, `replace go.loglayer.dev => ../...` for development, a placeholder `require go.loglayer.dev v0.0.0-...` line that the replace directive overrides. Add a CI step in `.github/workflows/ci.yml` that `cd`s into the new module and runs tests. Update the `Mostly single Go module` bullet in AGENTS.md "Key Design Decisions" with the new module path. +2. **If you split**, mirror the structure used by `transports/otellog/`: own `go.mod` with `module go.loglayer.dev/` (no `/v2` suffix on the module path), `replace go.loglayer.dev => ../...` for development, a placeholder `require go.loglayer.dev v0.0.0-...` line that the replace directive overrides. Depend on `go.loglayer.dev/v2` explicitly. The import path is `go.loglayer.dev/` because the sub-module ships at v1.0.0 initially (not v2.0.0); the `/v2` in deps is the *core's* version, not the sub-module's. When the sub-module itself later breaks its own API, it moves to `/v2` and the corresponding major bump. Add a CI step in `.github/workflows/ci.yml` that `cd`s into the new module and runs tests. Update the `Mostly single Go module` bullet in AGENTS.md "Key Design Decisions" with the new module path. 3. **If you don't split and the floor moves**, update `go.mod`, the matrix in `.github/workflows/ci.yml`, and the version statements in `README.md`, `docs/src/getting-started.md`, and `AGENTS.md`. Add a `.changeset/*.md` for the affected module(s) at the appropriate bump level and note the floor change in `docs/src/whats-new.md`. diff --git a/AGENTS.md b/AGENTS.md index 749dd53..1b91751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ To add `` (e.g. `transports/foo` or `plugins/bar`): 1. Create the directory and code as usual. 2. Add `/go.mod` with: - ``` + ```go module go.loglayer.dev/ go 1.25.0 @@ -216,6 +216,8 @@ To add `` (e.g. `transports/foo` or `plugins/bar`): require go.loglayer.dev v0.0.0-00010101000000-000000000000 ``` + The module path is `go.loglayer.dev/` with **no `/v2` suffix**, even though the core dependency is `go.loglayer.dev/v2`. The sub-module ships at v1.0.0 independently; it only gets a `/v2` in its own path when *it* breaks its own API after v1.x.x. (See `transports/datadog` — started at `transports/datadog/v1.0.0`, later bumped to `transports/datadog/v2.0.0` when the core went v2 and the wrapper shape changed.) + Adjust the `replace` depth (`../..` for `transports/foo`, `../../..` for `plugins/foo/livetest`, etc.). If the package depends on other split sub-modules (e.g. `plugins/plugintest`), add corresponding `replace` and `require` lines following the existing siblings as a template. 3. Register the module in `monorel.toml` with a `[packages.""]` block following the existing siblings as a template (`tag_prefix`, `path`, `changelog` all set to the path-derived values). 4. Add the path to `scripts/foreach-module.sh` (`ALL_MODULES`, `SHIPPED_MODULES`, and the `test` op's hardcoded list). diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9d1a644..46cbbe3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -163,6 +163,7 @@ gtag('config', '${gaMeasurementId}');`, { text: 'Axiom', link: '/transports/axiom' }, { text: 'Datadog', link: '/transports/datadog' }, { text: 'Google Cloud Logging', link: '/transports/gcplogging' }, + { text: 'New Relic', link: '/transports/newrelic' }, { 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..ca231a8 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.svg)](https://pkg.go.dev/go.loglayer.dev/transports/newrelic) | New Relic Log Ingest API. Site-aware URL, api-key header, LogEvent encoding. | | [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/datadog.md b/docs/src/transports/datadog.md index 736a65f..1b6d31b 100644 --- a/docs/src/transports/datadog.md +++ b/docs/src/transports/datadog.md @@ -225,41 +225,6 @@ tr.Close() // from httptransport.Transport tr.GetLoggerInstance() // from httptransport.Transport (returns nil) ``` -## Live Test - -A build-tagged test (`//go:build livetest`) ships with the package and hits the real Datadog intake. It's gated by build tag *and* by an env-var check so normal `go test ./...` runs ignore it entirely. - -```sh -# Minimal -DD_API_KEY= go test -tags=livetest -v -run TestLive_Datadog ./transports/datadog/ - -# With all options -DD_API_KEY= \ -DD_SITE=us1 \ -DD_SOURCE=go-loglayer-livetest \ -DD_SERVICE=loglayer-go-livetest \ -DD_HOSTNAME=$(hostname) \ -DD_TAGS=env:livetest,team:platform \ - go test -tags=livetest -v -run TestLive_Datadog ./transports/datadog/ -``` - -The test sends two entries (one Info with persistent fields, one Warn with metadata) and fails if the intake returns any error. It prints a search query you can paste into the Datadog Logs Explorer to verify the entries landed: - -``` -source:go-loglayer-livetest @livetest_id: -``` - -Indexing latency in Datadog is typically 5-60 seconds. Without `DD_API_KEY` the test skips with a clear message, so it's safe to leave the build tag in CI without leaking errors. - -| Env var | Required | Default | Purpose | -|---------------|----------|--------------------------|--------------------------------| -| `DD_API_KEY` | Yes | (none) | Datadog API key | -| `DD_SITE` | No | `us1` | Datadog region | -| `DD_SOURCE` | No | `go-loglayer-livetest` | `ddsource` field | -| `DD_SERVICE` | No | `loglayer-go-livetest` | `service` field | -| `DD_HOSTNAME` | No | empty | `hostname` field | -| `DD_TAGS` | No | `env:livetest` | `ddtags` field | - ## Fatal Behavior diff --git a/docs/src/transports/newrelic.md b/docs/src/transports/newrelic.md new file mode 100644 index 0000000..ff8a056 --- /dev/null +++ b/docs/src/transports/newrelic.md @@ -0,0 +1,207 @@ +--- +title: New Relic Transport +description: Ship logs to the New Relic Log Ingest API. +--- + +# New Relic Transport + + + +Sends log entries to the [New Relic Log Ingest API](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/). Built on the [HTTP transport](/transports/http) with a New Relic-specific encoder, site-aware URL, and `api-key` header. + +```sh +go get go.loglayer.dev/transports/newrelic +``` + +## Getting a License Key and Site + +New Relic identifies your account with a **license key** (also called a user key) and a **site** (the region your account lives in). You need both. + +To get a license key: + +1. Sign in at the URL that matches your New Relic account (see the Site table below). +2. Navigate to **Account management** → **License keys**. Direct link: `https://.newrelic.com/account management/license keys`. +3. Either copy an existing key or generate a new one. New Relic may hide the value after creation, so save it immediately. + +Further information on New Relic API keys is available in the [official documentation](https://docs.newrelic.com/docs/apis/intro-apis/new-relic-api-keys/). + +To pick the Site: + +| Site code | Sign-in URL | Intake URL | +|---|---|---| +| `SiteUS` *(default)* | `newrelic.com` | `https://log-api.newrelic.com/log/v1` | +| `SiteEU` | `eu.newrelic.com` | `https://log-api.eu.newrelic.com/log/v1` | + +If you signed up at the bare `newrelic.com`, you are on `SiteUS`. If your account is EU-based, use `SiteEU`. + +The license key is a secret. Treat it like a password: load it from an environment variable or secret manager rather than hard-coding it in source. + +## Basic Usage + +```go +import ( + "go.loglayer.dev/v2" + "go.loglayer.dev/transports/newrelic" +) + +tr := newrelic.New(newrelic.Config{ + LicenseKey: os.Getenv("NEW_RELIC_LICENSE_KEY"), + Site: newrelic.SiteUS, // or SiteEU +}) +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. + +## Sites + +`Site` controls the intake URL. Pick the one that matches your New Relic account: + +| Site | Intake URL | +|---------------|---------------------------------------------------| +| `SiteUS` (default) | `https://log-api.newrelic.com/log/v1` | +| `SiteEU` | `https://log-api.eu.newrelic.com/log/v1` | + +### On-prem / custom URL + +For on-prem deployments or when testing against a mock endpoint, set `Config.URL` directly. The override wins over `Site`: + +```go +newrelic.New(newrelic.Config{ + LicenseKey: "...", + URL: "https://newrelic.internal.acme.com/log/v1", + // Site is ignored when URL is set. +}) +``` + +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 := newrelic.New(newrelic.Config{ + LicenseKey: "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 + + LicenseKey string // required + Site Site // default SiteUS; ignored when URL is set + URL string // overrides the Site-derived intake URL (on-prem / mock) + AllowInsecureURL bool // permit non-HTTPS URLs (httptest, local proxies) + + HTTP httptransport.Config // batching/client/error handling overrides +} +``` + +### `LicenseKey` + +Required. Set as the `api-key` header on every request. `newrelic.New` panics with `newrelic.ErrLicenseKeyRequired` when this is empty; use `newrelic.Build(cfg) (*Transport, error)` if you load the key from an environment variable and want to handle the missing-config case explicitly. + +### `HTTP` + +Embedded `httptransport.Config` for batching, client timeout, error handling, and other HTTP-layer concerns. The `URL`, `Encoder`, and `api-key` header are set by the New Relic wrapper and cannot be overridden via this field. + +```go +tr := newrelic.New(newrelic.Config{ + LicenseKey: key, + 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("newrelic.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 +[ + { + "logtype": "LogEvent", + "timestamp": 1745616000123, + "loglevel": "info", + "message": "served request", + "requestId": "abc", + "durationMs": 42 + } +] +``` + +Every object includes `logtype: "LogEvent"` and `timestamp` (epoch milliseconds) as required by the New Relic API. 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`. Fields and metadata are merged via `transport.MergeIntoMap` so they coexist cleanly with the system fields. Set either knob on `loglayer.Config` to nest under a configured key instead. + +## Level → loglevel Mapping + +New Relic uses a `loglevel` string per entry. The transport maps loglayer levels: + +| LogLayer Level | New Relic loglevel | +|------------------|--------------------| +| `LogLevelTrace` | `trace` | +| `LogLevelDebug` | `debug` | +| `LogLevelInfo` | `info` | +| `LogLevelWarn` | `warn` | +| `LogLevelError` | `error` | +| `LogLevelFatal` | `critical` | +| `LogLevelPanic` | `critical` | + +New Relic has no distinction between Fatal and Panic, so both map to `critical` (the highest-severity level in the Log Ingest API). + +## API Limits + +New Relic enforces these restrictions on Log Ingest API requests ([reference](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/#limits)): + +- 1MB maximum payload per POST (compression recommended) +- Payload must be UTF-8 encoded +- 255 attributes maximum per event +- 255 characters maximum per attribute name +- 4,094 characters stored in NRDB; longer values stored as a blob + +The default `BatchSize` of 100 stays well under the 1MB payload limit for typical entries. If you bump `BatchSize` for higher throughput or ship large attributes, watch for the boundary. + +## Closing + +`newrelic.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 := newrelic.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 + +`newrelic.Transport` embeds `*httptransport.Transport`, so any HTTP-transport method works on it directly: + +```go +tr := newrelic.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/otellog.md b/docs/src/transports/otellog.md index e5e9f4f..8566506 100644 --- a/docs/src/transports/otellog.md +++ b/docs/src/transports/otellog.md @@ -209,16 +209,6 @@ log.WithContext(ctx).Info("served") For the persistent-binding pattern in HTTP handlers, see [Go Context](/logging-api/go-context). The `loghttp` middleware binds `r.Context()` automatically so handlers reading via `loghttp.FromRequest(r)` get trace correlation with no per-emission boilerplate. -## Live Integration Tests - -The transport ships with `//go:build livetest`-tagged tests that exercise the real OpenTelemetry SDK end-to-end (real `LoggerProvider` with an in-memory `Exporter`, real `TracerProvider` for span correlation). They're skipped by the default test run and opt-in via: - -```sh -go test -tags=livetest ./transports/otellog/ -``` - -CI runs them automatically. See `transports/otellog/livetest_test.go` for the full set. - ## Reaching the Underlying Logger `GetLoggerInstance` returns the underlying `log.Logger`: 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..2bedbe1 100644 --- a/go.work +++ b/go.work @@ -11,9 +11,10 @@ use ( ./transports/console ./transports/datadog ./transports/gcplogging - ./transports/lumberjack ./transports/http + ./transports/lumberjack ./transports/logrus + ./transports/newrelic ./transports/otellog ./transports/phuslu ./transports/pretty 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..1c28250 100755 --- a/scripts/foreach-module.sh +++ b/scripts/foreach-module.sh @@ -38,9 +38,10 @@ ALL_MODULES=( transports/console transports/datadog transports/gcplogging - transports/lumberjack transports/http + transports/lumberjack transports/logrus + transports/newrelic transports/otellog transports/phuslu transports/pretty @@ -80,9 +81,10 @@ SHIPPED_MODULES=( transports/console transports/datadog transports/gcplogging - transports/lumberjack transports/http + transports/lumberjack transports/logrus + transports/newrelic transports/otellog transports/phuslu transports/pretty @@ -171,9 +173,10 @@ case "$op" in transports/console transports/datadog transports/gcplogging - transports/lumberjack transports/http + transports/lumberjack transports/logrus + transports/newrelic transports/otellog transports/phuslu transports/pretty diff --git a/transports/axiom/CHANGELOG.md b/transports/axiom/CHANGELOG.md index e69de29..a6e9051 100644 --- a/transports/axiom/CHANGELOG.md +++ b/transports/axiom/CHANGELOG.md @@ -0,0 +1,13 @@ +# 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). + +## [1.0.0] - 2026-05-06 + +### Major Changes + +- Initial release. + diff --git a/transports/axiom/go.mod b/transports/axiom/go.mod index cddaaad..69b34b9 100644 --- a/transports/axiom/go.mod +++ b/transports/axiom/go.mod @@ -6,7 +6,7 @@ replace go.loglayer.dev => ../.. require ( github.com/axiomhq/axiom-go v0.32.0 - go.loglayer.dev/v2 v2.0.1 + go.loglayer.dev/v2 v2.1.0 ) require ( 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..825c32f --- /dev/null +++ b/transports/newrelic/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/transports/newrelic/README.md b/transports/newrelic/README.md new file mode 100644 index 0000000..4020d1d --- /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.svg)](https://pkg.go.dev/go.loglayer.dev/transports/newrelic) + +New Relic Logs HTTP intake transport for LogLayer. Site-aware URL, Api-Key header, New Relic log format encoder with logtype and timestamp. Built on the HTTP transport. + +## 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..ecc277b --- /dev/null +++ b/transports/newrelic/errors.go @@ -0,0 +1,18 @@ +package newrelic + +import "errors" + +// ErrLicenseKeyRequired is returned by Build (and panicked by New) when +// Config.LicenseKey is empty. +var ErrLicenseKeyRequired = errors.New("loglayer/transports/newrelic: Config.LicenseKey is required") + +// ErrInsecureURL is returned by Build (and panicked by New) when +// Config.URL has a non-https scheme. The license key travels in the +// Api-Key header on every request; refuse to ship it in cleartext. +var ErrInsecureURL = errors.New("loglayer/transports/newrelic: Config.URL must use https") + +// ErrHTTPOverrideForbidden is returned when Config.HTTP.URL or +// Config.HTTP.Encoder is non-zero. These fields are managed by this +// package. Set Config.URL on the New Relic config instead; 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") diff --git a/transports/newrelic/example_test.go b/transports/newrelic/example_test.go new file mode 100644 index 0000000..c82f7d0 --- /dev/null +++ b/transports/newrelic/example_test.go @@ -0,0 +1,24 @@ +package newrelic_test + +import ( + "go.loglayer.dev/transports/newrelic" + "go.loglayer.dev/v2" +) + +// New ships log entries to the New Relic Logs HTTP intake. LicenseKey is +// required; Site selects the regional intake (defaults to SiteUS). The +// transport spawns a worker goroutine; call Close on shutdown to flush +// pending entries. +func ExampleNew() { + t := newrelic.New(newrelic.Config{ + LicenseKey: "your-new-relic-license-key", + Site: newrelic.SiteUS, + }) + 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..a729f9d --- /dev/null +++ b/transports/newrelic/go.mod @@ -0,0 +1,14 @@ +module go.loglayer.dev/transports/newrelic + +go 1.25.0 + +require ( + github.com/goccy/go-json v0.10.6 + go.loglayer.dev/transports/http/v2 v2.0.1 + go.loglayer.dev/v2 v2.0.1 + go.uber.org/goleak v1.3.0 +) + +replace go.loglayer.dev => ../.. + +replace go.loglayer.dev/transports/http/v2 => ../http diff --git a/transports/newrelic/go.sum b/transports/newrelic/go.sum new file mode 100644 index 0000000..9feb45c --- /dev/null +++ b/transports/newrelic/go.sum @@ -0,0 +1,14 @@ +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.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= +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..8f4c600 --- /dev/null +++ b/transports/newrelic/goleak_test.go @@ -0,0 +1,20 @@ +package newrelic_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain wraps the suite in goleak.VerifyTestMain to catch goroutine +// leaks. The transport spawns an HTTP worker; tests must call tr.Close() +// to shut it down. HTTP connection-pool goroutines are ignored because +// they outlive normal test cleanup. +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"), + goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"), + ) +} diff --git a/transports/newrelic/livetest_test.go b/transports/newrelic/livetest_test.go new file mode 100644 index 0000000..b530014 --- /dev/null +++ b/transports/newrelic/livetest_test.go @@ -0,0 +1,92 @@ +//go:build livetest + +// Live test against the real New Relic Log Ingest API. Compiled only with +// `-tags=livetest` so normal `go test ./...` runs ignore it. +// +// Run: +// +// NR_LICENSE_KEY= go test -tags=livetest -v -run TestLive_NewRelic ./transports/newrelic/ +// +// Optional environment variables: +// +// NR_SITE US (default) or EU +// +// To verify in New Relic: open the Logs page and search for +// +// livetest_id: +// +// Indexing typically takes 5-30 seconds. + +package newrelic_test + +import ( + "errors" + "os" + "sync" + "testing" + "time" + + httptr "go.loglayer.dev/transports/http/v2" + "go.loglayer.dev/transports/newrelic" + "go.loglayer.dev/v2/transport/transporttest" + "go.loglayer.dev/v2/utils/idgen" +) + +func TestLive_NewRelic_SendsLog(t *testing.T) { + licenseKey := os.Getenv("NR_LICENSE_KEY") + if licenseKey == "" { + t.Skip("NR_LICENSE_KEY not set; skipping live New Relic test") + } + + site := newrelic.Site(os.Getenv("NR_SITE")) + baseID := idgen.Random("") + + var ( + errMu sync.Mutex + sendErrs []error + errCount int + ) + tr := newrelic.New(newrelic.Config{ + LicenseKey: licenseKey, + Site: site, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 500 * time.Millisecond, + 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 NR_LICENSE_KEY and NR_SITE", httpErr.StatusCode) + case 400: + t.Errorf("bad request (status %d) — check the log payload format", httpErr.StatusCode) + } + } + } + t.Fatalf("New Relic Log Ingest reported %d error(s); see logs above", errCount) + } + + t.Logf("Sent livetest entries to New Relic (%s).", site.IntakeURL()) + for i, v := range transporttest.LivetestVariants { + t.Logf(" %s: livetest_id:%s", v.Name, ids[i]) + } +} diff --git a/transports/newrelic/newrelic.go b/transports/newrelic/newrelic.go new file mode 100644 index 0000000..820d4c3 --- /dev/null +++ b/transports/newrelic/newrelic.go @@ -0,0 +1,197 @@ +// Package newrelic sends log entries to the New Relic Logs HTTP intake API. +// +// Wraps transports/http with New Relic-specific defaults: +// - Site-aware intake URL (US, EU) +// - Api-Key header from Config.LicenseKey +// - Encoder that emits New Relic's expected log shape (logtype, +// timestamp, loglevel, message) merged with the user's fields +// and metadata +// +// 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 ( + "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" +) + +// Site identifies the New Relic region. Affects only the intake URL. +type Site string + +const ( + SiteUS Site = "US" // log-api.newrelic.com (default) + SiteEU Site = "EU" // log-api.eu.newrelic.com +) + +// IntakeURL returns the New Relic logs intake endpoint for the site. An +// unknown or empty site falls back to SiteUS. +func (s Site) IntakeURL() string { + switch s { + case SiteEU: + return "https://log-api.eu.newrelic.com/log/v1" + default: + return "https://log-api.newrelic.com/log/v1" + } +} + +// Config holds New Relic transport configuration. +type Config struct { + transport.BaseConfig + + // LicenseKey is the New Relic license key or user key. Required. + // + // Tagged json:"-" so that log.WithMetadata(cfg).Info(...) through + // any JSON-emitting transport (structured, zerolog, zap, slog, + // etc.) won't ship the key in the rendered log. Direct field + // access by the transport's own Build() is unaffected. + LicenseKey string `json:"-"` + + // Site selects the New Relic region. Defaults to SiteUS. Ignored + // when URL is set. + Site Site + + // URL overrides the Site-derived intake URL. Use it for on-prem + // deployments or for testing against a mock endpoint. When set, + // Site is ignored. + URL string + + // AllowInsecureURL permits Config.URL to use a non-https scheme. The + // license 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 plaintext. Set true only when a TLS-terminating proxy + // or private network carries the cleartext hop. The Site-derived + // intake URLs are always https and unaffected. + AllowInsecureURL bool + + // HTTP overrides batching, client, error handling, and any other + // transports/http settings. URL and Encoder are managed 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 license key. +// +// Note: Go's fmt verbs %+v and %#v bypass the Stringer interface; +// the json:"-" tag on LicenseKey prevents the JSON-via-transport path. +func (c Config) String() string { + masked := c + if masked.LicenseKey != "" { + masked.LicenseKey = "***redacted***" + } + return fmt.Sprintf( + "newrelic.Config{LicenseKey:%q Site:%q URL:%q}", + masked.LicenseKey, masked.Site, masked.URL, + ) +} + +// 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.LicenseKey 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 +// ErrLicenseKeyRequired instead of panicking when cfg.LicenseKey is empty. +// Use this when the license 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.LicenseKey == "" { + return nil, ErrLicenseKeyRequired + } + if cfg.HTTP.URL != "" || cfg.HTTP.Encoder != nil { + return nil, ErrHTTPOverrideForbidden + } + + httpCfg := cfg.HTTP + httpCfg.BaseConfig = cfg.BaseConfig + if cfg.URL != "" { + if !cfg.AllowInsecureURL { + u, err := url.Parse(cfg.URL) + if err != nil || !strings.EqualFold(u.Scheme, "https") { + return nil, ErrInsecureURL + } + } + httpCfg.URL = cfg.URL + } else { + httpCfg.URL = cfg.Site.IntakeURL() + } + httpCfg.Encoder = newEncoder() + + // Clone Headers so we don't mutate the caller's map by adding Api-Key. + merged := make(map[string]string, len(cfg.HTTP.Headers)+1) + for k, v := range cfg.HTTP.Headers { + merged[k] = v + } + merged["Api-Key"] = cfg.LicenseKey + 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 New Relic's intake format. +// Each entry is a JSON object with logtype, timestamp, loglevel, message, +// plus any user fields and metadata. +func newEncoder() 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, 4+len(e.Data)) + obj["logtype"] = "LogEvent" + obj["timestamp"] = e.Time.UnixMilli() + obj["loglevel"] = loglevelFor(e.Level) + obj["message"] = transport.JoinMessages(e.Messages) + transport.MergeIntoMap(obj, e.Data, e.Metadata, e.Schema.MetadataFieldName) + objs[i] = obj + } + body, err := json.Marshal(objs) + return body, "application/json", err + }) +} + +// loglevelFor maps a loglayer LogLevel to New Relic's loglevel string. +// Fatal and Panic both map to "critical" (New Relic's highest-severity +// log level). +func loglevelFor(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, 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..fb3a300 --- /dev/null +++ b/transports/newrelic/newrelic_test.go @@ -0,0 +1,741 @@ +package newrelic_test + +import ( + "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" + "go.loglayer.dev/v2" + "go.loglayer.dev/v2/transport" +) + +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() + c.bodies = append(c.bodies, body) + c.headers = append(c.headers, r.Header.Clone()) + c.mu.Unlock() + w.WriteHeader(http.StatusAccepted) +} + +func newLogger(t *testing.T, cfg newrelic.Config) (*loglayer.LogLayer, *newrelic.Transport) { + t.Helper() + tr := newrelic.New(cfg) + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + return log, tr +} + +// Basic log delivery: write entries and call Close to flush the batch. +func TestNewRelic_BasicBatchOnClose(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + log, tr := newLogger(t, newrelic.Config{ + LicenseKey: "test-key-123", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log.Info("hello") + log.Warn("world") + + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) < 1 { + t.Fatal("expected at least one request") + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v: %q", err, cap.bodies[0]) + } + if len(arr) == 0 { + t.Fatal("expected at least one entry in batch") + } + + // First entry should be "hello" + if arr[0]["message"] != "hello" { + t.Errorf("first message: got %v, want hello", arr[0]["message"]) + } +} + +// FlushOnBatchSize: when the batch reaches BatchSize, the worker POSTs +// immediately without waiting for BatchInterval. +func TestNewRelic_FlushOnBatchSize(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + log, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 2, + BatchInterval: time.Hour, + }, + }) + + log.Info("one") + log.Info("two") + + // Give the worker time to flush the batch. + time.Sleep(50 * time.Millisecond) + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("expected a request after batch size reached") + } + + var arr []map[string]any + _ = json.Unmarshal(cap.bodies[0], &arr) + if len(arr) != 2 { + t.Errorf("expected 2 entries in batch, got %d", len(arr)) + } + + // Close to shut down cleanly. + cap.mu.Unlock() + t.Cleanup(func() { _ = tr.Close() }) + cap.mu.Lock() +} + +// FlushOnInterval: when BatchInterval elapses before BatchSize is reached, +// accumulated entries are POSTed. +func TestNewRelic_FlushOnInterval(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + log, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 100, + BatchInterval: 25 * time.Millisecond, + }, + }) + + log.Info("after interval") + + // Wait for the interval to fire. + time.Sleep(100 * time.Millisecond) + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("expected a request after interval elapsed") + } + + var arr []map[string]any + _ = json.Unmarshal(cap.bodies[0], &arr) + if len(arr) != 1 { + t.Errorf("expected 1 entry, got %d", len(arr)) + } + _ = tr.Close() +} + +// HeadersAndContentType: the Api-Key header and Content-Type are set on +// every request. +func TestNewRelic_HeadersAndContentType(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + log, tr := newLogger(t, newrelic.Config{ + LicenseKey: "my-license-key", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log.Info("check headers") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.headers) == 0 { + t.Fatal("no request captured") + } + + hdrs := cap.headers[0] + if got := hdrs.Get("Api-Key"); got != "my-license-key" { + t.Errorf("Api-Key: got %q, want my-license-key", got) + } + if got := hdrs.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type: got %q, want application/json", got) + } +} + +// OnError_HTTPStatus: when the server responds with a non-2xx status, the +// OnError callback is invoked with an HTTPError. +func TestNewRelic_OnError_HTTPStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + var ( + mu sync.Mutex + gotErr error + gotEntries []httptr.Entry + ) + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + OnError: func(err error, entries []httptr.Entry) { + mu.Lock() + gotErr = err + gotEntries = entries + mu.Unlock() + }, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Info("will fail") + time.Sleep(100 * time.Millisecond) + + mu.Lock() + errVal := gotErr + entries := gotEntries + mu.Unlock() + if errVal == nil { + t.Fatal("expected an error from OnError") + } + if !strings.Contains(errVal.Error(), "status 500") { + t.Errorf("error message: got %v", errVal) + } + if len(entries) != 1 { + t.Errorf("expected 1 retriable entry, got %d", len(entries)) + } + _ = tr.Close() +} + +// LevelFiltering: entries below Config.Level are not sent. +func TestNewRelic_LevelFiltering(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + log, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + BaseConfig: transport.BaseConfig{ + Level: loglayer.LogLevelWarn, + }, + }) + + log.Debug("should be filtered") + log.Info("also filtered") + log.Warn("this passes") + log.Error("this too") + + // Wait for interval to flush. + time.Sleep(100 * time.Millisecond) + _ = tr.Close() + + cap.mu.Lock() + defer cap.mu.Unlock() + var arr []map[string]any + for _, body := range cap.bodies { + var batch []map[string]any + if err := json.Unmarshal(body, &batch); err != nil { + continue + } + arr = append(arr, batch...) + } + + if len(arr) < 2 { + t.Fatalf("expected at least 2 entries (warn + error), got %d", len(arr)) + } + + // Check that no debug or info entries made it through. + for i, obj := range arr { + if loglevel, ok := obj["loglevel"].(string); ok { + if loglevel == "debug" || loglevel == "info" { + t.Errorf("entry %d should have been filtered, got loglevel %q", i, loglevel) + } + } + } +} + +// EncodedBodyShape: each entry in the batch has logtype, timestamp, loglevel, +// and message fields with the correct values. +func TestNewRelic_EncodedBodyShape(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Info("test body shape") + + time.Sleep(100 * time.Millisecond) + _ = tr.Close() + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("no request captured") + } + + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v: %q", err, cap.bodies[0]) + } + if len(arr) != 1 { + t.Fatalf("expected 1 entry, got %d", len(arr)) + } + + obj := arr[0] + + if obj["logtype"] != "LogEvent" { + t.Errorf("logtype: got %v, want LogEvent", obj["logtype"]) + } + + // Timestamp should be a number (epoch milliseconds). + if ts, ok := obj["timestamp"].(float64); !ok { + t.Errorf("timestamp should be a number, got %T: %v", obj["timestamp"], obj["timestamp"]) + } else if ts < 1_700_000_000_000 { + t.Errorf("timestamp too small for a valid epoch-ms: %v", ts) + } + + if obj["loglevel"] != "info" { + t.Errorf("loglevel: got %v, want info", obj["loglevel"]) + } + + if obj["message"] != "test body shape" { + t.Errorf("message: got %v, want 'test body shape'", obj["message"]) + } +} + +// FieldAndMetadataInBody: WithFields and WithMetadata values land in the +// JSON body alongside the standard fields. +func TestNewRelic_FieldAndMetadataInBody(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log = log.WithFields(loglayer.Fields{"requestId": "req-42"}) + log.WithMetadata(loglayer.Metadata{"durationMs": 123, "endpoint": "/api/v1"}).Info("handled") + + time.Sleep(100 * time.Millisecond) + _ = tr.Close() + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("no request captured") + } + + var arr []map[string]any + _ = json.Unmarshal(cap.bodies[0], &arr) + + obj := arr[0] + if obj["requestId"] != "req-42" { + t.Errorf("requestId: got %v", obj["requestId"]) + } + // JSON unmarshals numbers as float64. + if obj["durationMs"] != float64(123) { + t.Errorf("durationMs: got %v", obj["durationMs"]) + } + if obj["endpoint"] != "/api/v1" { + t.Errorf("endpoint: got %v", obj["endpoint"]) + } +} + +// CloseIsIdempotent: calling Close multiple times returns nil each time +// and does not panic. +func TestNewRelic_CloseIsIdempotent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{BatchSize: 1}, + }) + + if err := tr.Close(); err != nil { + t.Errorf("first Close: %v", err) + } + if err := tr.Close(); err != nil { + t.Errorf("second Close: %v", err) + } +} + +// New_PanicsWithoutLicenseKey: calling New with an empty LicenseKey panics +// with ErrLicenseKeyRequired. +func TestNewRelic_New_PanicsWithoutLicenseKey(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic when LicenseKey missing") + } + err, ok := r.(error) + if !ok || !errors.Is(err, newrelic.ErrLicenseKeyRequired) { + t.Errorf("panic value: got %v, want ErrLicenseKeyRequired", r) + } + }() + _ = newrelic.New(newrelic.Config{}) +} + +// Build_ReturnsErrLicenseKeyRequired: calling Build with an empty LicenseKey +// returns the sentinel error instead of panicking. +func TestNewRelic_Build_ReturnsErrLicenseKeyRequired(t *testing.T) { + _, err := newrelic.Build(newrelic.Config{}) + if !errors.Is(err, newrelic.ErrLicenseKeyRequired) { + t.Errorf("Build with missing LicenseKey: got %v, want ErrLicenseKeyRequired", err) + } +} + +// InsecureURLRejected: a http:// URL is rejected by default to prevent +// shipping the license key in plaintext. +func TestNewRelic_Build_InsecureURLRejected(t *testing.T) { + _, err := newrelic.Build(newrelic.Config{ + LicenseKey: "k", + URL: "http://example.com/log/v1", + }) + if !errors.Is(err, newrelic.ErrInsecureURL) { + t.Errorf("Build with http URL: got %v, want ErrInsecureURL", err) + } +} + +// AllowInsecureURLPermitsHTTP: AllowInsecureURL: true allows a http:// URL to +// be set. +func TestNewRelic_Build_AllowInsecureURLPermitsHTTP(t *testing.T) { + tr, err := newrelic.Build(newrelic.Config{ + LicenseKey: "k", + URL: "http://example.com/log/v1", + AllowInsecureURL: true, + }) + if err != nil { + t.Fatalf("AllowInsecureURL=true should pass: %v", err) + } + _ = tr.Close() +} + +// HTTPOverrideForbidden: setting HTTP.URL or HTTP.Encoder on the Config +// returns ErrHTTPOverrideForbidden instead of silently dropping the +// value. +func TestNewRelic_Build_HTTPOverrideForbidden(t *testing.T) { + _, err := newrelic.Build(newrelic.Config{ + LicenseKey: "k", + HTTP: httptr.Config{ + URL: "https://my-forwarder.internal/v1/logs", + }, + }) + 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{ + LicenseKey: "k", + HTTP: httptr.Config{ + Encoder: enc, + }, + }) + if !errors.Is(err, newrelic.ErrHTTPOverrideForbidden) { + t.Errorf("Build with HTTP.Encoder set: got %v, want ErrHTTPOverrideForbidden", err) + } +} + +// SiteEUDerivedURL: SiteEU produces the correct EU intake URL. +func TestNewRelic_SiteEUDerivedURL(t *testing.T) { + cases := []struct { + site newrelic.Site + want string + }{ + {"", "https://log-api.newrelic.com/log/v1"}, + {newrelic.SiteUS, "https://log-api.newrelic.com/log/v1"}, + {newrelic.SiteEU, "https://log-api.eu.newrelic.com/log/v1"}, + } + for _, c := range cases { + if got := c.site.IntakeURL(); got != c.want { + t.Errorf("Site %q: got %q, want %q", c.site, got, c.want) + } + } +} + +// ConfigStringRedactsLicenseKey: Config.String() hides the raw license +// key so an accidental log.Info(cfg) or fmt.Sprintf cannot leak it. +func TestNewRelic_ConfigStringRedactsLicenseKey(t *testing.T) { + cfg := newrelic.Config{ + LicenseKey: "deadbeef-secret-keep-me-out-of-logs", + Site: newrelic.SiteUS, + } + + s := cfg.String() + if strings.Contains(s, "deadbeef-secret-keep-me-out-of-logs") { + t.Errorf("LicenseKey leaked through String(): %s", s) + } + if !strings.Contains(s, "redacted") { + t.Errorf("String() should mark LicenseKey as redacted: %s", s) + } + + v := fmt.Sprintf("%v", cfg) + if strings.Contains(v, "deadbeef-secret-keep-me-out-of-logs") { + t.Errorf("LicenseKey leaked through %%v: %s", v) + } +} + +// TestNewRelic_GroupsWork validates that WithGroup entries are dispatched +// without error and produce well-formed encoded output. +func TestNewRelic_GroupsWork(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.WithGroup("web").Info("grouped log") + + time.Sleep(100 * time.Millisecond) + _ = tr.Close() + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("no request captured") + } + + // Verify the entry was delivered and encoded correctly. Groups are on + // Entry; the real assertion is that the encoder processed without error. + var arr []map[string]any + if err := json.Unmarshal(cap.bodies[0], &arr); err != nil { + t.Fatalf("body is not JSON: %v: %q", err, cap.bodies[0]) + } + if len(arr) != 1 { + t.Fatalf("expected 1 entry, got %d", len(arr)) + } + obj := arr[0] + if obj["logtype"] != "LogEvent" { + t.Errorf("logtype: got %v", obj["logtype"]) + } + if obj["message"] != "grouped log" { + t.Errorf("message: got %v", obj["message"]) + } +} + +// LoglevelFor: every loglayer level maps to the expected New Relic loglevel. +func TestNewRelic_LevelMapping(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 10, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Trace("t") + log.Debug("d") + log.Info("i") + log.Warn("w") + log.Error("e") + log.Fatal("f") + func() { + defer func() { _ = recover() }() + log.Panic("p") + }() + + time.Sleep(100 * time.Millisecond) + _ = tr.Close() + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("no request captured") + } + + var allEntries []map[string]any + for _, body := range cap.bodies { + var batch []map[string]any + _ = json.Unmarshal(body, &batch) + allEntries = append(allEntries, batch...) + } + + if len(allEntries) == 0 { + t.Fatal("no entries captured") + } + + wantMap := map[string]struct{}{ + "trace": {}, + "debug": {}, + "info": {}, + "warn": {}, + "error": {}, + "critical": {}, + } + for i, obj := range allEntries { + if lv, ok := obj["loglevel"].(string); ok { + if _, ok := wantMap[lv]; !ok { + t.Errorf("entry %d has unexpected loglevel %q", i, lv) + } + } + } +} + +// TestNewRelic_ConfigLicenseKeyTaggedJSONIgnore validates that the json:"-" +// tag on LicenseKey prevents accidental leaks via log.WithMetadata(cfg). +func TestNewRelic_ConfigLicenseKeyTaggedJSONIgnore(t *testing.T) { + field, ok := reflect.TypeOf(newrelic.Config{}).FieldByName("LicenseKey") + if !ok { + t.Fatal("Config.LicenseKey field not found") + } + if got := field.Tag.Get("json"); got != "-" { + t.Errorf("LicenseKey json tag: got %q, want \"-\"", got) + } +} + +// URLOverride: Config.URL overrides the Site-derived URL for on-prem +// deployments or testing against a mock endpoint. +func TestNewRelic_URLOverride(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + Site: newrelic.SiteEU, + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Info("on-prem") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.bodies) == 0 { + t.Fatal("no request received at the override URL") + } +} + +func TestNewRelic_CustomHeadersMerged(t *testing.T) { + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(cap.handler)) + defer srv.Close() + + _, tr := newLogger(t, newrelic.Config{ + LicenseKey: "k", + URL: srv.URL, + AllowInsecureURL: true, + HTTP: httptr.Config{ + BatchSize: 1, + BatchInterval: 10 * time.Millisecond, + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + }, + }, + }) + + log := loglayer.New(loglayer.Config{Transport: tr, DisableFatalExit: true}) + log.Info("with custom header") + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + cap.mu.Lock() + defer cap.mu.Unlock() + if len(cap.headers) == 0 { + t.Fatal("no request captured") + } + + hdrs := cap.headers[0] + if got := hdrs.Get("X-Custom-Header"); got != "custom-value" { + t.Errorf("X-Custom-Header: got %q, want custom-value", got) + } + // Api-Key should still be there alongside custom headers. + if got := hdrs.Get("Api-Key"); got != "k" { + t.Errorf("Api-Key: got %q, want k", got) + } +}