diff --git a/.changeset/jolly-bear.md b/.changeset/jolly-bear.md new file mode 100644 index 0000000..e0a31a0 --- /dev/null +++ b/.changeset/jolly-bear.md @@ -0,0 +1,5 @@ +--- +"transports/axiom": major +--- + +Initial release. diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md index f344db6..95fd89e 100644 --- a/.claude/rules/git-workflow.md +++ b/.claude/rules/git-workflow.md @@ -32,9 +32,19 @@ The rule exists to make that class of bug impossible by construction. If the loc - Use `git rebase --onto origin/` form when the local branch's lower commits are squash-merge ancestors that won't replay cleanly. Replaying just the work-in-this-PR's commits keeps the rebase trivial. - After the rebase succeeds, run the build / tests once before pushing. A rebase can produce silent semantic conflicts that compile but break behavior. +## Branch policy + +**Never commit directly to `main`.** Always create a feature branch from current main, work on it, then open a PR that targets main. This applies equally to: +- Feature development +- Bug fixes +- Documentation updates +- Configuration changes + +The only exception is if you're a maintainer merging a reviewed PR (via squash-merge as configured in GitHub). + ## What this rule does NOT cover -- Force-pushing to `main` or other shared protected branches. Don't. +- Force-pushing to other shared protected branches. Don't. - Resolving genuine merge conflicts during the rebase. Read both sides; never `git checkout --theirs/ours` blindly. - Skipping the rule with `--no-verify`. The rule is the rule even when the hook isn't enforcing it. diff --git a/.gitignore b/.gitignore index cdc7e0e..9002a30 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ Thumbs.db # Claude Code runtime state .claude/scheduled_tasks.lock +docs/superpowers/plans/ diff --git a/AGENTS.md b/AGENTS.md index 501da0a..749dd53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,10 @@ loglayer-go/ │ ├── logging-api/ Per-method API guides │ ├── transports/ Per-transport guides + _partials/ │ └── ... Top-level pages (index, intro, configuration, etc.) -├── transport/ BaseTransport / BaseConfig +├── transport/ BaseTransport / BaseConfig / helpers / transporttest ├── transports/ Built-in transports +│ ├── axiom/ Wraps github.com/axiomhq/axiom-go +│ ├── blank/ Delegates to user-supplied function (template for new transports) │ ├── console/ Plain fmt.Println-style │ ├── pretty/ Colorized terminal output (uses fatih/color) │ ├── structured/ JSON-per-line @@ -93,6 +95,9 @@ go test -tags=livetest -race ./transports/otellog/ ./plugins/oteltrace/ cd plugins/datadogtrace/livetest && go test -race ./... ``` +# Axiom: build-tag gated, lives in the transport module (cheap deps). +go test -tags=livetest ./transports/axiom/ + Two patterns are in use, picked by dependency weight: - **Build-tag gating in the main module** (`//go:build livetest`): used @@ -164,6 +169,12 @@ multi-core box. Releases are managed by [monorel](https://monorel.disaresta.com), a changesets-style release tool built specifically for the layout this repo uses (bare `vX.Y.Z` for the root, `/vX.Y.Z` for sub-modules). The release signal is explicit per-PR: `.changeset/.md` files declare which packages release at what bump level. Don't `git tag` manually. +Install monorel once: + +```sh +go install monorel.disaresta.com/cmd/monorel@latest +``` + - **Main module** tags as `v1.X.Y`. Sub-modules tag as `transports/otellog/v1.X.Y`, `plugins/oteltrace/v1.X.Y` (Go module convention). Configured in `monorel.toml` at the repo root. @@ -209,6 +220,8 @@ To add `` (e.g. `transports/foo` or `plugins/bar`): 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). 5. Add the path to `go.work`'s `use` block. + +6. **Important:** For fresh modules (new transports, plugins, or integrations), use `:major` for the initial release to establish v1.0.0. Subsequent releases will bump to `:minor` for new features or `:patch` for bug fixes. 6. Run `bash scripts/foreach-module.sh tidy` to settle indirect deps and `bash scripts/foreach-module.sh test` to confirm. 7. Open the PR. **No release happens from this PR** — `monorel.toml` registers the package but registration alone doesn't trigger a release. 8. Cut the first release in a follow-up PR by adding a changeset: @@ -401,3 +414,12 @@ These exist in upstream loglayer but are not in the Go v1: - Mixins (the `useLogLayerMixin` augmentation pattern) If you're asked to add one of these, propose the design first, do not silently start implementing. + +## Git Workflow + +**Never commit directly to `main`.** Always create a feature branch from current main, work on it, then open a PR that targets main. This applies to all changes: features, bug fixes, docs updates, configuration tweaks. + +The full workflow is in [`.claude/rules/git-workflow.md`](.claude/rules/git-workflow.md): +- Rebase your branch onto current main before opening a PR +- Always run code review (`/superpowers:requesting-code-review`) before committing final work +- Documentation changes need a separate review with framing "act as a senior Go developer encountering this for the first time" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c628d1..a12af06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Thanks for considering a contribution. The essentials: | lefthook | Drives the pre-commit, commit-msg, and pre-push git hooks. | `go install github.com/evilmartians/lefthook@latest` | | staticcheck | Pre-commit lint that mirrors CI. Hook hard-fails without it. | `go install honnef.co/go/tools/cmd/staticcheck@latest` | | Bun | Runs the conventional-commit linter and builds the docs site. The commit-msg hook hard-fails without `bun` + `node_modules`. | | +| monorel | Manages releases across all modules via changesets. Required for creating releases. | `go install monorel.disaresta.com/cmd/monorel@latest` | | govulncheck (optional) | Advisory vuln scan; the SessionStart hook surfaces findings as session context. | `go install golang.org/x/vuln/cmd/govulncheck@latest` | `go install` puts binaries in `$(go env GOPATH)/bin` (default `~/go/bin`). Make sure that directory is on your `PATH`, otherwise the git hooks silently skip when they can't find `lefthook` / `staticcheck`. If only `~/.local/bin` is on your `PATH`, symlink: @@ -163,6 +164,11 @@ for the root, `` for sub-modules (e.g. `transports/zerolog`, `minor`, or `patch`. The body becomes the rendered changelog entry for every package the changeset names. +For new transports, plugins, and integrations that are fresh modules, +the initial release should always be a **major version** (`:major`) to +indicate v1.0.0, following Go module conventions where the first +release establishes the major version number. + Hand-writing the file directly works too; the `monorel add` command is just a generator. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 62f6706..9d1a644 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -160,6 +160,7 @@ gtag('config', '${gaMeasurementId}');`, { text: 'Cloud', items: [ + { text: 'Axiom', link: '/transports/axiom' }, { text: 'Datadog', link: '/transports/datadog' }, { text: 'Google Cloud Logging', link: '/transports/gcplogging' }, { text: 'Sentry', link: '/transports/sentry' }, @@ -168,20 +169,20 @@ gtag('config', '${gaMeasurementId}');`, { text: 'Other Transports', items: [ - { text: 'HTTP', link: '/transports/http' }, { text: 'File (Lumberjack)', link: '/transports/lumberjack' }, + { text: 'HTTP', link: '/transports/http' }, { text: 'OpenTelemetry Logs', link: '/transports/otellog' }, ], }, { text: 'Supported Loggers', items: [ - { text: 'Zerolog', link: '/transports/zerolog' }, - { text: 'Zap', link: '/transports/zap' }, + { text: 'charmbracelet/log', link: '/transports/charmlog' }, { text: 'log/slog', link: '/transports/slog' }, - { text: 'phuslu/log', link: '/transports/phuslu' }, { text: 'logrus', link: '/transports/logrus' }, - { text: 'charmbracelet/log', link: '/transports/charmlog' }, + { text: 'phuslu/log', link: '/transports/phuslu' }, + { text: 'Zap', link: '/transports/zap' }, + { text: 'Zerolog', link: '/transports/zerolog' }, ], }, ], diff --git a/docs/src/transports/_partials/transport-list.md b/docs/src/transports/_partials/transport-list.md index 528b0ce..95e7150 100644 --- a/docs/src/transports/_partials/transport-list.md +++ b/docs/src/transports/_partials/transport-list.md @@ -6,12 +6,12 @@ Self-contained transports that format the entry and write it to an `io.Writer`. | Name | Version | Go Reference | Description | |------|---------|--------------|-------------| +| [Blank](/transports/blank) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/blank/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/blank/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/blank/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/blank/v2) | Delegates dispatch to a user-supplied function. For prototyping or one-off integrations. | +| [CLI](/transports/cli) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/cli/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/cli/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/cli/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/cli/v2) | Tuned for CLI apps: short level prefixes, stdout/stderr routing, TTY-detected color, no timestamps. | +| [Console](/transports/console) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/console/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/console/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/console/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/console/v2) | Plain `fmt.Println`-style output to stdout/stderr; minimal formatting. | | [Pretty](/transports/pretty) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/pretty/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/pretty/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/pretty/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/pretty/v2) | Colorized, theme-aware terminal output. **Recommended for local dev.** | | [Structured](/transports/structured) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/structured/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/structured/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/structured/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/structured/v2) | One JSON object per log entry. Recommended for production. | -| [Console](/transports/console) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/console/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/console/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/console/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/console/v2) | Plain `fmt.Println`-style output to stdout/stderr; minimal formatting. | -| [CLI](/transports/cli) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/cli/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/cli/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/cli/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/cli/v2) | Tuned for CLI apps: short level prefixes, stdout/stderr routing, TTY-detected color, no timestamps. | | [Testing](/transports/testing) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/testing/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/testing/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/testing/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/testing/v2) | Captures entries in memory for tests. | -| [Blank](/transports/blank) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/blank/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/blank/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/blank/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/blank/v2) | Delegates dispatch to a user-supplied function. For prototyping or one-off integrations. | @@ -23,6 +23,7 @@ Managed log services. Async + batched by default; site-aware where applicable. | Name | Version | Go Reference | Description | |------|---------|--------------|-------------| +| [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. | | [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. | @@ -37,8 +38,8 @@ Generic shippers and on-disk sinks. | Name | Version | Go Reference | Description | |------|---------|--------------|-------------| -| [HTTP](/transports/http) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/http/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/http/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/http/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/http/v2) | Generic batched HTTP POST to any endpoint. Pluggable Encoder. | | [File (Lumberjack)](/transports/lumberjack) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/lumberjack/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/lumberjack/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/lumberjack/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/lumberjack/v2) | One JSON object per line written to a rotating file. Backed by `lumberjack.v2`. | +| [HTTP](/transports/http) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/http/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/http/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/http/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/http/v2) | Generic batched HTTP POST to any endpoint. Pluggable Encoder. | | [OpenTelemetry Logs](/transports/otellog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/otellog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/otellog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/otellog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/otellog/v2) | Emits to an OTel `log.Logger`. Forwards `WithContext` so SDK processors can correlate with the active span. | @@ -51,11 +52,11 @@ Transports that hand the entry off to an existing third-party logger you already | Name | Version | Go Reference | Description | |------|---------|--------------|-------------| -| [Zerolog](/transports/zerolog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/zerolog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/zerolog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/zerolog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/zerolog/v2) | Wraps a `*zerolog.Logger` | -| [Zap](/transports/zap) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/zap/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/zap/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/zap/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/zap/v2) | Wraps a `*zap.Logger` | +| [charmbracelet/log](/transports/charmlog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/charmlog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/charmlog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/charmlog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/charmlog/v2) | Pretty terminal-friendly logger from Charm | | [log/slog](/transports/slog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/slog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/slog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/slog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/slog/v2) | Wraps a stdlib `*slog.Logger`. Forwards `WithContext` to handlers. | -| [phuslu/log](/transports/phuslu) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/phuslu/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/phuslu/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/phuslu/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/phuslu/v2) | High-performance zero-alloc JSON logger. Always exits on fatal. | | [logrus](/transports/logrus) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/logrus/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/logrus/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/logrus/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/logrus/v2) | The classic structured logger | -| [charmbracelet/log](/transports/charmlog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/charmlog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/charmlog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/charmlog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/charmlog/v2) | Pretty terminal-friendly logger from Charm | +| [phuslu/log](/transports/phuslu) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/phuslu/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/phuslu/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/phuslu/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/phuslu/v2) | High-performance zero-alloc JSON logger. Always exits on fatal. | +| [Zap](/transports/zap) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/zap/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/zap/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/zap/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/zap/v2) | Wraps a `*zap.Logger` | +| [Zerolog](/transports/zerolog) | [![Version](https://img.shields.io/github/v/tag/loglayer/loglayer-go?filter=transports/zerolog/v*&sort=date&label=version&style=flat-square&color=blue)](https://github.com/loglayer/loglayer-go/releases?q=transports/zerolog/&expanded=true) | [![Go Reference](https://pkg.go.dev/badge/go.loglayer.dev/transports/zerolog/v2.svg)](https://pkg.go.dev/go.loglayer.dev/transports/zerolog/v2) | Wraps a `*zerolog.Logger` | diff --git a/docs/src/transports/axiom.md b/docs/src/transports/axiom.md new file mode 100644 index 0000000..2b9bc24 --- /dev/null +++ b/docs/src/transports/axiom.md @@ -0,0 +1,159 @@ +--- +title: Axiom Transport +description: "Forward LogLayer entries to Axiom via github.com/axiomhq/axiom-go." +--- + +# Axiom Transport + + + +Ships structured logs to [Axiom](https://axiom.co) using the official [axiom-go SDK](https://github.com/axiomhq/axiom-go). The transport constructs a JSON object from each entry and sends it via `Client.Ingest()` as NDJSON. + +Import path: `go.loglayer.dev/transports/axiom/v2`. Package name: `axiom`. + +```sh +go get go.loglayer.dev/transports/axiom/v2 +``` + +## Authenticating + +Axiom authenticates with an API token. You can provide this to the transport in two ways: + +1. **Pass the client directly**: Construct `*axiom.Client` yourself and pass it to the transport. +2. **Use environment variables**: The transport reads `AXIOM_TOKEN` if no client is provided. + +| Env var | Purpose | +|---------|---------| +| `AXIOM_TOKEN` | API token with ingest permission. Used when constructing the client. | +| `AXIOM_ORG_ID` | Organization ID (required for personal tokens). | +| `AXIOM_DATASET` | Dataset name or ID to ingest logs into. | + +### Using environment variables + +```go +import ( + "github.com/axiomhq/axiom-go/axiom" + "go.loglayer.dev/v2" + "go.loglayer.dev/transports/axiom/v2" +) + +// Client picks up AXIOM_TOKEN from the environment +client, err := axiom.NewClient( + axiom.SetAPITokenConfig(os.Getenv("AXIOM_TOKEN")), +) +if err != nil { + panic(err) +} + +log := loglayer.New(loglayer.Config{ + Transport: axiom.New(axiom.Config{ + Client: client, + DatasetName: "my-logs", + }), +}) +``` + +## Basic Usage + +```go +import ( + "context" + + "github.com/axiomhq/axiom-go/axiom" + "go.loglayer.dev/v2" + "go.loglayer.dev/transports/axiom/v2" +) + +ctx := context.Background() +client, err := axiom.NewClient( + axiom.SetAPITokenConfig("your-api-token"), +) +if err != nil { + panic(err) +} + +log := loglayer.New(loglayer.Config{ + Transport: axiom.New(axiom.Config{ + Client: client, + DatasetName: "my-logs", + }), +}) + +log.Info("user signed in") +log.WithMetadata(map[string]any{"userId": 42}).Warn("retry exhausted") +``` + +## Config + +```go +type Config struct { + transport.BaseConfig + + Client *axiom.Client + DatasetName string + MessageField string + OnError func(error) +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `Client` | `*axiom.Client` | (required) | Constructed via `axiom.NewClient()` with authentication options. | +| `DatasetName` | `string` | (required) | Axiom dataset ID or name to ingest logs into. | +| `MessageField` | `string` | `"msg"` | The key under which the joined message text is placed in the JSON object. | +| `OnError` | `func(error)` | stderr | Called when `Client.Ingest()` returns an error. | + +## Payload Shape + +Each log entry is ingested as a JSON object: + +- `msg`: the joined message text (configurable via `MessageField`) +- Persistent fields from `WithFields()`, merged at root +- The serialized error from `WithError()` +- Map metadata flattened at root, or any other metadata nested under `metadata` + +```go +log.WithFields(map[string]any{"requestId": "abc"}). + WithError(errors.New("timeout")). + WithMetadata(map[string]any{"durationMs": 42}). + Info("served") +``` + +results in: + +```json +{ + "msg": "served", + "requestId": "abc", + "err": { "message": "timeout" }, + "durationMs": 42 +} +``` + +## Metadata Handling + +Map metadata (`loglayer.Metadata`) merges at the root of the JSON object. Non-map metadata (structs, scalars) nests under the `metadata` key by default. + +Set [`Config.MetadataFieldName`](/configuration#metadatafieldname) on the core to nest all metadata under a fixed key. + +## Level Mapping + +LogLayer levels map directly to Axiom's expected level strings: + +| LogLayer Level | Axiom Level | +|----------------|-------------| +| `LogLevelTrace` | `"trace"` | +| `LogLevelDebug` | `"debug"` | +| `LogLevelInfo` | `"info"` | +| `LogLevelWarn` | `"warn"` | +| `LogLevelError` | `"error"` | +| `LogLevelFatal` | `"fatal"` | +| `LogLevelPanic` | `"panic"` | + +## GetLoggerInstance + +`Transport.GetLoggerInstance()` returns the underlying `*axiom.Client`, useful for SDK features not exposed by the transport. + +```go +underlying := log.GetLoggerInstance(transportID).(*axiom.Client) +``` diff --git a/docs/src/whats-new.md b/docs/src/whats-new.md index 324bd4a..5ff6a97 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 06, 2026 + +`transports/axiom`: + +Initial release. New [Axiom transport](/transports/axiom). + ## May 03, 2026 `v2.1.0`: diff --git a/go.work b/go.work index 57059c6..2f90d7c 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,7 @@ go 1.25.0 use ( . + ./transports/axiom ./transports/blank ./transports/central ./transports/charmlog diff --git a/monorel.toml b/monorel.toml index bf8c30c..4ac861e 100644 --- a/monorel.toml +++ b/monorel.toml @@ -14,6 +14,16 @@ tag_prefix = "" path = "." changelog = "CHANGELOG.md" +[packages."transports/axiom"] +tag_prefix = "transports/axiom" +path = "transports/axiom" +changelog = "transports/axiom/CHANGELOG.md" + +[packages."transports/blank"] +tag_prefix = "transports/blank" +path = "transports/blank" +changelog = "transports/blank/CHANGELOG.md" + [packages."transports/charmlog"] tag_prefix = "transports/charmlog" path = "transports/charmlog" @@ -24,6 +34,11 @@ tag_prefix = "transports/cli" path = "transports/cli" changelog = "transports/cli/CHANGELOG.md" +[packages."transports/console"] +tag_prefix = "transports/console" +path = "transports/console" +changelog = "transports/console/CHANGELOG.md" + [packages."transports/datadog"] tag_prefix = "transports/datadog" path = "transports/datadog" @@ -34,11 +49,6 @@ tag_prefix = "transports/gcplogging" path = "transports/gcplogging" changelog = "transports/gcplogging/CHANGELOG.md" -[packages."transports/lumberjack"] -tag_prefix = "transports/lumberjack" -path = "transports/lumberjack" -changelog = "transports/lumberjack/CHANGELOG.md" - [packages."transports/http"] tag_prefix = "transports/http" path = "transports/http" @@ -49,6 +59,11 @@ tag_prefix = "transports/logrus" path = "transports/logrus" changelog = "transports/logrus/CHANGELOG.md" +[packages."transports/lumberjack"] +tag_prefix = "transports/lumberjack" +path = "transports/lumberjack" +changelog = "transports/lumberjack/CHANGELOG.md" + [packages."transports/otellog"] tag_prefix = "transports/otellog" path = "transports/otellog" @@ -69,6 +84,21 @@ tag_prefix = "transports/sentry" path = "transports/sentry" changelog = "transports/sentry/CHANGELOG.md" +[packages."transports/slog"] +tag_prefix = "transports/slog" +path = "transports/slog" +changelog = "transports/slog/CHANGELOG.md" + +[packages."transports/structured"] +tag_prefix = "transports/structured" +path = "transports/structured" +changelog = "transports/structured/CHANGELOG.md" + +[packages."transports/testing"] +tag_prefix = "transports/testing" +path = "transports/testing" +changelog = "transports/testing/CHANGELOG.md" + [packages."transports/zap"] tag_prefix = "transports/zap" path = "transports/zap" @@ -79,20 +109,25 @@ tag_prefix = "transports/zerolog" path = "transports/zerolog" changelog = "transports/zerolog/CHANGELOG.md" +[packages."plugins/datadogtrace"] +tag_prefix = "plugins/datadogtrace" +path = "plugins/datadogtrace" +changelog = "plugins/datadogtrace/CHANGELOG.md" + +[packages."plugins/fmtlog"] +tag_prefix = "plugins/fmtlog" +path = "plugins/fmtlog" +changelog = "plugins/fmtlog/CHANGELOG.md" + [packages."plugins/oteltrace"] tag_prefix = "plugins/oteltrace" path = "plugins/oteltrace" changelog = "plugins/oteltrace/CHANGELOG.md" -[packages."transports/blank"] -tag_prefix = "transports/blank" -path = "transports/blank" -changelog = "transports/blank/CHANGELOG.md" - -[packages."transports/slog"] -tag_prefix = "transports/slog" -path = "transports/slog" -changelog = "transports/slog/CHANGELOG.md" +[packages."plugins/plugintest"] +tag_prefix = "plugins/plugintest" +path = "plugins/plugintest" +changelog = "plugins/plugintest/CHANGELOG.md" [packages."plugins/redact"] tag_prefix = "plugins/redact" @@ -104,16 +139,6 @@ tag_prefix = "plugins/sampling" path = "plugins/sampling" changelog = "plugins/sampling/CHANGELOG.md" -[packages."plugins/fmtlog"] -tag_prefix = "plugins/fmtlog" -path = "plugins/fmtlog" -changelog = "plugins/fmtlog/CHANGELOG.md" - -[packages."plugins/datadogtrace"] -tag_prefix = "plugins/datadogtrace" -path = "plugins/datadogtrace" -changelog = "plugins/datadogtrace/CHANGELOG.md" - [packages."integrations/loghttp"] tag_prefix = "integrations/loghttp" path = "integrations/loghttp" @@ -123,23 +148,3 @@ changelog = "integrations/loghttp/CHANGELOG.md" tag_prefix = "integrations/sloghandler" path = "integrations/sloghandler" changelog = "integrations/sloghandler/CHANGELOG.md" - -[packages."transports/console"] -tag_prefix = "transports/console" -path = "transports/console" -changelog = "transports/console/CHANGELOG.md" - -[packages."transports/structured"] -tag_prefix = "transports/structured" -path = "transports/structured" -changelog = "transports/structured/CHANGELOG.md" - -[packages."transports/testing"] -tag_prefix = "transports/testing" -path = "transports/testing" -changelog = "transports/testing/CHANGELOG.md" - -[packages."plugins/plugintest"] -tag_prefix = "plugins/plugintest" -path = "plugins/plugintest" -changelog = "plugins/plugintest/CHANGELOG.md" diff --git a/scripts/foreach-module.sh b/scripts/foreach-module.sh index 7f908dc..19e8ca2 100755 --- a/scripts/foreach-module.sh +++ b/scripts/foreach-module.sh @@ -30,6 +30,7 @@ fi # sub-modules. Don't reorder without a reason. ALL_MODULES=( . + transports/axiom transports/blank transports/central transports/charmlog @@ -71,6 +72,7 @@ ALL_MODULES=( # of example code shouldn't gate the build. SHIPPED_MODULES=( . + transports/axiom transports/blank transports/central transports/charmlog @@ -102,7 +104,7 @@ SHIPPED_MODULES=( op="${1:-}" if [ -z "$op" ]; then - cat >&2 <&2 < Ops: tidy go mod tidy + diff check (all modules) @@ -115,7 +117,7 @@ Usage: $0 The 'all modules' set is: ${ALL_MODULES[*]} The 'shipped modules' set is: ${SHIPPED_MODULES[*]} -EOF +USAGE_EOF exit 2 fi @@ -123,9 +125,6 @@ case "$op" in tidy) # Run `go mod tidy` across every module first so a single invocation # cleans up *all* drift, then do one repo-wide diff check at the end. - # The earlier per-module diff fail-fast pattern made this script a - # poor pre-push hook: each run only ever found the first drifted - # module, so multi-module drift took multiple iterations to surface. for mod in "${ALL_MODULES[@]}"; do echo "==> $mod (tidy)" (cd "$mod" && go mod tidy) @@ -164,6 +163,7 @@ case "$op" in # "[no test files]" output. Every other module has tests. TEST_MODULES=( . + transports/axiom transports/blank transports/central transports/charmlog diff --git a/transports/axiom/CHANGELOG.md b/transports/axiom/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/transports/axiom/axiom.go b/transports/axiom/axiom.go new file mode 100644 index 0000000..6452b53 --- /dev/null +++ b/transports/axiom/axiom.go @@ -0,0 +1,142 @@ +// Package axiom provides a LogLayer transport backed by a +// caller-supplied [axiom.Client] from github.com/axiomhq/axiom-go. +// +// The user owns the client lifecycle (authentication, base URL); this +// transport assembles an NDJSON entry from the params and dispatches it +// via Client.Ingest(). +// +// # Payload shape +// +// Each log entry is sent as a JSON object with: +// +// - the joined message text under Config.MessageField (default "msg"); +// - all persistent fields and the serialized error from params.Data +// merged at root; +// - map metadata flattened at root, or any other metadata nested +// under params.Schema.MetadataFieldName (or "metadata" by default). +// +// See https://go.loglayer.dev for usage guides and the full API reference. +package axiom + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/axiomhq/axiom-go/axiom" + + "go.loglayer.dev/v2" + "go.loglayer.dev/v2/transport" +) + +// Config holds configuration options for the Axiom transport. +type Config struct { + transport.BaseConfig + + // Client is the underlying *axiom.Client from github.com/axiomhq/axiom-go. + // Required. Construct via axiom.NewClient() or with options. + Client *axiom.Client + + // DatasetName is the ID or name of the Axiom dataset to ingest logs into. + // Required. + DatasetName string + + // MessageField is the key under which the joined message text is + // placed in the JSON object. Defaults to "msg". + MessageField string + + // OnError is called when Client.Ingest returns an error. + // The default writes a one-line message to os.Stderr. + OnError func(error) +} + +// Transport ships log entries to an Axiom dataset via Client.Ingest(). +type Transport struct { + transport.BaseTransport + cfg Config +} + +// New constructs an Axiom Transport. Panics if cfg.Client is nil or +// cfg.DatasetName 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 Transport like New but returns errors instead of panicking. +func Build(cfg Config) (*Transport, error) { + if cfg.Client == nil { + return nil, ErrClientRequired + } + if cfg.DatasetName == "" { + return nil, ErrDatasetNameRequired + } + if cfg.MessageField == "" { + cfg.MessageField = "msg" + } + return &Transport{ + BaseTransport: transport.NewBaseTransport(cfg.BaseConfig), + cfg: cfg, + }, nil +} + +// GetLoggerInstance returns the underlying *axiom.Client. +func (t *Transport) GetLoggerInstance() any { return t.cfg.Client } + +// SendToLogger implements loglayer.Transport. +func (t *Transport) SendToLogger(params loglayer.TransportParams) { + if !t.ShouldProcess(params.LogLevel) { + return + } + + params.Messages = transport.JoinPrefixAndMessages(params.Prefix, params.Messages) + entry := t.buildEntry(params) + buf, err := json.Marshal(entry) + if err != nil { + t.reportError(fmt.Errorf("marshal entry: %w", err)) + return + } + + ctx := params.Ctx + if ctx == nil { + ctx = context.Background() + } + + reader := io.NopCloser(bytes.NewReader(buf)) + if _, err := t.cfg.Client.Ingest(ctx, t.cfg.DatasetName, reader, axiom.NDJSON, axiom.Identity); err != nil { + t.reportError(err) + } +} + +// buildEntry assembles a JSON object from TransportParams + Config. +func (t *Transport) buildEntry(params loglayer.TransportParams) map[string]any { + payload := make(map[string]any, transport.FieldEstimate(params)+1) + payload[t.cfg.MessageField] = transport.JoinMessages(params.Messages) + + // Merge params.Data (persistent fields + error) into payload + for k, v := range params.Data { + payload[k] = v + } + + transport.MergeIntoMap(payload, nil, params.Metadata, params.Schema.MetadataFieldName) + return payload +} + +func (t *Transport) reportError(err error) { + if t.cfg.OnError != nil { + t.cfg.OnError(err) + return + } + fmt.Fprintf(os.Stderr, "loglayer/transports/axiom: %v\n", err) +} + +// Close implements io.Closer for use with AddTransport / RemoveTransport +// lifecycle management. The Axiom client handles its own buffering and +// flushes on each Ingest call, so this is a no-op. +func (t *Transport) Close() error { return nil } diff --git a/transports/axiom/axiom_test.go b/transports/axiom/axiom_test.go new file mode 100644 index 0000000..aa26fa3 --- /dev/null +++ b/transports/axiom/axiom_test.go @@ -0,0 +1,271 @@ +package axiom + +import ( + "errors" + "testing" + + "github.com/axiomhq/axiom-go/axiom" + + "go.loglayer.dev/v2" + "go.loglayer.dev/v2/transport" +) + +func TestBuild_NilClientReturnsError(t *testing.T) { + _, err := Build(Config{}) + if !errors.Is(err, ErrClientRequired) { + t.Errorf("got %v, want ErrClientRequired", err) + } +} + +func TestNew_NilClientPanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic") + } + err, ok := r.(error) + if !ok || !errors.Is(err, ErrClientRequired) { + t.Errorf("panic value: got %v, want ErrClientRequired", r) + } + }() + New(Config{}) +} + +func TestBuild_EmptyDatasetNameReturnsError(t *testing.T) { + _, err := Build(Config{ + Client: &axiom.Client{}, + }) + if !errors.Is(err, ErrDatasetNameRequired) { + t.Errorf("got %v, want ErrDatasetNameRequired", err) + } +} + +func TestNew_EmptyDatasetNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic") + } + err, ok := r.(error) + if !ok || !errors.Is(err, ErrDatasetNameRequired) { + t.Errorf("panic value: got %v, want ErrDatasetNameRequired", r) + } + }() + New(Config{ + Client: &axiom.Client{}, + }) +} + +// fakeClient is a non-nil *axiom.Client so Build's nil-check passes. +// Only buildEntry is exercised below; Ingest paths that would dereference +// Client are not invoked in unit tests. +var fakeClient = &axiom.Client{} + +func TestBuildEntry_PayloadShape(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + params := loglayer.TransportParams{ + LogLevel: loglayer.LogLevelInfo, + Messages: []any{"served"}, + Data: loglayer.Data{"requestId": "abc"}, + Metadata: loglayer.Metadata{"durationMs": 42}, + } + entry := tr.buildEntry(params) + + if entry["msg"] != "served" { + t.Errorf("msg: got %v, want %q", entry["msg"], "served") + } + if entry["requestId"] != "abc" { + t.Errorf("requestId: got %v, want %q", entry["requestId"], "abc") + } + if entry["durationMs"] != 42 { + t.Errorf("durationMs: got %v, want 42", entry["durationMs"]) + } +} + +func TestBuildEntry_CustomMessageField(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + MessageField: "message", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + entry := tr.buildEntry(loglayer.TransportParams{ + LogLevel: loglayer.LogLevelInfo, + Messages: []any{"hello"}, + }) + if entry["message"] != "hello" { + t.Errorf("message: got %v, want %q", entry["message"], "hello") + } + if _, exists := entry["msg"]; exists { + t.Error("default 'msg' key should not be set when MessageField is overridden") + } +} + +func TestBuildEntry_MergeFieldsAndMetadata(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + params := loglayer.TransportParams{ + LogLevel: loglayer.LogLevelWarn, + Messages: []any{"warning"}, + Data: loglayer.Data{"field1": "a", "field2": 123}, + Metadata: loglayer.Metadata{"meta1": true, "meta2": "b"}, + } + entry := tr.buildEntry(params) + + if entry["msg"] != "warning" { + t.Errorf("msg: got %v, want %q", entry["msg"], "warning") + } + if entry["field1"] != "a" { + t.Errorf("field1: got %v, want %q", entry["field1"], "a") + } + if entry["field2"] != 123 { + t.Errorf("field2: got %v, want 123", entry["field2"]) + } + if entry["meta1"] != true { + t.Errorf("meta1: got %v, want true", entry["meta1"]) + } + if entry["meta2"] != "b" { + t.Errorf("meta2: got %v, want 'b'", entry["meta2"]) + } +} + +func TestBuildEntry_NonMapMetadataNestsUnderKey(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + entry := tr.buildEntry(loglayer.TransportParams{ + LogLevel: loglayer.LogLevelInfo, + Messages: []any{"x"}, + Metadata: 42, // scalar, not a map + }) + if entry["metadata"] != 42 { + t.Errorf("scalar metadata should nest under 'metadata': got %v", entry["metadata"]) + } +} + +func TestBuildEntry_MetadataFieldNameNests(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + params := loglayer.TransportParams{ + LogLevel: loglayer.LogLevelInfo, + Messages: []any{"x"}, + Metadata: loglayer.Metadata{"k": "v"}, + Schema: loglayer.Schema{MetadataFieldName: "customMeta"}, + } + entry := tr.buildEntry(params) + + if entry["customMeta"] == nil { + t.Fatal("metadata should be nested under customMeta") + } + // Check for either Metadata or map[string]any type since the interface + // stores the actual type. + switch m := entry["customMeta"].(type) { + case loglayer.Metadata: + if m["k"] != "v" { + t.Errorf("nested metadata: got %v, want k=v", m) + } + case map[string]any: + if m["k"] != "v" { + t.Errorf("nested metadata: got %v, want k=v", m) + } + default: + t.Errorf("nested metadata type: got %T, want Metadata or map[string]any", entry["customMeta"]) + } +} + +func TestBuildEntry_WithPrefix(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + params := loglayer.TransportParams{ + LogLevel: loglayer.LogLevelInfo, + Prefix: "[web]", + Messages: []any{"request completed"}, + Data: loglayer.Data{}, + Metadata: nil, + Schema: loglayer.Schema{}, + } + // Apply prefix before calling buildEntry (simulating what SendToLogger does) + params.Messages = transport.JoinPrefixAndMessages(params.Prefix, params.Messages) + + entry := tr.buildEntry(params) + + if entry["msg"] != "[web] request completed" { + t.Errorf("msg with prefix: got %q, want %q", entry["msg"], "[web] request completed") + } +} + +func TestBuildEntry_WithError(t *testing.T) { + tr, err := Build(Config{ + Client: fakeClient, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + params := loglayer.TransportParams{ + LogLevel: loglayer.LogLevelError, + Messages: []any{"connection failed"}, + Data: loglayer.Data{"err": map[string]any{"message": "connection refused"}}, + Metadata: nil, + } + params.Err = errors.New("connection refused") // kept for compatibility + + entry := tr.buildEntry(params) + + if entry["msg"] != "connection failed" { + t.Errorf("msg: got %q", entry["msg"]) + } + if val, ok := entry["err"]; !ok || val == nil { + t.Errorf("err field should be populated") + } else if m, ok := val.(map[string]any); !ok || m["message"] != "connection refused" { + t.Errorf("err value: got %v", val) + } +} + +func TestGetLoggerInstance(t *testing.T) { + client := &axiom.Client{} + tr, err := Build(Config{ + Client: client, + DatasetName: "testlogs", + }) + if err != nil { + t.Fatalf("Build: %v", err) + } + + instance := tr.GetLoggerInstance() + if instance != client { + t.Errorf("GetLoggerInstance: got %p, want %p", instance, client) + } +} diff --git a/transports/axiom/errors.go b/transports/axiom/errors.go new file mode 100644 index 0000000..d531730 --- /dev/null +++ b/transports/axiom/errors.go @@ -0,0 +1,13 @@ +package axiom + +import "errors" + +// ErrClientRequired is returned by Build (and panicked by New) when +// Config.Client is nil. The user supplies the Axiom client; the transport +// never constructs one itself, so a nil client can't be defaulted. +var ErrClientRequired = errors.New("loglayer/transports/axiom: Config.Client is required") + +// ErrDatasetNameRequired is returned by Build (and panicked by New) when +// Config.DatasetName is empty. The dataset ID/name is required to know +// where to ingest logs. +var ErrDatasetNameRequired = errors.New("loglayer/transports/axiom: Config.DatasetName is required") diff --git a/transports/axiom/example_test.go b/transports/axiom/example_test.go new file mode 100644 index 0000000..ed1da7a --- /dev/null +++ b/transports/axiom/example_test.go @@ -0,0 +1,45 @@ +package axiom + +import ( + "go.loglayer.dev/v2" +) + +// ExampleNew shows a basic usage pattern with the Axiom transport. The client +// is constructed via axiom.NewClient() which reads configuration from +// environment variables (AXIOM_TOKEN, AXIOM_ORG_ID). +func ExampleNew() { + // axiomClient would be created here in real code: + // client, err := axiom.NewClient() + var axiomClient any + _ = axiomClient + + t, err := Build(Config{ + Client: nil, + DatasetName: "my-logs", + }) + if err != nil { + return + } + + _ = loglayer.New(loglayer.Config{ + Transport: t, + DisableFatalExit: true, + }) +} + +// ExampleConfig_MessageField shows how to customize the message field. +func ExampleConfig_MessageField() { + t, err := Build(Config{ + Client: nil, + DatasetName: "my-logs", + MessageField: "message", + }) + if err != nil { + return + } + + _ = loglayer.New(loglayer.Config{ + Transport: t, + DisableFatalExit: true, + }) +} diff --git a/transports/axiom/go.mod b/transports/axiom/go.mod new file mode 100644 index 0000000..cddaaad --- /dev/null +++ b/transports/axiom/go.mod @@ -0,0 +1,26 @@ +module go.loglayer.dev/transports/axiom/v2 + +go 1.25.0 + +replace go.loglayer.dev => ../.. + +require ( + github.com/axiomhq/axiom-go v0.32.0 + go.loglayer.dev/v2 v2.0.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect +) diff --git a/transports/axiom/go.sum b/transports/axiom/go.sum new file mode 100644 index 0000000..2d17142 --- /dev/null +++ b/transports/axiom/go.sum @@ -0,0 +1,82 @@ +github.com/axiomhq/axiom-go v0.32.0 h1:aRpbqUAn01hY8aJXQftvWHyXfnrNB2KzN5ZquBWvFcE= +github.com/axiomhq/axiom-go v0.32.0/go.mod h1:3Gmr5M4tINm7Ti00GVfzAduO92Uhd0pghr4ZehIhFxc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +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.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= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/axiom/livetest_test.go b/transports/axiom/livetest_test.go new file mode 100644 index 0000000..0ea9aa1 --- /dev/null +++ b/transports/axiom/livetest_test.go @@ -0,0 +1,64 @@ +//go:build livetest + +// Live test against the real Axiom API. Compiled only with `-tags=livetest` +// so normal `go test ./...` runs ignore it. +// +// Run: +// +// AXIOM_TOKEN= AXIOM_DATASET= go test -tags=livetest -v -run TestLive_Axiom ./transports/axiom/ +// +// The test ingests a variety of log entries to the specified dataset. Check +// Axiom's Logs Explorer to verify they arrived (indexing typically takes +// 5-60 seconds). + +package axiom + +import ( + "os" + "testing" + + "github.com/axiomhq/axiom-go/axiom" + "go.loglayer.dev/v2/transport" + "go.loglayer.dev/v2/transport/transporttest" +) + +func TestLive_Axiom_SendsLog(t *testing.T) { + token := os.Getenv("AXIOM_TOKEN") + if token == "" { + t.Skip("AXIOM_TOKEN not set; skipping live Axiom test") + } + + dataset := os.Getenv("AXIOM_DATASET") + if dataset == "" { + t.Skip("AXIOM_DATASET not set; skipping live Axiom test") + } + + client, err := axiom.NewClient( + axiom.SetAPITokenConfig(token), + ) + if err != nil { + t.Fatalf("Failed to create Axiom client: %v", err) + } + + tr, err := Build(Config{ + BaseConfig: transport.BaseConfig{ID: "axiom"}, + Client: client, + DatasetName: dataset, + }) + if err != nil { + t.Fatalf("Failed to build transport: %v", err) + } + + baseID := "livetest-" + t.Name() + sentIDs := transporttest.SendLivetestVariants(tr, baseID) + + for i, v := range transporttest.LivetestVariants { + t.Logf(" %s: livetest_id:%s", v.Name, sentIDs[i]) + } + + if err := tr.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + t.Logf("Sent livetest entries to Axiom dataset %q.", dataset) +}