Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-ivy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"transports/newrelic": major
---

Initial release.
5 changes: 0 additions & 5 deletions .changeset/jolly-bear.md

This file was deleted.

4 changes: 3 additions & 1 deletion .claude/rules/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<path>`, `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/<path>` (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/<path>` 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 `<path>/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`.

Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ To add `<path>` (e.g. `transports/foo` or `plugins/bar`):
1. Create the directory and code as usual.
2. Add `<path>/go.mod` with:

```
```go
module go.loglayer.dev/<path>

go 1.25.0
Expand All @@ -216,6 +216,8 @@ To add `<path>` (e.g. `transports/foo` or `plugins/bar`):
require go.loglayer.dev v0.0.0-00010101000000-000000000000
```

The module path is `go.loglayer.dev/<path>` 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."<path>"]` 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).
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
1 change: 1 addition & 0 deletions docs/src/transports/_partials/transport-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

</div>
Expand Down
35 changes: 0 additions & 35 deletions docs/src/transports/datadog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<your-key> go test -tags=livetest -v -run TestLive_Datadog ./transports/datadog/

# With all options
DD_API_KEY=<your-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:<random-hex>
```

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

<!--@include: ./_partials/fatal-passthrough.md-->
Expand Down
207 changes: 207 additions & 0 deletions docs/src/transports/newrelic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
---
title: New Relic Transport
description: Ship logs to the New Relic Log Ingest API.
---

# New Relic Transport

<ModuleBadges path="transports/newrelic" />

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://<your-site>.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

<!--@include: ./_partials/fatal-passthrough.md-->

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.
10 changes: 0 additions & 10 deletions docs/src/transports/otellog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
6 changes: 6 additions & 0 deletions docs/src/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
3 changes: 2 additions & 1 deletion go.work
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions monorel.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading