diff --git a/.cspell.json b/.cspell.json
index 76310037..d2a8c360 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -7,7 +7,10 @@
"node_modules/**"
],
"overrides": [],
- "ignoreRegExpList": [],
+ "ignoreRegExpList": [
+ "/\\b[A-Za-z]+'[A-Za-z]+\\b/g",
+ "/\\\\e\\[[0-9;]*m/g"
+ ],
"useGitignore": true,
"dictionaryDefinitions": [
{
@@ -23,7 +26,6 @@
],
"dictionaries": [
"project-words",
- "lockfile-words-root",
- "lockfile-words-docs"
+ "lockfile-words-root"
]
}
diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt
index 98a0a50e..a292a391 100644
--- a/.cspell/project-words.txt
+++ b/.cspell/project-words.txt
@@ -1,4 +1,7 @@
+activemodelserializers
ankane
+apikey
+apikeys
appender
appenders
autorun
@@ -7,23 +10,26 @@ binstub
binstubs
Bugsnag
bytesize
+byteslice
carrierwave
codegen
creds
+crypted
dalli
defn
demodulize
docspring
dotenv
duplicable
-Dotenv
ESRCH
Fanout
favicons
+FFFD
gettime
goodjob
gosu
Healthcheck
+HMAC
Honeybadger
hostnames
hotwire
@@ -31,15 +37,21 @@ hrefs
instrumenter
Itest
ivar
+justinko
+jwts
kamal
klass
kwargs
+langalex
lightningcss
lockfiles
+logcraft
logdev
logfield
+logstasher
Logstop
LogStruct
+Luhn
metaprogramming
mswin
nilability
@@ -47,11 +59,13 @@ nilable
nonblock
nonblocking
optparse
+otps
parseable
passw
pentester
pentesters
popen
+Procfile
procs
progname
railtie
@@ -62,6 +76,7 @@ reraise
reraises
reraising
Rescuable
+Resque
resultset
Rollbar
rsig
@@ -69,22 +84,15 @@ RUBYGEMS
shadcn
shiki
Sidekiq
+signm
+SIGUSR
solargraph
ssns
TESTOPTS
testtask
+tsort
+unwritable
vercel
wordlists
xorshift
yardoc
-tsort
-HMAC
-otps
-crypted
-jwts
-Luhn
-byteslice
-unwritable
-FFFD
-signm
-SIGUSR
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 305b0bc4..0aca1800 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -42,7 +42,7 @@ jobs:
echo "sha=${run#* }" >> "$GITHUB_OUTPUT"
- name: Checkout the commit that CI run tested
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.ci_run.outputs.sha }}
persist-credentials: false
@@ -59,7 +59,7 @@ jobs:
version: 10.18.3
- name: Setup Node with the pnpm cache
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: 'pnpm'
@@ -70,12 +70,12 @@ jobs:
cd docs && pnpm install --frozen-lockfile
- name: Setup Pages
- uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4.0.0
+ uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
with:
static_docs_generator: next
- name: Download that run's coverage report
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: coverage-ruby-rails-latest
path: docs/public/coverage/
@@ -94,7 +94,7 @@ jobs:
run: cd docs && pnpm run build
- name: Upload artifact
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ./docs/out
@@ -113,4 +113,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
diff --git a/.github/workflows/rails-main.yml b/.github/workflows/rails-main.yml
index e11cdfa1..93857791 100644
--- a/.github/workflows/rails-main.yml
+++ b/.github/workflows/rails-main.yml
@@ -15,7 +15,7 @@ jobs:
env:
RUBY_VERSION: '4.0.1'
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ddc3ed29..68bc9c6c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,13 +25,43 @@ env:
TAG_NAME: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
jobs:
+ # Every other job needs this one, so a tag that doesn't match version.rb publishes nothing
wait_ci:
- name: Wait for CI success on tag
+ name: Validate the tag and wait for CI success
runs-on: ubuntu-latest
permissions:
contents: read
actions: read # to read the CI runs for the tagged commit
+ outputs:
+ version: ${{ steps.ver.outputs.version_tag }}
steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Determine version and validate tag
+ id: ver
+ shell: bash
+ run: |
+ TAG="$TAG_NAME"
+ if [[ -z "$TAG" ]]; then
+ TAG="v0.0.0-dryrun"
+ echo "DRY-RUN: Using placeholder tag $TAG" >&2
+ fi
+ VERSION_FROM_TAG="${TAG#v}"
+ VERSION_RB=$(sed -n 's/^ *VERSION *= *"\([^"]*\)".*/\1/p' lib/log_struct/version.rb)
+ echo "version_tag=$VERSION_FROM_TAG" >> "$GITHUB_OUTPUT"
+ echo "Resolved tag: $TAG (version $VERSION_FROM_TAG); code version: $VERSION_RB"
+ if [[ -z "$VERSION_RB" ]]; then
+ echo "::error::Couldn't read VERSION from lib/log_struct/version.rb."
+ exit 1
+ fi
+ if [[ "$DRY_RUN" != "true" && "$VERSION_FROM_TAG" != "$VERSION_RB" ]]; then
+ echo "::error::Tag version ($VERSION_FROM_TAG) does not match lib/log_struct/version.rb ($VERSION_RB). Update version.rb before tagging, or run as dry-run."
+ exit 1
+ fi
+
- name: Wait for CI workflow
env:
GH_TOKEN: ${{ github.token }}
@@ -76,7 +106,7 @@ jobs:
gem_file: ${{ steps.build.outputs.gem_file }}
steps:
- name: Checkout
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -87,28 +117,10 @@ jobs:
with:
ruby-version: '.ruby-version'
- - name: Determine version and validate tag
- id: ver
- shell: bash
- run: |
- TAG="$TAG_NAME"
- if [[ -z "$TAG" ]]; then
- TAG="v0.0.0-dryrun"
- echo "DRY-RUN: Using placeholder tag $TAG" >&2
- fi
- VERSION_FROM_TAG="${TAG#v}"
- VERSION_RB=$(ruby -e 'print File.read("lib/log_struct/version.rb")[/VERSION\s*=\s*"([^"]+)"/,1]')
- echo "version_tag=$VERSION_FROM_TAG" >> "$GITHUB_OUTPUT"
- echo "Resolved tag: $TAG (version $VERSION_FROM_TAG); code version: $VERSION_RB"
- if [[ "$DRY_RUN" != "true" && "$VERSION_FROM_TAG" != "$VERSION_RB" ]]; then
- echo "::error::Tag version ($VERSION_FROM_TAG) does not match lib/log_struct/version.rb ($VERSION_RB). Update version.rb before tagging, or run as dry-run."
- exit 1
- fi
-
- name: Build gem
id: build
env:
- VERSION: ${{ steps.ver.outputs.version_tag }}
+ VERSION: ${{ needs.wait_ci.outputs.version }}
run: |
gem build logstruct.gemspec
ls -la ./*.gem
@@ -129,7 +141,7 @@ jobs:
echo "gem_file=$GEM_FILE" >> "$GITHUB_OUTPUT"
- name: Upload the gem
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: gem
path: ${{ steps.build.outputs.gem_file }}
@@ -153,7 +165,7 @@ jobs:
id-token: write # RubyGems trusted publishing: exchanges this job's OIDC token for a short-lived API key
steps:
- name: Download the gem
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: gem
@@ -176,7 +188,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -203,7 +215,7 @@ jobs:
run: ruby scripts/export_provider_catalog.rb
- name: Setup Go
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.22'
cache: false
@@ -229,7 +241,7 @@ jobs:
fi
- name: Upload the provider changes
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: provider-changes
path: provider-changes.patch
@@ -244,7 +256,7 @@ jobs:
environment: release
steps:
- name: Download the provider changes
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: provider-changes
diff --git a/.github/workflows/terraform-example.yml b/.github/workflows/terraform-example.yml
index 6cb7eb24..5b59bb03 100644
--- a/.github/workflows/terraform-example.yml
+++ b/.github/workflows/terraform-example.yml
@@ -18,11 +18,11 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Terraform
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
+ uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.7.5
- name: Terraform Init (example)
@@ -30,7 +30,7 @@ jobs:
- name: Terraform Validate (example)
run: terraform -chdir=terraform-example validate
- name: Setup TFLint
- uses: terraform-linters/setup-tflint@90f302c255ef959cbfb4bd10581afecdb7ece3e6 # v4.1.1
+ uses: terraform-linters/setup-tflint@6ffdbaa3be476b3431f7275c26966ef32ff60337 # v6.3.2
- name: TFLint (core rules only)
run: |
# TFLint >= 0.47 removed positional directory args; use --chdir
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bd052451..8177c7eb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,7 +15,7 @@ jobs:
name: Lint
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -27,7 +27,7 @@ jobs:
bundler-cache: true
- name: Setup Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
@@ -37,7 +37,7 @@ jobs:
version: 10.18.3
- name: Enable pnpm cache
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -101,7 +101,7 @@ jobs:
name: 'Next.js Site Tests'
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -112,7 +112,7 @@ jobs:
bundler-cache: true
- name: Setup Node.js (docs)
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
@@ -122,7 +122,7 @@ jobs:
version: 10.18.3
- name: Enable pnpm cache (docs)
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -150,7 +150,7 @@ jobs:
needs: [pilot]
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
version: 10.18.3
- name: Setup Node with the pnpm cache
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -176,7 +176,7 @@ jobs:
run: cd docs && pnpm install --frozen-lockfile
- name: Download this run's coverage report
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: coverage-ruby-rails-latest
path: docs/public/coverage/
@@ -190,6 +190,12 @@ jobs:
- name: Build the site
run: cd docs && pnpm run build
+ - name: Install Chromium for the site's browser tests
+ run: cd docs && pnpm exec playwright install --with-deps chromium
+
+ - name: Run the site's browser tests
+ run: cd docs && pnpm run e2e
+
pilot:
name: 'Pilot Test: Latest Ruby / Rails'
runs-on: ubuntu-22.04
@@ -199,7 +205,7 @@ jobs:
CI: 'true'
BUNDLE_WITHOUT: 'development'
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -222,7 +228,7 @@ jobs:
run: scripts/rails_tests.sh
- name: Upload raw coverage directories
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: success()
with:
name: raw-coverage-ruby-rails-latest
@@ -241,7 +247,7 @@ jobs:
# Save merged coverage data as an artifact for deploy workflow
- name: Upload coverage reports
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: success()
with:
name: coverage-ruby-rails-latest
@@ -282,7 +288,7 @@ jobs:
BUNDLE_WITHOUT: 'development'
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -307,7 +313,7 @@ jobs:
needs: [lint]
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
diff --git a/AGENTS.md b/AGENTS.md
index e3ebe828..fefd2a7e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,7 +85,8 @@ For template file changes (tests, rake tasks, etc.), just run `scripts/rails_tes
- Build gem: `task gem:build`
- Install gem locally: `task gem:install`
- Install gem without network: `task gem:install:local`
-- Release gem to RubyGems: `task gem:release`
+- Release: `task gem:release` tags the version in `lib/log_struct/version.rb` on `main` and pushes the tag. The
+ Release workflow publishes it (below). `bundle exec rake release` refuses: the gem is never pushed from a laptop.
## Terraform Provider repo in this workspace
@@ -100,13 +101,18 @@ For template file changes (tests, rake tasks, etc.), just run `scripts/rails_tes
- Workflow: `.github/workflows/release.yml` ("Release Gem + Sync Terraform Provider").
- Triggers:
- - Push tag matching `v*` (e.g., `v0.0.1-rc1`, `v0.2.0`).
- - GitHub Release published.
+ - Push tag matching `v*` (e.g., `v0.0.1-rc1`, `v0.2.0`). Only admins can push `v*` tags (the "Release tags: admins
+ only" ruleset).
- Manual run (workflow_dispatch) with `dry_run` input.
- Behavior:
- - Builds and publishes the Ruby gem to RubyGems (requires `RUBYGEMS_API_KEY`).
- - Regenerates the provider’s embedded catalog (`scripts/export_provider_catalog.rb`), builds the provider, commits catalog changes, and tags the provider repo with the same version.
- - Enforces version alignment: the tag `vX.Y.Z` (or RC) must match `lib/log_struct/version.rb` unless run in dry-run.
+ - Waits for CI to pass on the tagged commit, and checks that the tag `vX.Y.Z` (or RC) matches
+ `lib/log_struct/version.rb` (unless run in dry-run).
+ - Builds the gem, and publishes it with RubyGems trusted publishing (an OIDC token exchanged for a short-lived API
+ key). There's no RubyGems API key in the repository.
+ - Regenerates the provider’s embedded catalog (`scripts/export_provider_catalog.rb`), builds the provider, commits
+ catalog changes, and tags the provider repo with the same version.
+ - The publishing jobs run in the `release` environment, which needs an approval, and don't run the repository's
+ code: they get the built gem and the provider changes from the jobs before them.
## Dry-run mode
@@ -121,12 +127,14 @@ For template file changes (tests, rake tasks, etc.), just run `scripts/rails_tes
## Required secrets
-- `RUBYGEMS_API_KEY`: API key with permission to publish `logstruct`.
-- `TF_PROVIDER_GITHUB_TOKEN`: PAT with write access to `DocSpring/terraform-provider-logstruct` for syncing/tagging.
+- `TF_PROVIDER_GITHUB_TOKEN` (a `release` environment secret): PAT with write access to
+ `DocSpring/terraform-provider-logstruct` for syncing/tagging.
+- RubyGems needs no secret: the `logstruct` gem has a trusted publisher for this repository's Release workflow and its
+ `release` environment.
# Core Dependencies
-This gem requires Rails 7.0+ and will always have access to these core Rails modules:
+This gem requires Ruby 3.3+ and Rails 7.1+, and will always have access to these core Rails modules:
- `::Rails`
- `::ActiveSupport`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e12e877b..2df6d609 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,142 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.2.0] - 2026-10-18
### Changed
+- **Breaking**: A Hash passed to the logger (e.g. `Rails.logger.info({evt: "signup", user_id: 1})`) is written with its keys at the top level of the line, instead of nested under `payload`. A Hash can set its own `src` and `evt`; `ts` and `lvl` are reserved and are reported as an error and dropped
+- **Breaking**: `Log::Error`'s `error_class` is any `Exception` class, not only `StandardError` classes, since ActiveJob and GoodJob pass any `Exception` that a job lets through
+- **Breaking**: LogStruct depends on `railties`, `actionpack`, `activesupport`, `globalid` and `lograge` instead of `rails`, so apps no longer install every Rails framework to use it (#9, thanks @langalex)
+- **Rails**: Integrations are set up after `config/initializers` run, so `config.integrations.*` settings and filters added in `config/initializers/logstruct.rb` take effect. `enabled` and `enabled_environments` can be set in an initializer; `LOGSTRUCT_ENABLED` overrides both
+- **Rails**: Because integrations are set up after the app's initializers, LogStruct's loggers and handlers replace ones an initializer set (for example a custom Sidekiq logger or lograge settings). Configure them through LogStruct instead
+- **Sidekiq**: A failed job is logged once, as a structured warn line with its `jid`, class, queue and retry count, instead of Sidekiq's default handler's three lines with the job's arguments in a JSON string. ActiveJob jobs' failures are left to the ActiveJob integration
+- **Puma**: Request errors Puma handles itself (its "Rack app (...)" lines) are structured error lines with the method and path, without the query string
+- **Breaking**: Production (any environment outside `config.local_environments`) logs to STDOUT only. LogStruct added a `log/
+ A check means the gem documents or implements the feature, a dash that it partly does (hover
+ over the dash for what's there), and a cross that it doesn't. Checked in October
+ 2026 against each gem's latest release: Rails Semantic Logger 5.2.0 (with Semantic
+ Logger 5.1.0), Lograge 0.15.1, Logstasher 3.0.0 and Logcraft 2.3.
+
- How LogStruct compares to other popular Rails logging solutions.
+ How LogStruct compares with four other Rails logging gems.
-
-
+
-
-
-
- {features.map((category) => (
- Feature
-
- LogStruct
-
-
- Rails
-
-
- Semantic
-
- Logger
- Lograge
- Logstasher
- Logcraft
-
-
- {category.items.map((feature) => (
-
- {category.category}
-
-
- {feature.name}
-
- {renderCell(feature.logstruct)}
-
-
- {renderCell(feature.railsSemanticLogger)}
+ {/* Seven columns don't fit a phone: the table scrolls sideways instead of the page */}
+
+
+
+ {category.items.map((feature) => (
+
+
+
+
+ {features.map((category) => (
+ Feature
+
+ LogStruct
+
+
+ Rails
+
+
+ Semantic
+
+ Logger
+ Lograge
+ Logstasher
+ Logcraft
+
+
- ))}
-
+ {category.category}
- {renderCell(feature.lograge)}
- {renderCell(feature.logstasher)}
- {renderCell(feature.logcraft)}
+
+ ))}
+
+ ))}
+ {feature.name}
+
+ {renderCell(feature.logstruct)}
+
+
+ {renderCell(feature.railsSemanticLogger)}
+
+ {renderCell(feature.lograge)}
+ {renderCell(feature.logstasher)}
+ {renderCell(feature.logcraft)}
+ log/<env>.log, and to STDOUT under{' '}
+ rails server. It supports tags and named tags, and has test helpers for
+ Minitest and RSpec. Request params and SQL binds go through Rails'{' '}
+ filter_parameters; other values are written as given.
+
+ LogStruct writes its lines through Semantic Logger too (the semantic_logger{' '}
+ gem, not rails_semantic_logger), with its own JSON formatter. Both replace
+ Rails' logger, so an app uses one or the other. LogStruct writes to STDOUT in
+ production and development, plus Rails' log file in development and test, and adds
+ typed lines, filtering and scrubbing of every line, security lines and error handling
+ modes.
key=value; JSON, Logstash,
+ Graylog2, LTSV and other formatters are options. Other log lines are left as they are.
+ It logs no params unless you add them with custom_options, and it's
+ off until you set config.lograge.enabled = true.
+
+
+ LogStruct depends on Lograge and uses it to build its request lines, which are then
+ filtered and scrubbed like every other line. The Hash a{' '}
+ lograge_custom_options proc returns is merged into the request line.
log/logstash_<env>.log by default, or to another logger. The
+ app's own Rails.logger lines stay in Rails' text log.{' '}
+ LogStasher.info and the other level methods log the app's own events
+ with tags (its logger's level is WARN unless you set{' '}
+ config.logstasher.log_level), and LogStasher.watch adds data
+ from any ActiveSupport notification to the request's event. It's off until you
+ set config.logstasher.enabled = true.
+
+ + LogStruct writes every line, the app's own included, as JSON (to STDOUT in + production), and has Sidekiq, GoodJob, ActiveStorage, CarrierWave and Shrine + integrations.
@@ -425,46 +486,69 @@ export default function ComparisonPage() { > Logcraft {' '} - is a newer gem that provides structured logging with good defaults for Rails - applications. It focuses on simplicity and includes request logging and basic - integrations. However, it lacks the advanced security features, type safety, and - comprehensive third-party integrations that LogStruct provides. + setsRails.logger, and every logger made with Logcraft.logger,
+ to write JSON to STDOUT, without an initializer. It replaces Rails' request logs
+ with one access log line per request, logs unhandled errors on one line, and writes SQL
+ queries as JSON at debug level. within_log_context adds fields to every
+ line logged in a block, and global_context to every line. It includes RSpec
+ matchers for log output. Sidekiq support is a separate gem,{' '}
+ logcraft-sidekiq. The README on its main branch describes Logcraft 3, which
+ isn't released yet.
+
+ + LogStruct also writes JSON to STDOUT in production. It adds typed lines, filtering of + every line (not only request params), scrubbing, security lines and integrations for + jobs, mailers and file uploads.
-- LogStruct combines the best features of all these gems while adding unique capabilities: -
+LogStruct builds on Semantic Logger and Lograge, and adds:
filter_parameters. Values of email and{' '}
+ email_address keys are replaced with a hash.
+ Bearer and Basic credentials are masked. IP and MAC address
+ scrubbing is opt-in.
src/evt pairs
+ at plan time and builds the CloudWatch filter patterns for metric filters.
- If you're starting a new Rails application or looking to upgrade your logging
- infrastructure, LogStruct provides the most complete, secure, and developer-friendly
- solution available.
+ What it doesn't do: it has no setting for other log destinations (it writes to
+ STDOUT in production and development, plus Rails' log file in development and
+ test), it's off in development unless you set LOGSTRUCT_ENABLED=true,
+ and it's off in the Rails console.
- LogStruct is designed to be highly configurable while working with sensible defaults. You
- can customize how and where logs are generated, which integrations are enabled, and how
- errors are handled.
+ LogStruct works without any configuration. To change its defaults, create{' '}
+
+ config/initializers/logstruct.rb
+
+ . This page lists every setting.
- Create a file at{' '}
-
- config/initializers/logstruct.rb
- {' '}
- with your desired configuration.
+ A typical initializer looks like this:
+ Rails passes each request's instrumentation payload to Lograge, so a controller can add
+ fields for lograge_custom_options to read:
+
config/initializers (or earlier, in{' '}
+ config/application.rb or config/environments/*.rb). LogStruct sets
+ up its logger and integrations after the initializers run. A setting changed after the app
+ has booted only affects what LogStruct reads as it writes each line, such as the filters and
+ limits.
+ - LogStruct follows a simple philosophy:{' '} - machines get JSON, humans get readable logs. By default: -
+By default LogStruct is on:
rails server, a Sidekiq server and good_job start.
+ It's off in development, in consoles, and in production Rake tasks,{' '}
+ rails runner and job runners it doesn't recognize (such as Solid
+ Queue's bin/jobs, Resque or Delayed Job), which keep Rails' usual
+ logger. When LogStruct runs has the details.
+
- You can override this behavior in several ways, with the following precedence (highest to - lowest): + These settings decide whether LogStruct is on, from highest precedence to lowest:
LOGSTRUCT_ENABLED environment variable
+
LOGSTRUCT_ENABLED=true to force JSON logs, or{' '}
- LOGSTRUCT_ENABLED=false to disable completely.
+ true, t, yes, y or 1{' '}
+ turns LogStruct on in any process and environment, consoles included. Any other value
+ turns it off.
config.enabled in an initializer
+
config.enabled = true in your initializer to enable in all
- development processes.
+ It has no environment check of its own: it applies to every process that loads the
+ initializer, in every environment. Wrap it in a condition:
config.enabled_environments
+
config.enabled_environments to change which environments have JSON
- logs by default (currently [:test, :production]).
+ The environments where LogStruct switches on for the processes it recognizes (default{' '}
+ [:test, :production]).
CI environment variable is automatically detected by most CI systems
- (GitHub Actions, GitLab CI, CircleCI, Travis, etc.). If your CI doesn't set it, add{' '}
- CI=true to your CI configuration.
- - LogStruct supports different environments and handles them appropriately: -
- LogStruct is disabled by default in development. When you explicitly enable it (for example,{' '}
- LOGSTRUCT_ENABLED=true rails s or setting config.enabled = true),
- LogStruct uses the same JSON formatter as production so you can validate structured logs
- locally. If you prefer the colorful human formatter for day‑to‑day debugging, set:
+
+ In the environments that aren't local (production, staging and any other), LogStruct
+ logs to STDOUT only. In local environments it also writes Rails' log file (
+ log/development.log, log/test.log). In test it writes{' '}
+ only the log file, so test output stays clean. With{' '}
+ RAILS_LOG_TO_STDOUT set, every environment logs to STDOUT only. See{' '}
+ Logging to STDOUT.
ActiveSupport::TaggedLogging compatibility patch is only applied when
- LogStruct is enabled. This ensures third‑party gems that write Hashes to{' '}
- Rails.logger (e.g., dotenv‑rails) do not affect your default development
- logging when LogStruct is disabled.
- test as well as production by default. Keeping test and
- production behavior as close as possible helps catch logging issues early (for example,
- unexpected serialization errors, missing fields, or broken integrations) before they reach
- prod. If you prefer to disable it in test, remove :test from{' '}
- config.enabled_environments.
-
+ When LogStruct is on in development, it writes the same JSON as production, so you can check
+ your lines locally. For colored, pretty-printed lines instead, set{' '}
+ prefer_json_in_development to false. These are the logger settings
+ and their defaults:
+
LOGSTRUCT_ENABLED=true when running the command:
- LOGSTRUCT_ENABLED=true rails console
- LOGSTRUCT_ENABLED=true rake db:migrate
- - LogStruct integrates with many popular gems. You can enable or disable specific - integrations: + Each integration can be turned off. The integrations{' '} + page describes the lines each one writes.
+ The integrations' other settings, with their defaults: +
+ +- LogStruct includes robust filtering for sensitive data to ensure privacy and security: + Every setting for filtering keys and scrubbing strings, with the defaults where the example + doesn't change them:
+= (as above). Assigning a new list replaces the
+ defaults. The Filtering Sensitive Data{' '}
+ page explains what each setting matches.
- LogStruct sorts the errors it handles into four groups, each with its own mode (the example
- below shows all four with their defaults). Type-checking errors and errors inside LogStruct
- are raised in development and test, so you see them early. In production they don't
- crash your app: type-checking errors are logged and reported, and LogStruct's own
- errors are logged. The modes are Ignore, Log, Report{' '}
- (log and report), LogProduction and ReportProduction (log, or log
- and report, in production; raise in development and test) and Raise.
-
- We automatically detect which error reporting service you use (Sentry, Bugsnag, Rollbar, - Honeybadger). If you use a service that we don't support yet, you can configure a{' '} - custom error handler. (Or you can send a PR!) + LogStruct sorts the errors it handles into four groups, each with a mode. The modes are:
+Ignore: do nothing.
+ Log: write an error line.
+ Report: write an error line and report the error.
+ LogProduction and ReportProduction: log, or log and report,
+ outside the local_environments; raise in them (development and test by
+ default).
+ Raise: raise the error everywhere, without logging or reporting it.
+ The groups and their defaults:
- Configure which instance variables on your mailers should be logged as IDs in{' '}
- additional_data. This is useful for tracking which account, user, organization,
- etc. sent an email.
+
+ A raised error reaches your app as usual, so your error reporter still sees it. Reports go + to the first of Sentry, Bugsnag, Rollbar and Honeybadger that your app loads; with none of + them, LogStruct logs the error instead. To use another service, set a{' '} + custom error reporting handler.
-
- In your mailer, set the instance variables and they'll automatically be logged:
+
+ config.integrations.actionmailer_id_mapping lists the mailer instance variables
+ whose IDs are added to mail lines, so you can see which account or user an email was sent
+ for. With the default mapping ({'{account: :account_id, user: :user_id}'}),
+ this mailer's lines get account_id and user_id fields:
- You can extend Lograge request logging with custom fields:
+ Request lines include the method, path, format, controller, action, status, durations (
+ duration_ms, view, db), params (as Rails filtered
+ them), source_ip, host, user_agent,{' '}
+ referer (without its query string), content_type and{' '}
+ accept, and req_id. To add your own fields:
- Custom options are merged into the request log output at the top level. LogStruct also
- captures request metadata like request_id, source_ip,
- user_agent, referer, host, content_type,
- and accept automatically when present.
+ The fields are added at the top level of the request line, and filtered like everything
+ else.
- You can implement custom string scrubbers to filter out sensitive data that isn't - caught by the built-in filters: + To scrub values the built-in scrubbers don't know about:
- {/* cspell:ignore doesn */} - If LogStruct doesn't support your error reporting service, you can register a custom - error reporting handler. (Or submit a PR!) + If LogStruct doesn't support your error reporting service, register a handler. (Or send + a PR!)
+ If the handler raises (a timeout calling your service), LogStruct logs its error as a{' '}
+ logstruct error line, and the error it was reporting is handled as it would
+ have been.
+
+ When your app loads more than one of the supported services, choose one: +
+ +
LogStruct sets Sorbet's runtime error handlers, so a type error anywhere in your app or
its gems is raised in development and test, and logged and reported in production while your
app carries on (error_handling_modes.type_checking_errors). Without LogStruct,
- Sorbet raises them everywhere.
+ Sorbet raises them everywhere. The handlers replace any your app set before LogStruct's
+ initializer ran.
Bearer or Basic (in any case), as in an
+ Authorization header logged as text: a Bearer token of 16 or more characters, or Basic
+ credentials that decode from Base64 to user:password. Replaced with{' '}
+ [FILTERED].
+ >
+ ),
+ examples: [
+ [
+ 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc',
+ 'Authorization: Bearer [FILTERED]',
+ ],
+ ['Authorization: Basic dXNlcjpwYXNzd29yZA==', 'Authorization: Basic [FILTERED]'],
+ ['Bearer token', 'Bearer token', 'Fewer than 16 characters'],
+ ['Basic plan', 'Basic plan', 'Not Base64 of user:password'],
+ ],
+ },
+ {
+ setting: 'url_query_params',
+ defaultOn: true,
+ matches: (
+ <>
+ In a URL's query string (after a ?), the value of each param whose name
+ would be filtered as a key, and of OAuth's code. A name like{' '}
+ user[password] is checked by its last part, written plainly or URL-encoded.
+ Replaced with [FILTERED].
+ >
+ ),
+ examples: [
+ ['GET /reset?token=abc123&locale=en', 'GET /reset?token=[FILTERED]&locale=en'],
+ [
+ '/signup?user[password]=hunter2&user[name]=Jo',
+ '/signup?user[password]=[FILTERED]&user[name]=Jo',
+ ],
+ ['/oauth/callback?code=4/0AY0e&state=xyz', '/oauth/callback?code=[FILTERED]&state=xyz'],
+ ['password=hunter2&user=jo', 'password=hunter2&user=jo', 'No ? before the params'],
+ ],
+ },
+ {
+ setting: 'url_passwords',
+ defaultOn: true,
+ matches: (
+ <>
+ The password in a URL's user info (scheme://user:password@host), also in
+ URL-encoded URLs. Replaced with [PASSWORD].
+ >
+ ),
+ examples: [
+ [
+ 'postgres://app:s3cret@db.internal:5432/app',
+ 'postgres://app:[PASSWORD]@db.internal:5432/app',
+ ],
+ ['redis://:s3cret@cache:6379/0', 'redis://:[PASSWORD]@cache:6379/0'],
+ [
+ 'https%3A%2F%2Fuser%3As3cret%40example.com',
+ 'https%3A%2F%2Fuser%3A[PASSWORD]%40example.com',
+ ],
+ ],
+ },
+ {
+ setting: 'email_addresses',
+ defaultOn: true,
+ matches: (
+ <>
+ Email addresses, with letters and digits of any script and a top-level domain of two or more
+ letters. Replaced with [EMAIL:hash] (see{' '}
+
+ Hashed Values
+
+ ).
+ >
+ ),
+ examples: [
+ ['Sent to Linda.Martinez@example.com', 'Sent to [EMAIL:a058d5090035d2e1]'],
+ ['Sent to linda.martinez@example.com', 'Sent to [EMAIL:a058d5090035d2e1]', 'Same hash'],
+ ['José.Núñez@correo.es', '[EMAIL:c28bb257abd35f9c]'],
+ ['deploy@localhost', 'deploy@localhost', 'No top-level domain'],
+ ],
+ },
+ {
+ setting: 'credit_card_numbers',
+ defaultOn: true,
+ matches: (
+ <>
+ 13 to 19 digits starting with 2 to 6: written together, in groups of four separated by
+ spaces or dashes, or in American Express and Diners Club groups. The number has to pass the
+ Luhn check that every card number passes, so most IDs and timestamps are left alone.
+ Replaced with [CREDIT_CARD].
+ >
+ ),
+ examples: [
+ ['Paid with 4111 1111 1111 1111', 'Paid with [CREDIT_CARD]'],
+ ['Paid with 4111-1111-1111-1111', 'Paid with [CREDIT_CARD]'],
+ ['Paid with 4111111111111111', 'Paid with [CREDIT_CARD]'],
+ ['Paid with 3782 822463 10005', 'Paid with [CREDIT_CARD]'],
+ ['Paid with 4111 1111 1111 1112', 'Paid with 4111 1111 1111 1112', 'Fails the Luhn check'],
+ ['Order 2026101712345679', 'Order [CREDIT_CARD]', 'Not a card, but passes the Luhn check'],
+ ],
+ },
+ {
+ setting: 'phone_numbers',
+ defaultOn: true,
+ matches: (
+ <>
+ US phone numbers written with separators: 555-123-4567,{' '}
+ 555 123 4567, 555.123.4567 and (555) 123-4567, with
+ or without +1 or 1. Replaced with [PHONE].
+ >
+ ),
+ examples: [
+ ['Call 555-123-4567', 'Call [PHONE]'],
+ ['Call 555.123.4567', 'Call [PHONE]'],
+ ['Call (555) 123-4567', 'Call [PHONE]'],
+ ['Call +1 555 123 4567', 'Call [PHONE]'],
+ ['Call 1-555-123-4567', 'Call [PHONE]'],
+ ['Call 5551234567', 'Call 5551234567', 'No separators'],
+ ['Call +44 20 7946 0958', 'Call +44 20 7946 0958', 'Not a US number'],
+ ['Reference 100-200-3000', 'Reference [PHONE]', 'Not a phone number'],
+ ],
+ },
+ {
+ setting: 'ssns',
+ defaultOn: true,
+ matches: (
+ <>
+ US social security numbers written with dashes or spaces (123-45-6789), and not
+ part of a longer number. Replaced with [SSN].
+ >
+ ),
+ examples: [
+ ['SSN 123-45-6789', 'SSN [SSN]'],
+ ['SSN 123 45 6789', 'SSN [SSN]'],
+ ['Part 123-45-6789-0', 'Part 123-45-6789-0', 'Part of a longer number'],
+ ['SSN 123456789', 'SSN 123456789', 'No separators'],
+ ],
+ },
+ {
+ setting: 'ip_addresses',
+ defaultOn: false,
+ matches: (
+ <>
+ IPv4 addresses (four numbers of 1 to 3 digits, separated by dots) and IPv6 addresses (text
+ that Ruby's IPAddr reads as one and that has a digit). Replaced with{' '}
+ [IP].
+ >
+ ),
+ examples: [
+ ['From 192.168.1.10', 'From [IP]'],
+ ['From 2001:db8::1', 'From [IP]'],
+ ['From ::ffff:192.168.1.1', 'From [IP]'],
+ ['From fe80::1%en0', 'From [IP]'],
+ ['Version 1.2.3.4', 'Version [IP]', 'Not an IP address'],
+ ['At 12:30:45', 'At 12:30:45', 'A time'],
+ ],
+ },
+ {
+ setting: 'mac_addresses',
+ defaultOn: false,
+ matches: (
+ <>
+ Six pairs of hex digits separated by colons or by dashes. Replaced with [MAC].
+ >
+ ),
+ examples: [
+ ['MAC 00:1A:2B:3C:4D:5E', 'MAC [MAC]'],
+ ['MAC 00-1A-2B-3C-4D-5E', 'MAC [MAC]'],
+ ['MAC 001A.2B3C.4D5E', 'MAC 001A.2B3C.4D5E', 'Dotted groups of four'],
+ ],
+ },
+];
+
+function CodeList({ items }: { items: string[] }) {
+ return (
+ <>
+ {items.map((item, index) => (
+
+ {item}
+ {index < items.length - 1 ? ', ' : ''}
+
+ ))}
+ >
+ );
+}
+
+function ScrubberTable() {
+ return (
+ | + Setting + | ++ Logged + | ++ Written + | +
|---|---|---|
|
+ {scrubber.setting}
+
+ {scrubber.defaultOn ? 'On by default' : 'Off by default'}
+
+ |
+ )}
+ {logged} | +
+ {written}
+ {note && (
+ {note}
+ )}
+ |
+
- LogStruct keeps sensitive data out of your logs in two ways: it filters values by the names - of their keys, and it scrubs patterns such as email addresses and card numbers out of every - string. Both apply to every line LogStruct writes, whatever logged it. + LogStruct keeps sensitive values out of the lines it writes in two ways: it filters values + by the names of their keys, and it scrubs data in known formats, such as email addresses and + card numbers, out of every string. Both run in LogStruct's formatter, on every line it + writes while it's on (see{' '} + + When LogStruct Runs + + ). Both match names and patterns, so they can miss sensitive data, and they can replace data + that isn't sensitive.
-
- LogStruct filters sensitive values in request parameters, job arguments and any other
- structured data by their key names, at any depth. A filtered value is replaced with a
- summary of it.
+
+ When a key's name is sensitive, LogStruct replaces its value with a summary. This + applies at any depth: in request params, job arguments, Hashes you log and Arrays of Hashes.
-- The summary depends on the value's type: -
- -LogStruct writes:
+The summary depends on the value:
+filter_keys_with_hashes, the
- class and a hash of the value (see{' '}
+ String: {'{"_class": "String"}'}. For a key in{' '}
+ filter_keys_with_hashes, a _hash of the value too (see{' '}
Hashed Values
).
_keys_count) and its
+ first 10 keys (_keys), scrubbed like any string. An empty Hash is{' '}
+ {'{"_class": "Hash", "_empty": true}'}.
_count). An
+ empty Array is {'{"_class": "Array", "_empty": true}'}.
+ nil, true): its class
+ only.
+ Summaries have no lengths or sizes, which would say how long a password or token is, and no
+ value inside a filtered Hash or Array is written. A value that is already{' '}
+ "[FILTERED]" (Rails filtered it) is written as it is, with no
+ summary.
+
- LogStruct filters these keys by default (filter_keys):
+
+ A key is filtered when any of these match it:
- -
- Keys are matched by their words, whatever their case or separators, so{' '}
- :credit_card also filters creditCard and Credit-Card.
- A default pattern filters these words anywhere in a key, with plurals like passwords and
- tokens: password, passwd, passphrase, pwd, token, secret, auth, authorization,
- authentication, cred, credential, cookie, session, jwt, bearer, otp, cvv, cvc, ssn and
- crypt. It also filters api_key, secret_key,{' '}
- private_key, access_key, encryption_key,{' '}
- signing_key and master_key, and keys that end in signature, sig,
- salt or certificate. For example:
+
filter_keys
+
+ , by default filter_keys_with_hashes
+
+ , by default :credit_card filters creditCard,{' '}
+ Credit-Card and CREDIT_CARD, but not{' '}
+ credit_card_number (the default pattern filters that one). An entry is a
+ name, not a pattern: :_key only filters a key named _key.
+ apiKey, X-Api-Key and{' '}
+ HTTP_X_API_KEY all contain api_key):
+ filter_parameters
+
+ , matched as Rails matches them (see{' '}
+
+ Rails filter_parameters
+
+ ).
+ filter_matchers and{' '}
+ path_matchers (see{' '}
+
+ Adding Keys
+
+ ).
+
+ Filtered by the defaults:
+ Not filtered by the defaults:
+ Because the pattern matches a word anywhere in a key, it also filters keys that aren't
+ secrets, such as
- LogStruct also filters the keys in your app's config.filter_parameters,
- and matches them the way Rails does:
+
+ After your app's config/initializers run, LogStruct adds the entries in{' '}
+ Rails.application.config.filter_parameters to its own rules, and matches them
+ the way Rails does:
:passw{' '}
- filters user_password), and a Regexp is matched as it is.
+ filters user_password). A Regexp is matched as it is.
"credit_card.code", or a Regexp with{' '}
- \.) match a nested key by its path. A request line's params match from
- the params down, as they do in Rails.
+ An entry with a dot ("credit_card.code", or a Regexp with{' '}
+ \.) matches a nested key by its path. A request line's params match from
+ the params down, as in Rails.
:_key doesn't filter{' '}
- cron_key. Exact names and Regexps do.
+ The partial matches of Symbols and Strings don't apply to the names of
+ LogStruct's own fields at the top of a line, so :_key doesn't
+ filter a GoodJob line's cron_key. Exact names, Regexps and blocks do.
- Rails' list is left in place, so request.filtered_parameters and{' '}
- ActiveRecord::Base.filter_attributes keep working, and values Rails has already
- replaced are logged as [FILTERED].
+
+ A new Rails 8 app filters config/initializers/filter_parameter_logging.rb). As partial matches, they
+ filter keys the defaults leave, some of them not secrets:{' '}
+ :otp matches footprint).
+
+ Rails filters a request's params before LogStruct gets them, so in a request line a key
+ Rails filters is "[FILTERED]", with no summary and no hash. A sign-up
+ request's params, with Rails 8's list:
+
+ Values Rails doesn't filter first, such as job arguments and the Hashes you log, get + LogStruct's summaries. LogStruct doesn't change Rails' list, so Rails' + own filtering keeps working as before.
- Email addresses are replaced with a hash, so you can follow one person through your logs
- (sign up, sign in, password reset) without logging their address. Every email address in a
- string becomes [EMAIL:hash]. A filtered key in{' '}
- filter_keys_with_hashes (:email and :email_address by
- default) logs the hash in its summary. Both use the same hash for the same address:
+
+ Keys in filter_keys_with_hashes (by default email and{' '}
+ email_address) are filtered like the others, and the summary of a String value
+ has a _hash of it. Every email address in a string becomes{' '}
+ [EMAIL:hash], with the same hash. So you can find the lines about one address
+ without logging it:
- The hash is an HMAC-SHA256 of the value, cut to hash_length characters (16 by
- default). Its key is hash_salt, or by default a key derived from your Rails
- app's secret_key_base, so the hashes differ from one app to the next.
+
+ reply_to isn't a filtered key, so its value is scrubbed like any string.
+ An email address is hashed in lowercase, so it has one hash however it's capitalized.
+ Other values are hashed as they are: JDoe and jdoe get different
+ hashes.
+
+ The hash is an HMAC-SHA256 of the value, cut to hash_length hex characters (16
+ by default). Its key is hash_salt. Without it, LogStruct derives a key from
+ your app's secret_key_base, so the hashes differ from one app to the next,
+ and they all change when you change secret_key_base. Set hash_salt{' '}
+ to keep them when you rotate it, or to get the same hashes in several apps:
+
+ Outside a Rails app, without hash_salt, LogStruct uses a fixed key that is
+ public in its source code.
hash_salt{' '}
- secret, and expect every hash to change when you change it or your{' '}
- secret_key_base.
+ addresses can work out which hash belongs to which address. Keep the key secret.
- Add keys and rules to the defaults, and turn the string scrubbers on or off:
+
+ Add to the lists with += and <<, as above. Assigning a list
+ replaces the defaults: config.filters.filter_keys = [:pin] stops filtering{' '}
+ password and token by name, and assigning{' '}
+ filter_matchers removes the default pattern. Keys you add to Rails'{' '}
+ filter_parameters work too, and Rails filters them in its own request params.
+ Every setting, with its default, is in{' '}
+
+ Configuration
+
+ .
- Assigning a new list (config.filters.filter_keys = [...]) replaces the
- defaults, and assigning filter_matchers removes the default pattern, so add to
- them as the example does.
-
- LogStruct scans every string it logs for patterns that look like sensitive data, whatever
- key they're under. That includes messages, Hash keys that aren't plain identifiers
- (an email address used as a key), Symbols and GlobalIDs.
+
+ LogStruct scans every String it writes for data in known formats and replaces it, whatever + key it's under: messages, values at any depth, Symbols, GlobalIDs, Hash keys that + aren't plain identifiers (an email address used as a key), and the keys in a filtered + Hash's summary. Tags, the request ID and the job context are scrubbed too. Numbers + aren't: a card number logged as an Integer is written as it is, unless its key is + filtered.
- -+ The scrubbers are based on ankane's{' '} + logstop {' '} - gem, which LogStruct's string scrubbing is based on. -
config.filters, and they run in this order:
+
+ {scrubber.setting} ({scrubber.defaultOn ? 'on' : 'off'} by default):{' '}
+ {scrubber.matches}
+ + What each one writes (the hashes depend on your key): +
++ The scrubbers look for formats, not meaning. They miss sensitive data in other formats, and + they replace text that only looks like it: +
+[EMAIL:hash]
+ Formats the scrubbers don't know are left: phone numbers outside the US, phone
+ numbers and SSNs without separators, names and street addresses, and secrets in plain
+ text, such as password=hunter2 outside a URL's query string or an API
+ key like sk_live_... in a message.
postgres://user:secret@host,{' '}
- redis://:secret@host and URL-encoded URLs, replaced with{' '}
- [PASSWORD]
+ Text that only looks sensitive is replaced: a reference written like a phone number, an ID
+ that passes the Luhn check, a version number like 1.2.3.4 (with{' '}
+ ip_addresses on), and a URL's user name and host (
+ https://user@example.com), which look like an email address.
+ + Keep secrets out of what you log where you can. Filtering catches the common names and + formats, not everything. +
+ +
+ For formats the built-in scrubbers don't know, set{' '}
+ config.string_scrubbing_handler. It gets every string after the built-in
+ scrubbers, Hash keys that aren't plain identifiers included, and returns the string to
+ write. See{' '}
+
+ Custom String Scrubbing
+ {' '}
+ in Configuration.
+
prefer_json_in_development off, the colored lines are filtered and scrubbed
+ the same way.
?token=, ?api_key=, user[password]=), and
- OAuth's ?code=, get the value [FILTERED]
+ Appenders you add to SemanticLogger yourself write with the formatter you
+ give them. SemanticLogger's own formatters don't filter anything: pass{' '}
+ formatter: LogStruct::SemanticLogger::Formatter.new to filter their lines
+ too.
[CREDIT_CARD].
- Numbers that fail the Luhn check every card number passes are left alone, which keeps most
- IDs and timestamps.
+
+ Loggers added with broadcast_to
+ {' '}
+ get String messages (and what a block returns, and lines written with{' '}
+ <<) scrubbed, as text. Hashes and LogStruct entries aren't passed
+ to them.
[PHONE]
+ Error reports: LogStruct passes the exception itself to Sentry, Bugsnag,
+ Rollbar, Honeybadger or your error_reporting_handler. Its message and
+ backtrace aren't scrubbed, so an email address or card number in an exception's
+ message reaches your error tracker. The context LogStruct sends with it is filtered and
+ scrubbed like a log line, and so is the error line LogStruct writes. Use your error
+ tracker's own filtering for what it receives.
[SSN]
+ SQL bind values (with enable_sql_logging on, and{' '}
+ sql_log_bind_params, which is on unless the Rails environment is production):
+ a value whose column name would be filtered as a key is written as{' '}
+ "[FILTERED]", with no summary or hash. So is a String value that
+ looks like a secret: longer than 50 characters with no spaces, only hex digits (32 or
+ more) or Base64 characters (20 or more), a JWT, or a Bearer token. Binary values are
+ written as their size in bytes. Other values are scrubbed like any string. The SQL itself
+ is scrubbed but not filtered: a value written into it (a string condition, or any value
+ with prepared_statements off) is only scrubbed. See{' '}
+
+ SQL Queries
+
+ .
[IP] (off by default)
+
+ The log.logstruct notification
+
+ : LogStruct sends it for each LogStruct entry or T::Struct logged through its
+ logger (not for Hashes or Strings), with the entry as it was logged, before filtering. If
+ a subscriber sends it anywhere, filter it there.
[MAC] (off by default)
+ While LogStruct is off, none of this applies: Rails writes its own lines,
+ filtered only by its filter_parameters.
- Each scrubber has a setting in config.filters (see{' '}
-
- Configuring Filtering
-
- ). Plain lines from the development color formatter, and messages sent to loggers added with{' '}
- broadcast_to, are scrubbed too.
-
- For patterns the built-in scrubbers don't cover, add a handler. It runs after them, on - every string and on every Hash key that isn't a plain identifier: -
-url lines have the service and the
file's key, but not the signed URL, which lets anyone read the file until it expires.
deliver_later) are logged with only the
- mailer, the mail and the delivery method as their arguments. A mail's own arguments
- can be secrets with no key to filter on, such as the tokens Devise passes to its
- confirmation, password reset and invitation emails. This applies while the ActiveJob
- integration is on and the delivery job is ActionMailer::MailDeliveryJob or a
- subclass of it. With the integration off, Rails' own job logs include the arguments.
- content_disposition. A location your uploader builds from a file's name
+ (and CarrierWave's paths, which use the uploaded name by default) is logged as it is:
+ scrub it with a string_scrubbing_handler.
sql_log_bind_params is on) are
- filtered by their column names, like parameters. The SQL itself is scrubbed but not
- filtered: it can hold literal values, from string conditions or when{' '}
- prepared_statements is off.
+ Mail delivery job arguments: a deliver_later job (
+ ActionMailer::MailDeliveryJob or a subclass) is logged with only the mailer,
+ the mail and the delivery method as its arguments, while the ActiveJob integration is on.
+ A mail's own arguments can be secrets with no key to filter on, such as the tokens
+ Devise passes to its confirmation and password reset emails.
+ A string longer than limits.max_string_bytes is scrubbed before it's cut:
+ the scrubbers see its first max_string_bytes plus 256 bytes, so data at the cut
+ is replaced whole (the replacement itself can be cut). A URL password or Basic{' '}
+ credentials that the cut runs through are replaced however long they are. A{' '}
+ string_scrubbing_handler sees the same part of the string. Values nested more
+ than 20 levels deep, and circular references, would skip filtering, so they're replaced
+ with a summary like a filtered value's (
+ {'{"_filtered": {"_class": "Hash", "_keys_count": 2}}'}). The limits on keys,
+ items and string sizes are in{' '}
+
+ Limits
+
+ .
+
- Follow these steps to add LogStruct to your Rails application and start enjoying the - benefits of structured JSON logging. + LogStruct needs Ruby 3.3 or newer and Rails 7.1 or newer.
{`Add this line to your application's Gemfile:`}
-And then execute:
+And then run:
- {`LogStruct is now installed and will automatically enable JSON structured logging for test runs and server processes in production environments. Console and other Rake tasks will use human-readable logs by default.`}
+ That's all you need. In production, LogStruct is now on for your web server and job
+ processes (Puma, Unicorn, Thin, Falcon, rails server, Sidekiq and{' '}
+ good_job start), and in test for every process except the console. Consoles,
+ production Rake tasks and other processes keep Rails' usual logger. See{' '}
+ When LogStruct runs for the details and how to turn
+ it on for other processes.
+ LogStruct is off in development, so rails server looks the same as before. To
+ see the lines your app will write in production, start the server with{' '}
+ LOGSTRUCT_ENABLED=true:
+
+ Each request then writes a line like this: +
+
- While LogStruct works out of the box with zero configuration, you might want to customize it
- to better suit your application. Create a new file at{' '}
+ LogStruct works without any configuration. To change its defaults, create{' '}
config/initializers/logstruct.rb
- {' '}
- with the following content:
+
+ , for example:
+ The configuration page lists every setting. +
+- You can use LogStruct with the standard Rails logger: + Keep using the Rails logger. Log a message, or a Hash of fields:
-- LogStruct will automatically convert all logs to JSON format with a consistent structure, - making them easy to parse and search in log management systems. -
+These lines are written:
+
+ A Hash's keys become the line's fields. evt names the event
+ (default log), and src says where the line came from (default{' '}
+ app). Leave src as app, or use a name of your own:
+ LogStruct's integrations use names like rails and job.
+
+ The email value was filtered: LogStruct logs a hash of it, so you can find
+ every line about the same address without logging the address. See{' '}
+ Filtering Sensitive Data.
+
- Explore these pages to learn more about LogStruct: -
- A guide to handling Postmark bounce errors and sending custom notifications using LogStruct. + Postmark refuses some mails for good: mail to an inactive recipient (after a hard bounce, a + spam complaint or a manual suppression) and requests it can't accept, such as an + invalid address. Sending them again won't help. This guide shows how to log those + errors without failing the delivery, and how to post an alert to Slack without the + recipients' addresses.
-
- LogStruct provides three error handling methods you can use in rescue_from{' '}
- handlers:
+
+ Where LogStruct is on, it adds{' '}
+ rescue_from StandardError, with: :log_and_reraise_error to{' '}
+ ActionMailer::Base, so every mailer already has it. A failed delivery is logged
+ as one error line and raised again: deliver_now raises, and a{' '}
+ deliver_later job fails. Whether the job runs again is up to your job backend
+ and your retry_on settings. Where LogStruct is off, mailers have no default
+ handler (see{' '}
+
+ below
+
+ ).
+
+ A mailer can name one of these in its own rescue_from. Each writes one{' '}
+ error line, and then:
log_and_ignore_error(error) - Logs the error but doesn't report to Sentry or
- reraise
+ log_and_ignore_error carries on as if the mail had been sent.
log_and_report_error(error) - Logs the error, reports to Sentry, but doesn't
- reraise
+ log_and_report_error reports the error once, to your error tracker or{' '}
+ error_reporting_handler, and carries on.
log_and_reraise_error(error) - Logs the error, reports to Sentry, and
- reraises (for retry)
+ log_and_reraise_error raises the error again (deliver_now{' '}
+ raises, and a deliver_later job fails), and reports it where{' '}
+ config.error_handling_modes.standard_errors reports your app's errors
+ (see{' '}
+
+ Error Handling Configuration
+
+ ): in Report mode, and in ReportProduction mode in production.
Raise, LogStruct doesn't report the errors{' '}
+ log_and_reraise_error raises, though an error tracker that catches raised
+ errors may report them itself.
+ - When using Postmark, you may want to handle bounce errors (inactive recipients, invalid - emails) differently than other errors: -
-- LogStruct logs structured error data but doesn't send notifications directly. To send custom - notifications (like Slack alerts), subscribe to LogStruct logs: -
-
- When a mailer error occurs, LogStruct creates a{' '}
- LogStruct::Log::ActionMailer::Error with:
+
+ Where LogStruct is off (by default in development, consoles, Rake tasks and job runners it
+ doesn't recognize), or with enable_actionmailer off, mailers still have
+ the three methods, so a rescue_from that names one works. Each writes{' '}
+ <ErrorClass>: <message> to the mailer's logger, and then:
error_class - The exception class (e.g.,{' '}
- Postmark::InactiveRecipientError)
- message - Formatted message including context and the actual exception
- message
- backtrace - Stack trace
+ log_and_ignore_error ignores the error.
mailer_class - The mailer class name
+ log_and_report_error reports it to Rails.error (as handled) and
+ doesn't raise it.
mailer_action - The mailer action/method name
+ log_and_reraise_error raises it, without reporting it.
+ In a process where LogStruct is off, nothing scrubs that line, and Postmark's messages + include the recipient's address. +
+ +
+ Name log_and_ignore_error for Postmark's two errors in your{' '}
+ ApplicationMailer. Rails checks a class's own handlers before the ones it
+ inherits, so these come before LogStruct's default handler on{' '}
+ ActionMailer::Base:
+
+ Both are subclasses of Postmark::ApiInputError, which the postmark gem raises
+ when its API answers 422. With LogStruct on, a refused mail writes its delivery{' '}
+ line, then this error line and no delivered line, in any mode, and
+ a deliver_later job succeeds:
+
+ The line also has the error's backtrace (its first 20 lines by default,{' '}
+ config.limits.max_backtrace_lines), left out here. The recipients in the
+ message come from the error's own recipients (an{' '}
+ InactiveRecipientError has them) or else from the mail's to,{' '}
+ cc and bcc, and are scrubbed like any address. See{' '}
+
+ ActionMailer
+ {' '}
+ for the other mail lines.
+
+ LogStruct doesn't send notifications, but it publishes each of its log entries (the{' '}
+ LogStruct::Log structs) as a log.logstruct notification when it
+ logs it, with the entry in payload[:log] and the level in{' '}
+ payload[:level]. To post Postmark's errors to Slack, subscribe in an
+ initializer and enqueue a job of your own:
+
message,{' '}
+ to and subject can hold the recipients' addresses as they
+ are, so send only fields like mailer_class and error_class.
+ to, from, subject - Email metadata
+ The subscriber runs inside the logger call, after the line is queued to be written. If it
+ raises, the line is still written and the error is handled like your app's other
+ errors (the standard_errors mode, which raises it from the logger call by
+ default), so keep it to enqueueing a job.
attachment_count - Number of attachments
+ It runs only where LogStruct handles mail errors: in processes where LogStruct is on, with{' '}
+ enable_actionmailer on (the default). For deliver_later,
+ that's the job process.
additional_data - Custom context (account_id, user_id, etc.)
+ A refused mail publishes one LogStruct::Log::ActionMailer::Error, so it sends
+ one alert.
rescue_from catches the exception
- log_and_ignore_error)
-
+ A subscriber gets a LogStruct::Log::ActionMailer::Error. Its attributes, and
+ their keys in the line:
+
| + Attribute + | ++ Key in the line + | ++ Value + | +
|---|---|---|
| + {attribute} + | +{key} | +{value} | +
+ An error raised before the mailer ran, such as a deliver_later job whose record
+ was deleted before the job ran, is logged by the mailer class, with only{' '}
+ mailer_class, error_class, message and{' '}
+ backtrace.
+
| + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
+ When LogStruct is on, every mail is logged twice: a delivery line just before
+ it's sent and a delivered line once it has been sent. A delivery that
+ fails is an error line. LogStruct gives every mailer a default handler for
+ errors (rescue_from StandardError, with: :log_and_reraise_error) and two more
+ handlers you can name in your own rescue_from calls. Rails' own mail
+ lines, which also log the whole mail at debug level, are turned off.
+
+ Written before the mail is sent (ActionMailer's before_deliver), at{' '}
+ info level. The mail has no Message-ID yet, so there's no{' '}
+ msg_id.
+
+ Written after the mail was sent (after_deliver), at info level. It
+ isn't written when the delivery fails.
+
+ Written at error level by the mailer's error handler, once per failed
+ delivery. It has the mail's fields, the error's class and backtrace, and a message
+ that says which handler logged it:
+
log_and_reraise_error writes{' '}
+
+ <ErrorClass>: Email delivery failed. Recipients: <recipients>. Error
+ message: <message>
+
+ . The other two handlers write{' '}
+
+ <ErrorClass>: Cannot send email to <recipients>. Error message:
+ <message>
+
+ .
+ recipients method
+ (Postmark's errors have one), or else from the mail's to,{' '}
+ cc and bcc, and are scrubbed like any other address.
+ They're unknown when the mailer failed before it built the mail.
+ deliver_later, ActionMailer::MailDeliveryJob passes an
+ error that escapes the mailer to the mailer class. An error the mailer already logged is
+ raised again without a second line, so the job fails as deliver_now would
+ (and the{' '}
+
+ ActiveJob integration
+ {' '}
+ writes the job's error line). An error raised before the mailer ran,
+ such as a record that was deleted before the job ran, is logged by the class with fewer
+ fields:
+ filters.email_addresses on (the default), each is
+ written as [EMAIL:<hash>]; an address has one hash however
+ it's capitalized. With it off, the addresses are written as they are.
+ >,
+ ],
+ ['from', 'The first From address, written the same way.'],
+ [
+ 'subject',
+ 'The subject, scrubbed like any other string (email addresses in it are hashed too).',
+ ],
+ [
+ 'msg_id',
+ <>
+ The Message-ID (delivered and error lines). It looks like an
+ email address, so with filters.email_addresses on it's written as{' '}
+ [EMAIL:<hash>] too.
+ >,
+ ],
+ ['mailer', "The mailer's class."],
+ ['mailer_action', 'The mailer method that built the mail.'],
+ ['attachments', 'How many attachments the mail has.'],
+ [
+ 'account_id, user_id',
+ <>
+ The id of each instance variable named in{' '}
+ actionmailer_id_mapping that the mailer set (by default{' '}
+ @account and @user).
+ >,
+ ],
+ ['error_class', "error lines: the error's class."],
+ ['msg', 'error lines: the message described above.'],
+ ['backtrace', 'error lines: the backtrace, up to max_backtrace_lines (default 20).'],
+ ]}
+ />
+
+
+ LogStruct adds rescue_from StandardError, with: :log_and_reraise_error to{' '}
+ ActionMailer::Base, so every mailer has it. Rails checks a mailer's own{' '}
+ rescue_from handlers first, so you can name one of the three handlers for
+ particular errors:
+
+ Each handler writes one error line, then:
+
log_and_ignore_error carries on as if the mail had been sent.
+ log_and_report_error reports the error once, to your error tracker or{' '}
+ error_reporting_handler, and carries on.
+ log_and_reraise_error raises the error again: deliver_now raises
+ it, and a deliver_later job fails. It also reports it where{' '}
+ config.error_handling_modes.standard_errors reports your app's errors
+ (see{' '}
+
+ Error Handling Configuration
+
+ ): in Report mode, and in ReportProduction mode in production.
+ Raise mode, the default for standard errors, LogStruct doesn't report
+ the errors log_and_reraise_error raises. An error tracker that catches raised
+ errors (in the job or the request) may still report them.
+
+ In development, the console and Rake tasks (LogStruct is off there by default), or with{' '}
+ enable_actionmailer off, LogStruct doesn't add the default handler, but
+ mailers still have the three methods, so a rescue_from that names one
+ doesn't raise NameError. Each writes{' '}
+ <ErrorClass>: <message> to the mailer's logger, then:
+
log_and_ignore_error ignores the error.
+ log_and_report_error reports it to Rails.error (as handled, with
+ the source logstruct.action_mailer) and doesn't raise it.
+ log_and_reraise_error raises it, without reporting it.
+
+ Mailers keep their logger. A logger.info call inside a mailer is written as an
+ ordinary line, with src app.
+
config.integrations.enable_actionmailer (default true).
+ config.integrations.actionmailer_id_mapping (default{' '}
+ {account: :account_id, user: :user_id}).
+ config.filters.email_addresses (default true
+ ).
+ config.error_handling_modes.standard_errors (default Raise).
+ + See{' '} + + Configuration + {' '} + for the other settings. +
+delivered line for a failed delivery, even when the handler ignored the
+ error.
+ deliver_later mail's arguments: its job lines have only the mailer,
+ the mail and the delivery method (see{' '}
+
+ ActiveJob arguments
+
+ ).
+ | + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
+ When LogStruct is on, it detaches Rails' ActiveJob log subscriber and attaches its own,
+ which writes a job line when a job is enqueued, scheduled, started, finished,
+ retried, discarded or fails. It works with any queue backend (Sidekiq, GoodJob, SolidQueue,
+ the async adapter, ...), since it listens to ActiveJob's own events. The lines go to{' '}
+ ActiveJob::Base.logger and carry ActiveJob's tags.
+
+ perform_later without a wait. This job was enqueued with{' '}
+
+ InvoiceJob.perform_later(42, {"api_key" => key, "plan" =>
+ "pro"})
+
+ :
+
+ perform_all_later writes an enqueue or schedule line for each job it enqueued.
+ A job the backend couldn't take (ActiveJob::EnqueueError, or another
+ exception while enqueuing) writes an{' '}
+
+ error
+ {' '}
+ line instead, with executions 0, and a job that a before_enqueue{' '}
+ callback stopped writes no line. Rails tags the lines of perform_later with{' '}
+ ActiveJob, but not those of perform_all_later.
+
+ Some adapters (Sidekiq's) enqueue the jobs together and say only how many they
+ enqueued. When that's all of them, each job has its line. When it's fewer (a
+ Sidekiq client middleware stopped one), there's one warn line with both
+ counts instead, since the adapter doesn't say which jobs it enqueued. If the adapter
+ raises (Redis is down), perform_all_later raises too, and there's one{' '}
+ error line with the error and the number of jobs. With other adapters, the jobs
+ enqueued before the error have their lines, and so do the jobs the adapter refused (
+ ActiveJob::EnqueueError) and the job that raised.
+
+ perform_later with wait or wait_until, and the
+ retries that retry_on schedules:
+
+ Before the job's perform runs. During the job, ActiveJob tags every line
+ with the job's class and ID.
+
+ After perform returns, or when the job's own retry_on,{' '}
+ discard_on or rescue_from handled its exception. A job that a{' '}
+ before_perform callback stopped didn't run, and has no finish line.
+
+ The job raised an exception that it didn't handle, or the backend couldn't enqueue
+ it (then executions is 0), at error level. Any{' '}
+ Exception is logged, not only StandardErrors. Sidekiq's and
+ GoodJob's integrations don't log the same exception again.
+
+ retry_on caught the job's exception and enqueued the job again, after{' '}
+ wait_ms. The retry itself has a schedule line just before this
+ one, and the run that raised has a finish line after it.
+
+ The exception came back on the last of retry_on's attempts, at{' '}
+ error level. The line has the same fields as a retry line, without{' '}
+ wait_ms. The job then raises the exception, which is an error{' '}
+ line, unless retry_on was given a block.
+
+ discard_on caught the job's exception and dropped the job, at{' '}
+ error level, as Rails logs it. A finish line follows.
+
Every job line has:
+0 when it's enqueued, 1{' '}
+ on its first run.
+ >,
+ ],
+ [
+ 'provider_job_id',
+ "The backend's own ID for the job (a Sidekiq jid, a SolidQueue row ID), as a String, when the backend sets one.",
+ ],
+ [
+ 'arguments',
+ <>
+ The job's arguments, filtered and scrubbed (see{' '}
+
+ Arguments
+
+ ). Left out when the job's log_arguments is off.
+ >,
+ ],
+ ['tags', "ActiveJob's tags."],
+ ]}
+ />
+ And per event:
+executions.
+ >,
+ ],
+ ['duration_ms', 'finish: how long perform took, in milliseconds.'],
+ ['finished_at', 'finish: when the job finished, the same time as ts.'],
+ ['error_class', "error, retry, retry_stopped, discard: the exception's class."],
+ ['msg', "error, retry, retry_stopped, discard: the exception's message."],
+ ['backtrace', 'error: the backtrace, up to max_backtrace_lines (default 20).'],
+ ['wait_ms', 'retry: how long until the job runs again, in milliseconds.'],
+ ]}
+ />
+
+ log_arguments is on, which is
+ Rails' default. Turn it off in a job whose arguments you don't want in your logs
+ (the example below).
+ filter_parameters, and
+ strings are scrubbed (email addresses, card numbers and the rest). A plain String argument
+ has no key, so it's only scrubbed: a token passed as a String is logged unless one of
+ the scrubbers matches it. See{' '}
+
+ Filtering Sensitive Data
+
+ .
+ gid://my-app/User/5).
+ ActionMailer::MailDeliveryJob (every deliver_later) and its
+ subclasses, only the first three arguments are logged: the mailer, the mail and the
+ delivery method, as in{' '}
+ ["UserMailer", "reset_password", "deliver_now"]
+ . The mail's own arguments are left out, because mailers are often passed tokens as
+ plain Strings (Devise passes its confirmation, password reset and invitation tokens that
+ way), which no key-based filter can find.
+
+ config.integrations.enable_activejob (default true), and each
+ job's log_arguments. With the integration off, Rails' own ActiveJob
+ lines are written instead, as text in msg. They include the arguments of every
+ job whose log_arguments is on, mail arguments too. See{' '}
+
+ Configuration
+ {' '}
+ for the other settings.
+
before_enqueue callback stopped has no line, and one that a{' '}
+ before_perform callback stopped has a start line only.
+ | + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
+ GoodJob runs ActiveJob jobs, so its jobs are logged by the{' '}
+
+ ActiveJob integration
+ {' '}
+ (enqueue, schedule, start, finish and error lines). This integration covers GoodJob itself.
+ When LogStruct is on, GoodJob.logger is a LogStruct logger, so GoodJob's
+ own messages are JSON lines. Each error GoodJob passes to its thread error handler is one
+ structured error line, and then your app's on_thread_error{' '}
+ handler runs as before. LogStruct also turns itself on in the good_job start{' '}
+ process.
+
GoodJob.logger is replaced with a LogStruct
+ logger named GoodJob (written as prog).
+ GoodJob._on_thread_error. LogStruct logs each one, then calls
+ your app's handler, so an error tracker that handler reports to still gets it. It
+ works whether the app sets the handler in config.good_job.on_thread_error{' '}
+ (which GoodJob applies after LogStruct's setup) or with{' '}
+ GoodJob.on_thread_error = at any time. The app's handler runs even if
+ LogStruct's line fails.
+ good_job start.
+ {' '}
+ LogStruct counts GoodJob's executable as a server process, like Puma and Sidekiq, so
+ LogStruct is on there in its enabled environments (test and{' '}
+ production by default). It stops the executable adding a plain text STDOUT
+ logger beside LogStruct's (GoodJob::CLI.log_to_stdout = false), so each
+ line is written once, as JSON. In async mode GoodJob runs inside your web server's
+ process, where LogStruct is on because it's a server.
+
+ GoodJob's own messages are job log lines, with GoodJob's
+ tags in tags:
+
+ A line GoodJob's logger writes while a job runs also has the job's{' '}
+ job_id, job_class, queue_name,{' '}
+ executions, scheduled_at and priority.
+
+ Each thread error is a job error line at error level:
+
+ GoodJob also passes every failed job's exception to the thread error handler. The
+ ActiveJob integration has already logged it, with the job's ID and class, so LogStruct
+ doesn't log it again here; your handler still gets it. Exceptions that aren't{' '}
+ StandardErrors (GoodJob's handled_exceptions setting can let
+ them through) are logged and passed on too.
+
+ GoodJob's notifier passes database connection errors to the thread error handler only + once they repeat. Until then GoodJob logs each one itself, and LogStruct keeps that line, + without the backtrace: +
+
+ job log lines from GoodJob.logger:
+
+ job error lines (thread errors):
+
msg).
+ >,
+ ],
+ ['backtrace', 'The backtrace, up to max_backtrace_lines (default 20).'],
+ ['pid', 'The process ID.'],
+ ['tid', "The thread's object ID, in base 36."],
+ ]}
+ />
+
+ GoodJob._on_thread_error.
+ GoodJob::CurrentThread.job or
+ GoodJob 3's GoodJob::CurrentThread.execution.
+
+ config.integrations.enable_goodjob (default true). The job lines
+ come from the ActiveJob integration (config.integrations.enable_activejob). See{' '}
+
+ Configuration
+ {' '}
+ for the other settings.
+
GoodJob::LogSubscriber.loggers no longer get job
+ errors; they're in the structured lines.
+
- LogStruct integrates with many popular gems and Rails components to provide comprehensive
- structured logging throughout your application. These integrations automatically hook into
- important events and capture relevant context for better observability.
+ Each integration turns the lines a Rails component or gem writes into LogStruct lines, or
+ adds lines of its own. An integration is set up only when its gem is loaded, and each one
+ can be turned off with its config.integrations.enable_* setting (see{' '}
+
+ Integration Configuration
+
+ ). All of them are on by default except SQL logging.
+
+ The example lines below are generated in your browser to show each line's shape. Tick + "Apply filtering" to see what LogStruct's filters do to them. The pages for + ActiveJob, ActionMailer, Sidekiq, GoodJob, SQL and Security show lines written by the real + code, and every field is described in{' '} + + Log Fields + + .
{description}
+ {docsPath && ( ++ + {docsPath.startsWith('/docs/integrations/') + ? `The ${title} integration in detail` + : 'Error handling settings'} + +
+ )} {/* Show Ruby code example if one is specified */} {configuration_code && ( @@ -65,6 +91,7 @@ export default function IntegrationsPage() { logType={logType} events={events} preferredEvent={preferredEvent} + now={EXAMPLES_TIME} /> ); })()} diff --git a/docs/app/docs/integrations/security/page.tsx b/docs/app/docs/integrations/security/page.tsx new file mode 100644 index 00000000..aa61a6df --- /dev/null +++ b/docs/app/docs/integrations/security/page.tsx @@ -0,0 +1,321 @@ +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { CodeBlock } from '@/components/code-block'; +import { EditPageLink } from '@/components/edit-page-link'; +import { HeadingWithAnchor } from '@/components/heading-with-anchor'; +import { RubyCodeExample } from '@/components/ruby-code-example'; +import { Callout } from '@/components/ui/callout'; + +function FieldTable({ rows }: { rows: [string, ReactNode][] }) { + return ( +| + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
DebugExceptions interceptor. Rails
+ rescues it and responds as usual. The error isn't reported.
+ >,
+ ],
+];
+
+export default function SecurityIntegrationPage() {
+ return (
+
+ LogStruct writes a security line when Rails blocks a request for a host it
+ doesn't allow, when a request fails Rails' CSRF checks, and when Rails finds
+ conflicting client IP headers (IP spoofing). Rails still handles each request as it would
+ without LogStruct. CSRF and IP spoofing errors are also reported to your error tracker by
+ default. Blocked hosts are logged only.
+
+ Rails' HostAuthorization middleware blocks requests whose{' '}
+ Host header isn't in config.hosts, and it's only active
+ where config.hosts isn't empty. Rails fills it in development; in test and
+ production it's empty unless your app sets it. LogStruct gives the middleware a
+ response app that writes a warn line, then responds:
+
config.host_authorization[:response_app], if it set
+ one (other host_authorization settings such as exclude are
+ kept);
+
+ This line has no req_id: HostAuthorization runs before the
+ middleware that sets it. It isn't reported, whatever{' '}
+ error_handling_modes.security_errors says.
+
+ With protect_from_forgery with: :exception (Rails' default for{' '}
+ ActionController::Base), Rails raises{' '}
+ ActionController::InvalidAuthenticityToken for a request without a valid CSRF
+ token, and ActionController::InvalidCrossOriginRequest when another site embeds
+ a JavaScript response in a <script> tag. LogStruct registers an
+ interceptor with Rails' DebugExceptions middleware, which is in the stack
+ in every environment and sees the error before ShowExceptions turns it into a
+ response. Both errors are an error line with the event{' '}
+ csrf_violation and the error's message in msg:
+
+ Rails then responds as usual: 422 Unprocessable Content for both errors (in production, your{' '}
+ public/422.html). Rails' own lines for the request are still written too,
+ such as its "Can't verify CSRF token authenticity." warning.
+
+ Rails raises ActionDispatch::RemoteIp::IpSpoofAttackError from{' '}
+ request.remote_ip when the Client-IP and{' '}
+ X-Forwarded-For headers disagree (with{' '}
+ config.action_dispatch.ip_spoofing_check on, the default). It raises it only
+ where something reads remote_ip: a request with those headers to an action that
+ never reads it gets no line and its usual response. Where it's raised, the line is{' '}
+ warn level:
+
+ Rails doesn't map this error to a status, so it responds with a 500 (in production,
+ your public/500.html); in tests that don't render exceptions, the error is
+ raised. An error raised in your controllers is logged by the DebugExceptions{' '}
+ interceptor. One raised by a middleware outside ShowExceptions is logged by
+ LogStruct's error middleware, which sits just before{' '}
+ ActionDispatch::RemoteIp and raises the error again for your server to handle.
+
Every security line has:
+remote_ip on CSRF lines, Rack's{' '}
+ request.ip on blocked host and IP spoofing lines (and on CSRF lines when
+ the request's IP headers are spoofed).
+ >,
+ ],
+ ['user_agent', 'The User-Agent header.'],
+ [
+ 'referer',
+ 'The Referer header, without its query and fragment, which can carry reset or confirmation tokens.',
+ ],
+ ['msg', "What happened (for CSRF and IP spoofing, the error's message)."],
+ [
+ 'req_id',
+ 'The request ID, on CSRF and IP spoofing lines, as on every line logged during the request.',
+ ],
+ ]}
+ />
+ And per event:
+
+ IP addresses are written as they are unless you turn on filters.ip_addresses{' '}
+ (see{' '}
+
+ Filtering Sensitive Data
+
+ ), which writes them as [IP], in msg too.
+
+ The CSRF and IP spoofing lines are written in every mode.{' '}
+ config.error_handling_modes.security_errors decides what happens next. A
+ reported error is passed once to your error tracker (or error_reporting_handler
+ ) with the request's path, method, user agent and referer. "Local
+ environments" are those in local_environments (development and test by
+ default).
+
| + security_errors + | ++ After the line + | +
|---|---|
| {mode} | +{result} | +
config.integrations.enable_host_authorization (default true):
+ blocked host lines.
+ config.integrations.enable_rack_error_handler (default true):
+ CSRF and IP spoofing lines, and the error lines for errors that get past Rails'{' '}
+ ShowExceptions. With it on, when Rails can't parse a request's
+ params (malformed JSON, a bad %-encoding), its debug line gives the body's size and
+ type instead of the body, and the error line leaves out the input the parser's
+ message quotes. Nothing could filter either.
+ config.error_handling_modes.security_errors (default Report).
+ + See{' '} + + Error Handling Configuration + {' '} + for the modes. +
+protect_from_forgery with: :null_session or :reset_session, only
+ Rails' own warning line is written. Rails' test environment turns forgery
+ protection off, so tests have no CSRF lines unless they turn it on.
+ rescue_from: they never
+ reach DebugExceptions.
+ remote_ip.
+ rails error lines, written where
+ Rails would log its own block of text, with the response's status. They
+ aren't reported by LogStruct: your error tracker's Rails integration reports them,
+ as it would without LogStruct. See{' '}
+
+ Troubleshooting
+
+ .
+ | + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
+ When LogStruct is on and Sidekiq is loaded, Sidekiq's logger is a LogStruct logger, so
+ Sidekiq's own lines and your Sidekiq.logger calls are JSON lines. In the
+ Sidekiq process, every line logged while a job runs has the job's ID and class in{' '}
+ ctx, and a failed job is one warn line with the error instead of
+ Sidekiq's three text lines.
+
config/initializers run, so the lines
+ Sidekiq logs while an initializer configures it are JSON too. The logger is named{' '}
+ Sidekiq-Server in the Sidekiq process and Sidekiq-Client{' '}
+ everywhere else, and the name is written as prog.
+ Rails.logger, Sidekiq.logger or any other SemanticLogger logger)
+ gets the job's jid and class in ctx, so a
+ query on ctx.jid returns every line of one job run. For an ActiveJob job,{' '}
+ class is the job's own class, not Sidekiq's wrapper.
+
+ Sidekiq logs start when it starts a job, and done or{' '}
+ fail when the job ends. Each is a sidekiq log line
+ with Sidekiq's own job context in ctx; done and{' '}
+ fail add elapsed, in seconds.
+
+ Rails.logger.info("Rendering invoice PDF") inside the job writes:
+
+ A Hash logged with its own ctx keeps its keys and gains the job's.
+
+ A String passed to Sidekiq.logger is a sidekiq log{' '}
+ line with the message in msg, Sidekiq's context in ctx, and
+ the process and thread IDs. A Hash, a LogStruct log struct or an exception is logged as it
+ is, like on Rails.logger: a Hash's keys are the line's fields and are
+ filtered like any other Hash, and its src is app unless the Hash
+ sets one. Sidekiq logs some of its settings as a Hash, so their keys are filtered too. This
+ line came from{' '}
+
+ Sidekiq.logger.info({msg: "Synced invoice", invoice_id: 42, api_token:
+ token})
+ {' '}
+ during a job:
+
+ When a job raises, Sidekiq calls its error handlers. LogStruct's handler writes one{' '}
+ sidekiq error line at warn level, the level
+ Sidekiq's default handler uses. It doesn't include the job's arguments:
+ Sidekiq's handler logged the whole job, arguments included, as a JSON string, which
+ LogStruct can't filter.
+
error_context says what Sidekiq was doing.
+ job error line with the job's ID and
+ arguments, and this line is skipped for that error. It's still written for errors
+ raised before ActiveJob ran the job, such as a job class that a deploy removed.
+
+ sidekiq log lines (a String logged with{' '}
+ Sidekiq.logger):
+
class, jid, and elapsed{' '}
+ (seconds) on done and fail. Left out outside a job.
+ >,
+ ],
+ ['pid', 'The process ID.'],
+ ['tid', "Sidekiq's thread ID, as Sidekiq writes it in its own logs."],
+ [
+ 'prog',
+ <>
+ Sidekiq-Server or Sidekiq-Client.
+ >,
+ ],
+ ]}
+ />
+
+ sidekiq error lines:
+
Job raised exception.
+ >,
+ ],
+ ['queue_name', "The job's queue."],
+ [
+ 'retry_count',
+ <>
+ The job's retry_count when Sidekiq fetched it: not there on the
+ first run, 0 on the first retry.
+ >,
+ ],
+ [
+ 'ctx',
+ <>
+ The job's jid and class.
+ >,
+ ],
+ ]}
+ />
+
+ Sidekiq module
+ and adds the job context. Sidekiq 6 has no Sidekiq::Config::ERROR_HANDLER, so
+ its own error handler stays: a failed job is three warn lines (the job as a
+ JSON string with its arguments, the error, and the backtrace), written through
+ LogStruct's logger.
+
+ config.integrations.enable_sidekiq (default true). With it off, or
+ with LogStruct off, Sidekiq keeps its own logger and error handler. If an initializer turns
+ either off after LogStruct replaced the logger, LogStruct puts Sidekiq's logger back.
+ See{' '}
+
+ Configuration
+ {' '}
+ for the other settings.
+
jid.
+ retry_count says which run failed.
+ ctx included, are filtered and scrubbed as described in{' '}
+
+ Filtering Sensitive Data
+
+ .
+ | + Field + | ++ Value + | +
|---|---|
| {field} | +{value} | +
+ SQL logging is off by default. With{' '}
+ config.integrations.enable_sql_logging = true, LogStruct subscribes to
+ ActiveRecord's sql.active_record events and writes an app{' '}
+ database line for each query that takes at least{' '}
+ sql_slow_query_threshold milliseconds (100 by default). It detaches Rails'
+ own SQL log lines while it's on.
+
+ Each query is one line at info level. These two were logged with{' '}
+ sql_slow_query_threshold = 0 in the test environment, where bind values are
+ logged:
+
<name> executed.
+ >,
+ ],
+ [
+ 'sql',
+ 'The SQL, scrubbed like any other string (email addresses, card numbers and so on), but not filtered.',
+ ],
+ [
+ 'name',
+ <>
+ ActiveRecord's name for the query, such as User Load, or{' '}
+ SQL Query when it has none.
+ >,
+ ],
+ ['duration_ms', 'How long the query took, in milliseconds, to two decimal places.'],
+ ['row_count', 'The number of rows, when ActiveRecord reports it.'],
+ [
+ 'adapter',
+ <>
+ The connection adapter's class name, such as PostgreSQLAdapter.
+ >,
+ ],
+ [
+ 'bind_params',
+ <>
+ The bind values, filtered (see{' '}
+
+ Bind values
+
+ ). Left out when sql_log_bind_params is off.
+ >,
+ ],
+ [
+ 'db_name',
+ <>
+ The database name, from adapters that have current_database (PostgreSQL
+ and MySQL).
+ >,
+ ],
+ ['pool_size', "The size of the connection's pool."],
+ ['active_count', 'How many connections in the pool are in use.'],
+ [
+ 'op_type',
+ <>
+ The SQL's first word, in capitals (SELECT, INSERT,{' '}
+ BEGIN, ...).
+ >,
+ ],
+ [
+ 'table_names',
+ <>
+ The tables named after FROM, JOIN, UPDATE and{' '}
+ INTO, found with a regular expression: for a schema-qualified name such
+ as "public"."users" it's the schema's name.
+ >,
+ ],
+ ]}
+ />
+
+ sql_slow_query_threshold milliseconds (default{' '}
+ 100.0). With 0 or nil, every query is logged,
+ transactions' BEGIN and COMMIT included.
+ SCHEMA or CACHE (query cache hits),
+ queries on schema_migrations or ar_internal_metadata, and{' '}
+ SHOW, DESCRIBE and EXPLAIN statements.
+
+ While SQL logging is on, LogStruct detaches Rails'{' '}
+ ActiveRecord::LogSubscriber and turns off{' '}
+ ActiveRecord.verbose_query_logs (the "↳ caller" lines), so each query
+ is logged once. With SQL logging off, Rails' own lines stay: debug-level lines with the
+ query as text in msg, which aren't written at the info level
+ that Rails' generated production config sets.
+
sql_log_bind_params defaults to true unless{' '}
+ Rails.env is production. An environment with another name, such
+ as staging, logs bind values unless you turn it off.
+ filter_parameters or filter_keys_with_hashes (email{' '}
+ by default) is written as [FILTERED]. In the lines above, that's{' '}
+ email, password_digest and reset_token.
+ <3 bytes of binary data>).
+ [FILTERED] when they look like a secret: longer
+ than 50 characters, 32 or more hex digits, 20 or more Base64 characters, a JWT, a{' '}
+ Bearer token, or text containing password, secret,{' '}
+ token, key or auth.
+ config.integrations.enable_sql_logging (default false).
+ config.integrations.sql_slow_query_threshold, in milliseconds, as a Float
+ (default 100.0).
+ config.integrations.sql_log_bind_params (default true unless{' '}
+ Rails.env is production).
+ + See{' '} + + Configuration + {' '} + for the other settings. +
+sql field isn't filtered. Values written into the SQL itself (a
+ string condition such as where("token = '...'"), or every
+ value when prepared_statements is off) are logged, scrubbed but not filtered.
+ req_id, so a query can be
+ matched to the request that ran it.
+