diff --git a/CHANGELOG.md b/CHANGELOG.md index 671b61b0..b0bb710d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-23 + +### Changed + +- **Breaking**: CarrierWave lines log `file_id` as an HMAC of the file's identifier (keyed like email hashes, see `hash_salt`), and no longer log `filename`, `store_path` or `ext`. CarrierWave names stored files after the uploaded file by default, and an uploaded file's name often names a person. `LogField::Filename`, `LogField::StorePath` and `LogField::Extension` are removed. +- **Breaking**: A filtered Hash's summary has its `_keys_count` but not its first 10 keys (`_keys`): a Hash's keys can be secrets themselves, like tokens or email addresses used as keys. +- **Breaking**: `additional_data` can't set any field of its line's own type, whether or not the line has it (an error line's `msg`, `backtrace` or `error_message`), or `req_id`. Like `src`, `evt`, `lvl` and `ts`, such a key is left out and handled as a LogStruct error (raised in `local_environments`). It used to replace the field. Other types' field names, like `name` on a plain line, can still be used. +- **Filtering**: Without a `hash_salt`, values hashed before the Rails app has its `secret_key_base` use a random key for each process, instead of a fixed key that was public in LogStruct's source. Set `hash_salt` to keep these hashes the same across processes. +- **Development**: `task ci` checks the locked Ruby and docs dependencies for known advisories, with `bundler-audit` and `pnpm audit`. + +### Fixed + +- **Formatting**: Hash keys longer than `max_string_bytes` are left out with their values, and counted in `_oversized_keys`. A request's parameter names could make a line any size. +- **Formatting**: `ActionController::Parameters` are written like a Hash, within the size limits, instead of being converted whole first. Lograge's `params` and Ahoy's `properties` are converted at their first level only, and only as many keys as the line keeps; `_more_keys` and `_oversized_keys` count the rest. +- **Filtering**: A Rails filter block with three arguments gets a copy of the parameters that stops at 10,000 values, 20 levels of nesting or a value that contains itself. Past those, every key the block would check is filtered. The parameters used to be copied whole. +- **Errors**: An error raised while formatting, serializing or filtering a value is reported with its class and backtrace but not its message, which can quote the value. Errors in Rails filter blocks and LogStruct's filter matchers aren't reported with the key, which can be the secret. An object whose `as_json` raises is logged as `[SERIALIZATION_ERROR]` instead of its `to_s`, and its `inspect` isn't reported. +- **Formatting**: A Hash key that isn't a String or Symbol is written as its string, scrubbed, so an email address in its `to_s` is scrubbed. +- **Filtering**: A value longer than `max_string_bytes` is hashed by its first `max_string_bytes` bytes and its length. +- **Development**: Update Next.js to 16.4.1 for the docs site, which fixes three advisories in features the static site doesn't use, and the test-only Dalli to 3.2.13. + ## [0.2.2] - 2026-10-21 ### Changed diff --git a/Gemfile b/Gemfile index 27c5e046..aca235c9 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,8 @@ gem "sorbet-typescript" # Essential testing gems that don't have Ruby version restrictions group :test do - gem "dalli" + gem "dalli", "~> 3.2.13" + gem "bundler-audit", require: false gem "minitest", "~> 5.20" gem "minitest-reporters", "~> 1.6" gem "simplecov", "~> 0.22", require: false diff --git a/Gemfile.lock b/Gemfile.lock index da51faf2..a06716b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - logstruct (0.2.2) + logstruct (0.3.0) actionpack (>= 7.1) activesupport (>= 7.1) globalid (>= 1.0) @@ -105,6 +105,9 @@ GEM bugsnag (6.27.1) concurrent-ruby (~> 1.0) builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) carrierwave (3.1.4) activemodel (>= 6.0.0) activesupport (>= 6.0.0) @@ -119,7 +122,8 @@ GEM connection_pool (2.5.0) content_disposition (1.0.0) crass (1.0.7) - dalli (3.2.8) + dalli (3.2.13) + logger date (3.4.1) debug (1.10.0) irb (~> 1.10) @@ -460,9 +464,10 @@ DEPENDENCIES amazing_print bigdecimal bugsnag (~> 6.26) + bundler-audit carrierwave (~> 3.0) climate_control (~> 1.2) - dalli + dalli (~> 3.2.13) debug dotenv-rails (~> 3.1) drb diff --git a/Taskfile.yml b/Taskfile.yml index 231bf6fe..4c23ed6f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -64,6 +64,13 @@ tasks: - docs:test - test - ruby:tapioca:verify + - security + + security: + desc: Check locked Ruby and docs dependencies for known advisories + cmds: + - bundle exec bundler-audit check --update + - cd docs && pnpm audit fix: desc: Auto-fix lint issues then run the full validation workflow (replaces scripts/all_write.sh) diff --git a/docs/app/docs/filtering-sensitive-data/page.tsx b/docs/app/docs/filtering-sensitive-data/page.tsx index 99317b77..2d1575a2 100644 --- a/docs/app/docs/filtering-sensitive-data/page.tsx +++ b/docs/app/docs/filtering-sensitive-data/page.tsx @@ -40,8 +40,7 @@ const FILTERED_VALUES_LINE = `{ "credentials": { "_filtered": { "_class": "Hash", - "_keys_count": 2, - "_keys": ["client_id", "client_secret"] + "_keys_count": 2 } }, "src": "app", @@ -463,8 +462,8 @@ export default function FilteringSensitiveDataPage() { ).
_keys_count) and its
- first 10 keys (_keys), scrubbed like any string. An empty Hash is{' '}
+ Hash: its class and how many keys it has (_keys_count).
+ Child keys are omitted because they may contain secrets. An empty Hash is{' '}
{'{"_class": "Hash", "_empty": true}'}.
- Outside a Rails app, without hash_salt, LogStruct uses a fixed key that is
- public in its source code.
+ Outside a Rails app, without hash_salt, LogStruct uses a random process key.
+ Set hash_salt if hashes must be stable across processes or restarts. Values
+ longer than max_string_bytes are hashed using their bounded prefix and total
+ byte length.
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.
+ is logged as it is: scrub it with a string_scrubbing_handler. CarrierWave
+ logs an HMAC of the file ID and omits the file name and storage path.
deliver_later job (
diff --git a/docs/app/docs/log-fields/example-lines.json b/docs/app/docs/log-fields/example-lines.json
index 37c4a953..db373235 100644
--- a/docs/app/docs/log-fields/example-lines.json
+++ b/docs/app/docs/log-fields/example-lines.json
@@ -387,14 +387,11 @@
"lvl": "info",
"ts": "2026-10-17T09:30:15.123+00:00",
"storage": "file",
- "file_id": "avatar.png",
+ "file_id": "a4570aeb17304689",
"uploader": "AvatarUploader",
"model": "User",
"mount_point": "avatar",
"version": "",
- "store_path": "uploads/user/avatar/1873/avatar.png",
- "ext": "png",
- "filename": "avatar.png",
"mime_type": "image/png",
"size": 48213,
"duration_ms": 12.7,
@@ -407,14 +404,11 @@
"lvl": "info",
"ts": "2026-10-17T09:30:15.123+00:00",
"storage": "file",
- "file_id": "avatar.png",
+ "file_id": "a4570aeb17304689",
"uploader": "AvatarUploader",
"model": "User",
"mount_point": "avatar",
"version": "",
- "store_path": "uploads/user/avatar/1873/avatar.png",
- "ext": "png",
- "filename": "avatar.png",
"mime_type": "image/png",
"size": 48213,
"prog": "Rails",
@@ -426,13 +420,11 @@
"lvl": "info",
"ts": "2026-10-17T09:30:15.123+00:00",
"storage": "file",
- "file_id": "avatar.png",
+ "file_id": "a4570aeb17304689",
"uploader": "AvatarUploader",
"model": "User",
"mount_point": "avatar",
"version": "",
- "store_path": "uploads/user/avatar/1873/avatar.png",
- "ext": "png",
"prog": "Rails",
"req_id": "6f1c9a2e-7b4d-4e8a-9c61-2d5b8f0e4a17"
},
@@ -791,8 +783,7 @@
"credentials": {
"_filtered": {
"_class": "Hash",
- "_keys_count": 2,
- "_keys": ["client_id", "client_secret"]
+ "_keys_count": 2
}
},
"note": "Call Linda on [PHONE]",
diff --git a/docs/app/docs/log-fields/page.tsx b/docs/app/docs/log-fields/page.tsx
index c83fffde..64a558d2 100644
--- a/docs/app/docs/log-fields/page.tsx
+++ b/docs/app/docs/log-fields/page.tsx
@@ -192,13 +192,17 @@ export default function LogFieldsPage() {
additional_data of {'{msg: "..."}'} replaces the message.
+ Use additional_data only for unknown fields. Put known fields in the log
+ type's schema.
src, evt, lvl and ts are reserved.
- LogStruct drops them and handles the ArgumentError as a LogStruct error
- (raised in local_environments, logged elsewhere).
+ src, evt, lvl, ts, req_id{' '}
+ and the fields of the line's own type are reserved, whether or not the line has them:
+ an error line's msg, backtrace and{' '}
+ error_message. LogStruct drops them and handles the{' '}
+ ArgumentError as a LogStruct error (raised in local_environments
+ , logged elsewhere). Other types' field names, like name on a plain
+ line, are free to use.
_class, _keys_count and its first 10 keys in{' '}
- _keys. An empty Hash is {'{"_class": "Hash", "_empty": true}'}.
+ A Hash: _class and _keys_count. An empty Hash is{' '}
+ {'{"_class": "Hash", "_empty": true}'}.
_class and _count. An empty Array is{' '}
@@ -344,7 +348,7 @@ export default function LogFieldsPage() {
_keys.
+ a summary.
@@ -380,7 +384,9 @@ export default function LogFieldsPage() {
max_string_bytes (default 32768, 32 KB): a longer string is cut to that many
bytes after it's scrubbed, and ends with{' '}
- "... (cut from N bytes)".
+ "... (cut from N bytes)". A longer Hash key and its value are
+ omitted;
+ _oversized_keys counts omitted keys.
filter_parameters or your{' '}
filter_keys, is replaced by a summary: its class, plus a Hash's key
- count and first 10 keys or an Array's item count. email and{' '}
- email_address values get a hash of the address too.
+ count or an Array's item count. email and email_address{' '}
+ values get a hash of the address too.
LogStruct::Log::Interfaces::AdditionalDataField and{' '}
LogStruct::Log::Shared::MergeAdditionalDataFields, for an optional{' '}
additional_data Hash whose keys go at the top level of the line. A key named{' '}
- src, evt, lvl or ts is a LogStruct
- error (logstruct_errors): raised in development and test, and logged and left
- out in production.
+ src, evt, lvl, ts or{' '}
+ req_id is a LogStruct error (logstruct_errors): raised in
+ development and test, and logged and left out in production. Your own fields win over
+ additional data when your serialize writes them after merging it.
diff --git a/docs/lib/log-generation/sample-data.ts b/docs/lib/log-generation/sample-data.ts
index 7443f38a..8af002ea 100644
--- a/docs/lib/log-generation/sample-data.ts
+++ b/docs/lib/log-generation/sample-data.ts
@@ -193,7 +193,6 @@ export const SampleHelpers = {
queue: (gen: RandomGen) => gen.sample(['default', 'mailers', 'critical', 'low']),
email: (gen: RandomGen) => `user${gen.randomInt(1, 999)}@example.com`,
emailArray: (gen: RandomGen) => [`user${gen.randomInt(1, 999)}@example.com`],
- filename: (gen: RandomGen) => gen.sample(['file.txt', 'image.png', 'data.json']),
mime: (gen: RandomGen) => gen.sample(['text/plain', 'image/png', 'application/json']),
url: (_gen: RandomGen) => 'https://example.com/file',
ip: (gen: RandomGen) => gen.sample(['127.0.0.1', '10.0.0.1', '192.168.1.1']),
@@ -291,8 +290,6 @@ export const SampleByLogField: Readonly<
[LogField.Uploader]: (_gen: RandomGen) => 'AvatarUploader',
[LogField.Model]: (_gen: RandomGen) => 'User',
[LogField.MountPoint]: (_gen: RandomGen) => 'avatar',
- [LogField.StorePath]: (_gen: RandomGen) => 'uploads/store/file.txt',
- [LogField.Extension]: (_gen: RandomGen) => '.jpg',
[LogField.Sql]: SampleHelpers.sql,
[LogField.Name]: SampleHelpers.name,
[LogField.RowCount]: (gen: RandomGen) => gen.randomInt(0, 100),
@@ -307,7 +304,6 @@ export const SampleByLogField: Readonly<
[LogField.Adapter]: (_gen: RandomGen) => 'PostgreSQLAdapter',
[LogField.ResourceClass]: (_gen: RandomGen) => 'User',
[LogField.AhoyEvent]: (_gen: RandomGen) => 'signup',
- [LogField.Filename]: SampleHelpers.filename,
[LogField.Properties]: (_gen: RandomGen) => ({ plan: 'pro' }),
[LogField.Data]: (_gen: RandomGen) => ({}),
[LogField.DownloadOptions]: (_gen: RandomGen) => ({ filename: 'file.txt' }),
diff --git a/docs/package.json b/docs/package.json
index 5f630edf..1348e37a 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -30,7 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
- "next": "16.4.0",
+ "next": "16.4.1",
"next-themes": "^0.4.6",
"react": "^19.3.0",
"react-dom": "^19.3.0",
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
index a0dabbd1..93fa3de4 100644
--- a/docs/pnpm-lock.yaml
+++ b/docs/pnpm-lock.yaml
@@ -42,8 +42,8 @@ importers:
specifier: ^0.544.0
version: 0.544.0(react@19.3.0)
next:
- specifier: 16.4.0
- version: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.64.0)(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ specifier: 16.4.1
+ version: 16.4.1(@babel/core@7.29.7)(@playwright/test@1.64.0)(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -58,7 +58,7 @@ importers:
version: 16.1.2(react@19.3.0)
recharts:
specifier: ^3.10.1
- version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1)
+ version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.1)(react@19.3.0)(redux@5.0.1)
tailwind-merge:
specifier: ^3.7.0
version: 3.7.0
@@ -699,57 +699,57 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
- '@next/env@16.4.0':
- resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==}
+ '@next/env@16.4.1':
+ resolution: {integrity: sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==}
- '@next/swc-darwin-arm64@16.4.0':
- resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==}
+ '@next/swc-darwin-arm64@16.4.1':
+ resolution: {integrity: sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.4.0':
- resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==}
+ '@next/swc-darwin-x64@16.4.1':
+ resolution: {integrity: sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.4.0':
- resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==}
+ '@next/swc-linux-arm64-gnu@16.4.1':
+ resolution: {integrity: sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.4.0':
- resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==}
+ '@next/swc-linux-arm64-musl@16.4.1':
+ resolution: {integrity: sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.4.0':
- resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==}
+ '@next/swc-linux-x64-gnu@16.4.1':
+ resolution: {integrity: sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.4.0':
- resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==}
+ '@next/swc-linux-x64-musl@16.4.1':
+ resolution: {integrity: sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.4.0':
- resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==}
+ '@next/swc-win32-arm64-msvc@16.4.1':
+ resolution: {integrity: sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.4.0':
- resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==}
+ '@next/swc-win32-x64-msvc@16.4.1':
+ resolution: {integrity: sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1647,6 +1647,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ baseline-browser-mapping@2.11.29:
+ resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
brace-expansion@2.1.7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
@@ -1681,6 +1686,9 @@ packages:
caniuse-lite@1.0.30001815:
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
+ caniuse-lite@1.0.30001816:
+ resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2417,8 +2425,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.4.0:
- resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==}
+ next@16.4.1:
+ resolution: {integrity: sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2541,6 +2549,10 @@ packages:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.30:
+ resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
pretty-format@30.5.1:
resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -2567,8 +2579,8 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
- react-is@19.3.0:
- resolution: {integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==}
+ react-is@19.3.1:
+ resolution: {integrity: sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
@@ -3672,30 +3684,30 @@ snapshots:
'@tybys/wasm-util': 0.10.4
optional: true
- '@next/env@16.4.0': {}
+ '@next/env@16.4.1': {}
- '@next/swc-darwin-arm64@16.4.0':
+ '@next/swc-darwin-arm64@16.4.1':
optional: true
- '@next/swc-darwin-x64@16.4.0':
+ '@next/swc-darwin-x64@16.4.1':
optional: true
- '@next/swc-linux-arm64-gnu@16.4.0':
+ '@next/swc-linux-arm64-gnu@16.4.1':
optional: true
- '@next/swc-linux-arm64-musl@16.4.0':
+ '@next/swc-linux-arm64-musl@16.4.1':
optional: true
- '@next/swc-linux-x64-gnu@16.4.0':
+ '@next/swc-linux-x64-gnu@16.4.1':
optional: true
- '@next/swc-linux-x64-musl@16.4.0':
+ '@next/swc-linux-x64-musl@16.4.1':
optional: true
- '@next/swc-win32-arm64-msvc@16.4.0':
+ '@next/swc-win32-arm64-msvc@16.4.1':
optional: true
- '@next/swc-win32-x64-msvc@16.4.0':
+ '@next/swc-win32-x64-msvc@16.4.1':
optional: true
'@parcel/watcher-android-arm64@2.6.0':
@@ -4503,6 +4515,8 @@ snapshots:
baseline-browser-mapping@2.11.28: {}
+ baseline-browser-mapping@2.11.29: {}
+
brace-expansion@2.1.7:
dependencies:
balanced-match: 1.0.2
@@ -4535,6 +4549,8 @@ snapshots:
caniuse-lite@1.0.30001815: {}
+ caniuse-lite@1.0.30001816: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -5412,25 +5428,25 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.64.0)(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.4.1(@babel/core@7.29.7)(@playwright/test@1.64.0)(@types/node@22.20.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- '@next/env': 16.4.0
+ '@next/env': 16.4.1
'@swc/helpers': 0.5.23
- baseline-browser-mapping: 2.11.28
- caniuse-lite: 1.0.30001815
- postcss: 8.5.29
+ baseline-browser-mapping: 2.11.29
+ caniuse-lite: 1.0.30001816
+ postcss: 8.5.30
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.4.0
- '@next/swc-darwin-x64': 16.4.0
- '@next/swc-linux-arm64-gnu': 16.4.0
- '@next/swc-linux-arm64-musl': 16.4.0
- '@next/swc-linux-x64-gnu': 16.4.0
- '@next/swc-linux-x64-musl': 16.4.0
- '@next/swc-win32-arm64-msvc': 16.4.0
- '@next/swc-win32-x64-msvc': 16.4.0
+ '@next/swc-darwin-arm64': 16.4.1
+ '@next/swc-darwin-x64': 16.4.1
+ '@next/swc-linux-arm64-gnu': 16.4.1
+ '@next/swc-linux-arm64-musl': 16.4.1
+ '@next/swc-linux-x64-gnu': 16.4.1
+ '@next/swc-linux-x64-musl': 16.4.1
+ '@next/swc-win32-arm64-msvc': 16.4.1
+ '@next/swc-win32-x64-msvc': 16.4.1
'@playwright/test': 1.64.0
sharp: 0.35.5(@types/node@22.20.5)
transitivePeerDependencies:
@@ -5531,10 +5547,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.2
+ postcss@8.5.30:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
pretty-format@30.5.1:
dependencies:
'@jest/react-is-18': react-is@18.3.1
- '@jest/react-is-19': react-is@19.3.0
+ '@jest/react-is-19': react-is@19.3.1
'@jest/schemas': 30.5.0
ansi-styles: 5.2.0
@@ -5553,7 +5575,7 @@ snapshots:
react-is@18.3.1: {}
- react-is@19.3.0: {}
+ react-is@19.3.1: {}
react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1):
dependencies:
@@ -5603,7 +5625,7 @@ snapshots:
react@19.3.0: {}
- recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1):
+ recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.1)(react@19.3.0)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)
clsx: 2.1.1
@@ -5613,7 +5635,7 @@ snapshots:
immer: 11.1.21
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
- react-is: 19.3.0
+ react-is: 19.3.1
react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1)
reselect: 5.2.0
tiny-invariant: 1.3.3
diff --git a/lib/log_struct/bounded_input.rb b/lib/log_struct/bounded_input.rb
new file mode 100644
index 00000000..26b4f914
--- /dev/null
+++ b/lib/log_struct/bounded_input.rb
@@ -0,0 +1,101 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "action_controller"
+
+module LogStruct
+ # Copies of logged Hashes and Arrays that stop before an input too large to copy in full
+ module BoundedInput
+ extend T::Sig
+
+ # A Rails filter block with three arguments gets every parameter, so a copy for it keeps what the formatter
+ # leaves out of the line. These limits only stop values no request sends: nesting deeper than the formatter
+ # writes, and more values than a form or JSON body has. A copy that reaches one is truncated, and the
+ # filters that read it fail closed.
+ MAX_DEPTH = 20
+ MAX_NODES = 10_000
+
+ # The keys that count what a Hash's copy left out. The formatter writes them as it writes its own counts:
+ # Rails' default filter_parameters include :_key, which would filter them.
+ COUNT_KEYS = T.let(Set[:_more_keys, :_oversized_keys].freeze, T::Set[Symbol])
+
+ class State < T::Struct
+ prop :remaining_nodes, Integer, default: MAX_NODES
+ prop :truncated, T::Boolean, default: false
+ end
+
+ sig do
+ params(value: T.untyped, state: State, depth: Integer, ancestors: T::Hash[T.untyped, T::Boolean]).returns(T.untyped)
+ end
+ def self.copy(value, state:, depth: 0, ancestors: {}.compare_by_identity)
+ if depth > MAX_DEPTH || ancestors.key?(value) || state.remaining_nodes <= 0
+ state.truncated = true
+ return nil
+ end
+ state.remaining_nodes -= 1
+
+ case value
+ when Hash, ::ActionController::Parameters
+ ancestors[value] = true
+ begin
+ result = {}
+ value.each_pair do |key, child|
+ break if state.truncated
+
+ result[key] = copy(child, state: state, depth: depth + 1, ancestors: ancestors)
+ end
+ result
+ ensure
+ ancestors.delete(value)
+ end
+ when Array
+ ancestors[value] = true
+ begin
+ result = []
+ value.each do |child|
+ break if state.truncated
+
+ result << copy(child, state: state, depth: depth + 1, ancestors: ancestors)
+ end
+ result
+ ensure
+ ancestors.delete(value)
+ end
+ else
+ value
+ end
+ end
+
+ # How many keys a Hash or ActionController::Parameters has, without converting its values
+ sig { params(hash: T.untyped).returns(Integer) }
+ def self.key_count(hash)
+ hash.is_a?(Hash) ? hash.size : hash.keys.size
+ end
+
+ # The first keys of a Hash or ActionController::Parameters as Symbols, for a log struct's Hash field, without
+ # converting the keys the formatter would leave out. The formatter writes max_hash_keys entries of a Hash,
+ # and the counts of the keys left out here are entries too: the last key's slot goes to _more_keys, and an
+ # omitted oversized key's slot to _oversized_keys, so the formatter writes both counts as they are.
+ sig { params(hash: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
+ def self.symbolize_keys(hash)
+ limits = LogStruct.config.limits
+ total = key_count(hash)
+ examined = (total > limits.max_hash_keys) ? [limits.max_hash_keys - 1, 0].max : total
+ result = T.let({}, T::Hash[Symbol, T.untyped])
+ oversized_keys = 0
+ hash.each_pair.with_index do |(key, value), index|
+ break if index >= examined
+
+ if key.to_s.bytesize > limits.max_string_bytes
+ oversized_keys += 1
+ next
+ end
+
+ result[key.to_s.to_sym] = value
+ end
+ result[:_more_keys] = total - examined if examined < total
+ result[:_oversized_keys] = oversized_keys if oversized_keys.positive?
+ result
+ end
+ end
+end
diff --git a/lib/log_struct/concerns/configuration.rb b/lib/log_struct/concerns/configuration.rb
index 28e3bb5d..3b038be4 100644
--- a/lib/log_struct/concerns/configuration.rb
+++ b/lib/log_struct/concerns/configuration.rb
@@ -259,7 +259,7 @@ def build_path_block_matcher(filter)
result = filter.call(key, value, root)
!!result || (value.is_a?(String) && value != original_value)
rescue => e
- handle_filter_error(e, filter, original_key || "")
+ handle_filter_error(e, filter)
true
end
ConfigStruct::PathFilterMatcher.new(callable: callable, label: filter.inspect)
@@ -290,11 +290,11 @@ def build_callable_filter_matcher(filter)
begin
!!filter.call(key)
rescue => e
- handle_filter_error(e, filter, key)
+ handle_filter_error(e, filter)
true
end
rescue => e
- handle_filter_error(e, filter, key)
+ handle_filter_error(e, filter)
true
end
ConfigStruct::FilterMatcher.new(callable: callable, label: filter.inspect)
@@ -305,11 +305,11 @@ def arity_for_filter(filter)
filter.respond_to?(:arity) ? filter.arity : 2
end
- sig { params(error: StandardError, filter: T.untyped, key: String).void }
- def handle_filter_error(error, filter, key)
+ # The key isn't reported: a key can be the secret (an email address or a token used as a key)
+ sig { params(error: StandardError, filter: T.untyped).void }
+ def handle_filter_error(error, filter)
context = {
filter: filter.class.name,
- key: key,
filter_label: begin
filter.inspect
rescue
@@ -317,7 +317,7 @@ def handle_filter_error(error, filter, key)
end
}
- LogStruct.handle_exception(error, source: Source::Internal, context: context)
+ LogStruct.handle_value_error(error, "filter_parameters entry failed", context: context)
end
end
end
diff --git a/lib/log_struct/concerns/error_handling.rb b/lib/log_struct/concerns/error_handling.rb
index 9b42400f..93b5d0dd 100644
--- a/lib/log_struct/concerns/error_handling.rb
+++ b/lib/log_struct/concerns/error_handling.rb
@@ -89,6 +89,16 @@ def filtered_context(context)
Thread.current[:logstruct_formatting_internal_error] = previous_internal_error
end
+ # Handle an error raised while LogStruct read a value it was logging, without the error's message: the
+ # message can quote that value, as a NoMethodError quotes its receiver's inspect before Ruby 3.4.
+ # failure says what failed, and the reported error keeps the original's class and backtrace.
+ sig { params(error: Exception, failure: String, context: T.nilable(T::Hash[Symbol, T.untyped])).void }
+ def handle_value_error(error, failure, context: nil)
+ reported = RuntimeError.new("#{error.class}: #{failure}")
+ reported.set_backtrace(error.backtrace)
+ handle_exception(reported, source: Source::Internal, context: context)
+ end
+
# Handle an error according to the configured error handling mode (log, report, raise, etc).
# Pass `log: false` when the caller has already logged the error in its own structured line.
sig { params(error: StandardError, source: Source, context: T.nilable(T::Hash[Symbol, T.untyped]), log: T::Boolean).void }
diff --git a/lib/log_struct/config_struct/filters.rb b/lib/log_struct/config_struct/filters.rb
index 04d92711..83919908 100644
--- a/lib/log_struct/config_struct/filters.rb
+++ b/lib/log_struct/config_struct/filters.rb
@@ -2,6 +2,7 @@
# frozen_string_literal: true
require "active_support/hash_with_indifferent_access"
+require_relative "../bounded_input"
module LogStruct
# A key's words, lowercase and joined by underscores, so that keys match however they're written:
@@ -38,8 +39,8 @@ def matches?(key, value)
# A filter that needs more than the key: Rails' entries with a dot (`card.number`) match a key by its
# path, and Rails gives filter blocks with three arguments the parameters around the key. The path
# starts at the line's top-level field (`["params", "card", "number"]`); `root` is that field's value,
- # or the whole line for a top-level key, with indifferent access as Rails gives it. Building it copies
- # every value inside it, so a matcher that doesn't read it sets uses_root to false and gets nil.
+ # or the whole line for a top-level key, with indifferent access as Rails gives it. A matcher
+ # that doesn't read the root sets uses_root to false and gets nil.
class PathFilterMatcher < T::Struct
extend T::Sig
@@ -50,7 +51,10 @@ class PathFilterMatcher < T::Struct
sig { params(path: T::Array[String], value: T.untyped, root: T.untyped).returns(T::Boolean) }
def matches?(path, value, root)
root = nil unless uses_root
- callable.call(path, value, root.is_a?(LazyRoot) ? root.value : root)
+ root = root.value if root.is_a?(LazyRoot)
+ return true if root.equal?(LazyRoot::UNAVAILABLE)
+
+ callable.call(path, value, root)
end
end
@@ -58,6 +62,8 @@ def matches?(path, value, root)
class LazyRoot
extend T::Sig
+ UNAVAILABLE = T.let(Object.new.freeze, Object)
+
sig { params(value: T.untyped).void }
def initialize(value)
@source = value
@@ -70,12 +76,20 @@ def initialize(value)
def value
return @value if @built
- @built = true
- @value = begin
- @source.is_a?(Hash) ? ActiveSupport::HashWithIndifferentAccess.new(@source) : @source
- rescue SystemStackError, StandardError
- @source
+ state = BoundedInput::State.new
+ bounded = BoundedInput.copy(@source, state: state)
+ @value = if state.truncated
+ UNAVAILABLE
+ elsif bounded.is_a?(Hash)
+ ActiveSupport::HashWithIndifferentAccess.new(bounded)
+ else
+ bounded
end
+ @built = true
+ @value
+ rescue SystemStackError, StandardError
+ @built = true
+ @value = UNAVAILABLE
end
end
@@ -86,7 +100,7 @@ class Filters < T::Struct
# A filtered value is replaced by a summary of it, with no length (which would say how long a
# secret is):
#
- # { _filtered: { _class: "Hash", _keys_count: 3, _keys: [:key1, :key2, :key3] } } # first 10 keys
+ # { _filtered: { _class: "Hash", _keys_count: 3 } }
# { _filtered: { _class: "Array", _count: 2 } }
# { _filtered: { _class: "String" } } # plus its _hash for filter_keys_with_hashes
#
diff --git a/lib/log_struct/config_struct/limits.rb b/lib/log_struct/config_struct/limits.rb
index fb8bbed4..018f7b90 100644
--- a/lib/log_struct/config_struct/limits.rb
+++ b/lib/log_struct/config_struct/limits.rb
@@ -21,7 +21,7 @@ class Limits < T::Struct
# Default: 20
prop :max_backtrace_lines, Integer, default: 20
- # Strings longer than this many bytes are cut, and say how long they were
+ # Strings longer than this many bytes are cut; oversized Hash keys and their values are omitted
# Default: 32768 (32 KB)
prop :max_string_bytes, Integer, default: 32_768
end
diff --git a/lib/log_struct/enums/log_field.rb b/lib/log_struct/enums/log_field.rb
index 531edbd6..b3027c29 100644
--- a/lib/log_struct/enums/log_field.rb
+++ b/lib/log_struct/enums/log_field.rb
@@ -120,7 +120,6 @@ class LogField < T::Enum
Storage = new(:storage)
Operation = new(:op)
FileId = new(:file_id)
- Filename = new(:filename)
MimeType = new(:mime_type)
Size = new(:size)
Metadata = new(:metadata)
@@ -134,8 +133,6 @@ class LogField < T::Enum
Model = new(:model)
MountPoint = new(:mount_point)
Version = new(:version)
- StorePath = new(:store_path)
- Extension = new(:ext)
# SQL-specific fields
Sql = new(:sql)
diff --git a/lib/log_struct/formatter.rb b/lib/log_struct/formatter.rb
index 3f0236d8..469dd351 100644
--- a/lib/log_struct/formatter.rb
+++ b/lib/log_struct/formatter.rb
@@ -10,6 +10,7 @@
require_relative "enums/event"
require_relative "enums/log_field"
require_relative "string_scrubber"
+require_relative "bounded_input"
require_relative "log"
require_relative "param_filters"
require_relative "multi_error_reporter"
@@ -80,13 +81,13 @@ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, p
return summarize_unprocessed_value(arg) if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(arg)
case arg
- when Hash, Array
+ when Hash, Array, ::ActionController::Parameters
ancestors[arg] = true
begin
- if arg.is_a?(Hash)
- process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
- else
+ if arg.is_a?(Array)
process_array(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
+ else
+ process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
end
ensure
ancestors.delete(arg)
@@ -107,7 +108,7 @@ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, p
scrub_string(String(object))
end
rescue => e
- LogStruct.handle_exception(e, source: Source::Internal)
+ LogStruct.handle_value_error(e, "GlobalID formatting failed")
"[GLOBALID_ERROR]"
end
end
@@ -125,26 +126,24 @@ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, p
when Numeric, TrueClass, FalseClass, NilClass
arg
else
- # Structs, ActionController::Parameters and other objects: filter and scrub what
+ # Structs and other objects: filter and scrub what
# to_json would write
process_values(arg.as_json, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
end
rescue SystemStackError, StandardError => e
# A SystemStackError comes from an object whose as_json refers back to itself
- e = RuntimeError.new("#{e.class}: #{e.message}") unless e.is_a?(StandardError)
- # Report error through LogStruct's framework
context = {
processor_method: "process_values",
value_type: arg.class.name,
recursion_depth: recursion_depth
}
- LogStruct.handle_exception(e, source: Source::Internal, context: context)
+ LogStruct.handle_value_error(e, "value formatting failed", context: context)
summarize_unprocessed_value(arg)
end
sig do
params(
- hash: T::Hash[T.untyped, T.untyped],
+ hash: T.untyped,
recursion_depth: Integer,
ancestors: T::Hash[T.untyped, T::Boolean],
path: T.nilable(T::Array[String]),
@@ -158,13 +157,30 @@ def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
line_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(hash) : nil
max_keys = LogStruct.config.limits.max_hash_keys
- hash.each_with_index do |(key, value), index|
+ max_key_bytes = LogStruct.config.limits.max_string_bytes
+ oversized_keys = 0
+ hash.each_pair.with_index do |(key, value), index|
if index >= max_keys
- result[:_more_keys] = hash.size - max_keys
+ result[:_more_keys] = BoundedInput.key_count(hash) - max_keys
break
end
+ if BoundedInput::COUNT_KEYS.include?(key) && value.is_a?(Integer)
+ result[key] = value
+ next
+ end
+
+ if key.to_s.bytesize > max_key_bytes
+ oversized_keys += 1
+ next
+ end
+
+ # Checked again: each byte that isn't valid UTF-8 becomes a 3-byte U+FFFD
key = valid_utf8_key(key)
+ if key.to_s.bytesize > max_key_bytes
+ oversized_keys += 1
+ next
+ end
logstruct_field = recursion_depth == 0 && LOG_FIELD_KEYS.include?(key)
key_path = path && [*path, key.to_s]
# A top-level key's root is the line; the keys below it get the top-level field's value
@@ -173,7 +189,9 @@ def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
# Check if this key should be filtered at any depth
filtered = ParamFilters.should_filter_key?(key, value, logstruct_field: logstruct_field, path: key_path, root: key_root)
# Keys are scrubbed too: an email address can be a key
- result[StringScrubber.scrub_name(key)] = if !filtered
+ scrubbed_key = StringScrubber.scrub_name(key)
+ scrubbed_key = scrub_limited_string(scrubbed_key) if scrubbed_key.is_a?(String) && scrubbed_key.bytesize > max_key_bytes
+ result[scrubbed_key] = if !filtered
process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: key_path, root: children_root)
elsif value == RAILS_FILTERED_MASK
# Rails' parameter filter has already replaced the value
@@ -183,6 +201,8 @@ def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
end
end
+ result[:_oversized_keys] = oversized_keys if oversized_keys.positive?
+
result
end
@@ -205,7 +225,7 @@ def valid_utf8_key(key)
fixed = valid_utf8(name)
fixed.equal?(name) ? key : fixed.to_sym
else
- key
+ valid_utf8(key.to_s)
end
end
@@ -295,15 +315,10 @@ def log_value_to_hash(log_value, time:)
log_value.to_s
end
rescue => e
- # Handle serialization errors
- context = {
- object_class: log_value.class.name,
- object_inspect: log_value.inspect.truncate(100)
- }
- LogStruct.handle_exception(e, source: Source::Internal, context: context)
-
- # Fall back to the string representation to ensure we continue processing
- log_value.to_s
+ LogStruct.handle_value_error(e, "value serialization failed", context: {object_class: log_value.class.name})
+
+ # An object's string representation may contain the same secret that broke serialization.
+ "[SERIALIZATION_ERROR]"
end
end
diff --git a/lib/log_struct/hash_utils.rb b/lib/log_struct/hash_utils.rb
index 79bf2034..6d80cdfc 100644
--- a/lib/log_struct/hash_utils.rb
+++ b/lib/log_struct/hash_utils.rb
@@ -2,13 +2,14 @@
# frozen_string_literal: true
require "openssl"
+require "securerandom"
module LogStruct
# Utility module for hashing sensitive data
module HashUtils
- # The key when there's neither a configured hash_salt nor a Rails app with a secret_key_base.
- # It's public, so these hashes can be matched to a list of candidate values.
- FALLBACK_KEY = "l0g5t0p"
+ # The key without a hash_salt, before the Rails app has its secret_key_base. A fixed key would be public,
+ # and anyone could match its hashes to a list of candidate emails; these hashes differ between processes.
+ FALLBACK_KEY = T.let(::SecureRandom.random_bytes(32).freeze, String)
class << self
extend T::Sig
@@ -17,7 +18,13 @@ class << self
sig { params(value: String).returns(String) }
def hash_value(value)
length = LogStruct.config.filters.hash_length
- OpenSSL::HMAC.hexdigest("SHA256", hash_key, value)[0...length] || "error"
+ max_bytes = LogStruct.config.limits.max_string_bytes
+ input = if value.bytesize > max_bytes
+ value.byteslice(0, max_bytes).to_s.b + "\0".b + value.bytesize.to_s.b
+ else
+ value
+ end
+ OpenSSL::HMAC.hexdigest("SHA256", hash_key, input)[0...length] || "error"
end
# The configured hash_salt, or a key derived from the app's secret_key_base
diff --git a/lib/log_struct/integrations/ahoy.rb b/lib/log_struct/integrations/ahoy.rb
index d52c2787..ff627ab9 100644
--- a/lib/log_struct/integrations/ahoy.rb
+++ b/lib/log_struct/integrations/ahoy.rb
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true
+require_relative "../bounded_input"
+
module LogStruct
module Integrations
# Ahoy analytics integration. If Ahoy is present, prepend a small hook to
@@ -45,10 +47,9 @@ def track(name, *args)
# Event properties can come from the browser, so they can be anything; a non-Hash is logged as its class
sig { params(properties: T.untyped).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def self.properties_for_log(properties)
- properties = properties.to_unsafe_h if properties.respond_to?(:to_unsafe_h)
case properties
when nil then nil
- when Hash then properties.to_h { |key, value| [key.to_s.to_sym, value] }
+ when Hash, ::ActionController::Parameters then BoundedInput.symbolize_keys(properties)
else {_class: properties.class.name}
end
end
diff --git a/lib/log_struct/integrations/carrierwave.rb b/lib/log_struct/integrations/carrierwave.rb
index 1e0ccfef..74d6a6d7 100644
--- a/lib/log_struct/integrations/carrierwave.rb
+++ b/lib/log_struct/integrations/carrierwave.rb
@@ -6,6 +6,7 @@
rescue LoadError
# CarrierWave gem is not available, integration will be skipped
end
+require_relative "../hash_utils"
module LogStruct
module Integrations
@@ -49,7 +50,6 @@ def store!(*args)
# file_fields is a Hash Sorbet can't check against the struct's keyword arguments
Log::CarrierWave::Upload.new(
**T.unsafe(file_fields),
- filename: file&.filename,
mime_type: file&.content_type,
size: file&.size,
duration_ms: duration_ms
@@ -72,7 +72,6 @@ def download!(uri, remote_headers = {})
log_file_operation do
Log::CarrierWave::Download.new(
**T.unsafe(file_fields),
- filename: file&.filename,
mime_type: file&.content_type,
size: file&.size
)
@@ -87,13 +86,11 @@ def download!(uri, remote_headers = {})
def file_fields
{
storage: storage.class.name.to_s.split("::").last.to_s.downcase.to_sym,
- file_id: identifier.to_s,
+ file_id: HashUtils.hash_value(identifier.to_s),
uploader: self.class.name,
model: model&.class&.name,
mount_point: mounted_as&.to_s,
- version: version_name&.to_s,
- store_path: store_path,
- extension: file&.extension
+ version: version_name&.to_s
}
end
diff --git a/lib/log_struct/integrations/lograge.rb b/lib/log_struct/integrations/lograge.rb
index 12aea7fe..3665b1af 100644
--- a/lib/log_struct/integrations/lograge.rb
+++ b/lib/log_struct/integrations/lograge.rb
@@ -6,6 +6,7 @@
rescue LoadError
# Lograge gem is not available, integration will be skipped
end
+require_relative "../bounded_input"
module LogStruct
module Integrations
@@ -196,12 +197,12 @@ def apply_custom_options(event, options)
result = custom_options_proc.call(event, options)
return if !result.is_a?(Hash) || result.equal?(options)
- options.merge!(result.to_h { |key, value| [key.to_s.to_sym, value] })
+ options.merge!(BoundedInput.symbolize_keys(result))
end
sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(Log::Request) }
def build_request_log(data)
- normalized_data = normalize_lograge_data(data)
+ normalized_data = BoundedInput.symbolize_keys(data)
# Coerce common fields to expected types
status = ((s = normalized_data[:status]) && s.respond_to?(:to_i)) ? s.to_i : s
@@ -210,7 +211,7 @@ def build_request_log(data)
db = ((b = normalized_data[:db]) && b.respond_to?(:to_f)) ? b.to_f : b
params = normalized_data[:params]
- params = params.deep_symbolize_keys if params&.respond_to?(:deep_symbolize_keys)
+ params = BoundedInput.symbolize_keys(params) if params.is_a?(Hash) || params.is_a?(::ActionController::Parameters)
additional_data = extract_additional_data(normalized_data)
@@ -237,13 +238,6 @@ def build_request_log(data)
)
end
- sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
- def normalize_lograge_data(data)
- data.each_with_object({}) do |(key, value), normalized|
- normalized[key.to_s.to_sym] = value
- end
- end
-
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def extract_additional_data(data)
extras = T.let({}, T::Hash[Symbol, T.untyped])
diff --git a/lib/log_struct/log/action_mailer/delivered.rb b/lib/log_struct/log/action_mailer/delivered.rb
index 15806a57..a6dd9049 100644
--- a/lib/log_struct/log/action_mailer/delivered.rb
+++ b/lib/log_struct/log/action_mailer/delivered.rb
@@ -42,6 +42,16 @@ class Delivered < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/action_mailer/delivery.rb b/lib/log_struct/log/action_mailer/delivery.rb
index bf189225..57814a88 100644
--- a/lib/log_struct/log/action_mailer/delivery.rb
+++ b/lib/log_struct/log/action_mailer/delivery.rb
@@ -42,6 +42,16 @@ class Delivery < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/action_mailer/error.rb b/lib/log_struct/log/action_mailer/error.rb
index fe3aff17..17988c92 100644
--- a/lib/log_struct/log/action_mailer/error.rb
+++ b/lib/log_struct/log/action_mailer/error.rb
@@ -47,6 +47,16 @@ class Error < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::To, LogField::From, LogField::Subject, LogField::MessageId, LogField::MailerClass, LogField::MailerAction, LogField::AttachmentCount, LogField::ErrorClass, LogField::Message, LogField::Backtrace].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/carrierwave.rb b/lib/log_struct/log/carrierwave.rb
index 99a49304..c66b602c 100644
--- a/lib/log_struct/log/carrierwave.rb
+++ b/lib/log_struct/log/carrierwave.rb
@@ -21,8 +21,6 @@ class BaseFields < T::Struct
const :model, T.nilable(String), default: nil
const :mount_point, T.nilable(String), default: nil
const :version, T.nilable(String), default: nil
- const :store_path, T.nilable(String), default: nil
- const :extension, T.nilable(String), default: nil
Kwargs = T.type_alias do
{
@@ -31,9 +29,7 @@ class BaseFields < T::Struct
uploader: T.nilable(String),
model: T.nilable(String),
mount_point: T.nilable(String),
- version: T.nilable(String),
- store_path: T.nilable(String),
- extension: T.nilable(String)
+ version: T.nilable(String)
}
end
@@ -45,9 +41,7 @@ def to_kwargs
uploader: uploader,
model: model,
mount_point: mount_point,
- version: version,
- store_path: store_path,
- extension: extension
+ version: version
}
end
end
diff --git a/lib/log_struct/log/carrierwave/delete.rb b/lib/log_struct/log/carrierwave/delete.rb
index 04ac0cdd..2fe6a189 100644
--- a/lib/log_struct/log/carrierwave/delete.rb
+++ b/lib/log_struct/log/carrierwave/delete.rb
@@ -35,8 +35,6 @@ class Delete < T::Struct
const :model, T.nilable(String), default: nil
const :mount_point, T.nilable(String), default: nil
const :version, T.nilable(String), default: nil
- const :store_path, T.nilable(String), default: nil
- const :extension, T.nilable(String), default: nil
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
@@ -51,8 +49,6 @@ def to_h
h[LogField::Model] = model unless model.nil?
h[LogField::MountPoint] = mount_point unless mount_point.nil?
h[LogField::Version] = version unless version.nil?
- h[LogField::StorePath] = store_path unless store_path.nil?
- h[LogField::Extension] = extension unless extension.nil?
h
end
end
diff --git a/lib/log_struct/log/carrierwave/download.rb b/lib/log_struct/log/carrierwave/download.rb
index 276916f6..29813e15 100644
--- a/lib/log_struct/log/carrierwave/download.rb
+++ b/lib/log_struct/log/carrierwave/download.rb
@@ -35,11 +35,8 @@ class Download < T::Struct
const :model, T.nilable(String), default: nil
const :mount_point, T.nilable(String), default: nil
const :version, T.nilable(String), default: nil
- const :store_path, T.nilable(String), default: nil
- const :extension, T.nilable(String), default: nil
# Event-specific fields
- const :filename, T.nilable(String), default: nil
const :mime_type, T.nilable(String), default: nil
const :size, T.nilable(Integer), default: nil
@@ -56,9 +53,6 @@ def to_h
h[LogField::Model] = model unless model.nil?
h[LogField::MountPoint] = mount_point unless mount_point.nil?
h[LogField::Version] = version unless version.nil?
- h[LogField::StorePath] = store_path unless store_path.nil?
- h[LogField::Extension] = extension unless extension.nil?
- h[LogField::Filename] = filename unless filename.nil?
h[LogField::MimeType] = mime_type unless mime_type.nil?
h[LogField::Size] = size unless size.nil?
h
diff --git a/lib/log_struct/log/carrierwave/upload.rb b/lib/log_struct/log/carrierwave/upload.rb
index 93d4a7d2..1dbf9294 100644
--- a/lib/log_struct/log/carrierwave/upload.rb
+++ b/lib/log_struct/log/carrierwave/upload.rb
@@ -35,11 +35,8 @@ class Upload < T::Struct
const :model, T.nilable(String), default: nil
const :mount_point, T.nilable(String), default: nil
const :version, T.nilable(String), default: nil
- const :store_path, T.nilable(String), default: nil
- const :extension, T.nilable(String), default: nil
# Event-specific fields
- const :filename, T.nilable(String), default: nil
const :mime_type, T.nilable(String), default: nil
const :size, T.nilable(Integer), default: nil
const :metadata, T.nilable(T::Hash[String, T.untyped]), default: nil
@@ -58,9 +55,6 @@ def to_h
h[LogField::Model] = model unless model.nil?
h[LogField::MountPoint] = mount_point unless mount_point.nil?
h[LogField::Version] = version unless version.nil?
- h[LogField::StorePath] = store_path unless store_path.nil?
- h[LogField::Extension] = extension unless extension.nil?
- h[LogField::Filename] = filename unless filename.nil?
h[LogField::MimeType] = mime_type unless mime_type.nil?
h[LogField::Size] = size unless size.nil?
h[LogField::Metadata] = metadata unless metadata.nil?
diff --git a/lib/log_struct/log/error.rb b/lib/log_struct/log/error.rb
index c64e4edc..955c28ff 100644
--- a/lib/log_struct/log/error.rb
+++ b/lib/log_struct/log/error.rb
@@ -48,6 +48,16 @@ class Error < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::ErrorClass, LogField::Message, LogField::ErrorMessage, LogField::Backtrace, LogField::Status, LogField::CauseClass, LogField::CauseMessage].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Request fields (optional)
include LogStruct::Log::Interfaces::RequestFields
diff --git a/lib/log_struct/log/plain.rb b/lib/log_struct/log/plain.rb
index 18008aa5..a4e038c4 100644
--- a/lib/log_struct/log/plain.rb
+++ b/lib/log_struct/log/plain.rb
@@ -36,6 +36,16 @@ class Plain < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Message].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/puma/error.rb b/lib/log_struct/log/puma/error.rb
index eb10cbf9..306001e3 100644
--- a/lib/log_struct/log/puma/error.rb
+++ b/lib/log_struct/log/puma/error.rb
@@ -42,6 +42,16 @@ class Error < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::ErrorClass, LogField::Message, LogField::Backtrace, LogField::ErrorContext, LogField::HttpMethod, LogField::Path].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/puma/shutdown.rb b/lib/log_struct/log/puma/shutdown.rb
index c7cc2f24..4f75b05a 100644
--- a/lib/log_struct/log/puma/shutdown.rb
+++ b/lib/log_struct/log/puma/shutdown.rb
@@ -37,6 +37,16 @@ class Shutdown < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::ProcessId].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/puma/start.rb b/lib/log_struct/log/puma/start.rb
index 28bf3e0b..2a5e2256 100644
--- a/lib/log_struct/log/puma/start.rb
+++ b/lib/log_struct/log/puma/start.rb
@@ -45,6 +45,16 @@ class Start < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Mode, LogField::PumaVersion, LogField::PumaCodename, LogField::RubyVersion, LogField::MinThreads, LogField::MaxThreads, LogField::Environment, LogField::ProcessId, LogField::ListeningAddresses].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/request.rb b/lib/log_struct/log/request.rb
index bbfdc303..7b84cf27 100644
--- a/lib/log_struct/log/request.rb
+++ b/lib/log_struct/log/request.rb
@@ -52,6 +52,16 @@ class Request < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Format, LogField::Controller, LogField::Action, LogField::Status, LogField::DurationMs, LogField::View, LogField::Database, LogField::Params, LogField::Host, LogField::ContentType, LogField::Accept].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Request fields (optional)
include LogStruct::Log::Interfaces::RequestFields
diff --git a/lib/log_struct/log/security/blocked_host.rb b/lib/log_struct/log/security/blocked_host.rb
index 4f5dbb61..157f6297 100644
--- a/lib/log_struct/log/security/blocked_host.rb
+++ b/lib/log_struct/log/security/blocked_host.rb
@@ -46,6 +46,16 @@ class BlockedHost < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message, LogField::BlockedHost, LogField::BlockedHosts, LogField::XForwardedFor].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Request fields (optional)
include LogStruct::Log::Interfaces::RequestFields
diff --git a/lib/log_struct/log/security/csrf_violation.rb b/lib/log_struct/log/security/csrf_violation.rb
index 0d83b5a8..91cabd08 100644
--- a/lib/log_struct/log/security/csrf_violation.rb
+++ b/lib/log_struct/log/security/csrf_violation.rb
@@ -43,6 +43,16 @@ class CSRFViolation < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Request fields (optional)
include LogStruct::Log::Interfaces::RequestFields
diff --git a/lib/log_struct/log/security/ip_spoof.rb b/lib/log_struct/log/security/ip_spoof.rb
index e7e18438..f8eec375 100644
--- a/lib/log_struct/log/security/ip_spoof.rb
+++ b/lib/log_struct/log/security/ip_spoof.rb
@@ -45,6 +45,16 @@ class IPSpoof < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Path, LogField::HttpMethod, LogField::SourceIp, LogField::UserAgent, LogField::Referer, LogField::Message, LogField::ClientIp, LogField::XForwardedFor].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Request fields (optional)
include LogStruct::Log::Interfaces::RequestFields
diff --git a/lib/log_struct/log/sidekiq/error.rb b/lib/log_struct/log/sidekiq/error.rb
index 43e4ba34..3a1ae75f 100644
--- a/lib/log_struct/log/sidekiq/error.rb
+++ b/lib/log_struct/log/sidekiq/error.rb
@@ -42,6 +42,16 @@ class Error < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::ErrorClass, LogField::Message, LogField::Backtrace, LogField::ErrorContext, LogField::QueueName, LogField::RetryCount].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/sidekiq/log.rb b/lib/log_struct/log/sidekiq/log.rb
index 453bb83f..b906c66f 100644
--- a/lib/log_struct/log/sidekiq/log.rb
+++ b/lib/log_struct/log/sidekiq/log.rb
@@ -40,6 +40,16 @@ class Log < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Message, LogField::Context, LogField::ProcessId, LogField::ThreadId].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/log/sql.rb b/lib/log_struct/log/sql.rb
index cd10587d..cbf92414 100644
--- a/lib/log_struct/log/sql.rb
+++ b/lib/log_struct/log/sql.rb
@@ -47,6 +47,16 @@ class SQL < T::Struct
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+ FIELD_KEYS = T.let(
+ [LogField::Message, LogField::Sql, LogField::Name, LogField::DurationMs, LogField::RowCount, LogField::Adapter, LogField::BindParams, LogField::DatabaseName, LogField::ConnectionPoolSize, LogField::ActiveConnections, LogField::OperationType, LogField::TableNames].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
+
# Serialize shared fields
include LogStruct::Log::Interfaces::CommonFields
include LogStruct::Log::Shared::SerializeCommon
diff --git a/lib/log_struct/param_filters.rb b/lib/log_struct/param_filters.rb
index 6b0b22ce..18df5514 100644
--- a/lib/log_struct/param_filters.rb
+++ b/lib/log_struct/param_filters.rb
@@ -43,7 +43,7 @@ def should_filter_key?(key, value = nil, logstruct_field: false, path: nil, root
matcher.matches?(normalized_key, value)
rescue => e
- handle_filter_matcher_error(e, matcher.label, normalized_key)
+ handle_filter_matcher_error(e, matcher.label)
# An unsuccessful filter must never expose the value it was meant to protect.
true
end
@@ -55,7 +55,7 @@ def should_filter_key?(key, value = nil, logstruct_field: false, path: nil, root
filters.path_matchers.any? do |matcher|
matcher.matches?(path, value, root)
rescue => e
- handle_filter_matcher_error(e, matcher.label, normalized_key)
+ handle_filter_matcher_error(e, matcher.label)
true
end
end
@@ -94,16 +94,14 @@ def summarize_string(string, include_hash)
filtered_string
end
- # Summarize a filtered Hash: its first 10 keys and how many there are. No size, which would say
- # how long the secrets in it are.
+ # A filtered Hash's child keys can themselves contain secrets.
sig { params(hash: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
def summarize_hash(hash)
return {_class: "Hash", _empty: true} if hash.empty?
{
_class: Hash,
- _keys_count: hash.size,
- _keys: hash.each_key.take(10).map { |key| normalize_summary_key(StringScrubber.scrub_name(key)) }
+ _keys_count: hash.size
}
end
@@ -120,26 +118,9 @@ def summarize_array(array)
private
- sig { params(key: T.any(String, Symbol, Integer, T.untyped)).returns(T.any(Symbol, String)) }
- def normalize_summary_key(key)
- if key.is_a?(Symbol)
- key
- elsif key.respond_to?(:to_sym)
- key.to_sym
- else
- key.to_s
- end
- rescue
- key.to_s
- end
-
- sig { params(error: StandardError, label: String, key: String).void }
- def handle_filter_matcher_error(error, label, key)
- context = {
- matcher: label,
- key: key
- }
- LogStruct.handle_exception(error, source: Source::Internal, context: context)
+ sig { params(error: StandardError, label: String).void }
+ def handle_filter_matcher_error(error, label)
+ LogStruct.handle_value_error(error, "filter matcher failed", context: {matcher: label})
end
end
end
diff --git a/lib/log_struct/shared/merge_additional_data_fields.rb b/lib/log_struct/shared/merge_additional_data_fields.rb
index e865cef3..c0f74b0b 100644
--- a/lib/log_struct/shared/merge_additional_data_fields.rb
+++ b/lib/log_struct/shared/merge_additional_data_fields.rb
@@ -3,6 +3,7 @@
require_relative "interfaces/additional_data_field"
require_relative "../enums/source"
+require_relative "../enums/log_field"
module LogStruct
module Log
@@ -14,17 +15,32 @@ module MergeAdditionalDataFields
requires_ancestor { T::Struct }
requires_ancestor { Interfaces::AdditionalDataField }
- # Reserved keys that cannot be overwritten by additional_data.
- # These are the core log structure fields that must not be modified.
- RESERVED_KEYS = T.let(%i[src evt lvl ts].freeze, T::Array[Symbol])
+ # Every line's own fields, and the request ID the formatter adds unless the line has one
+ RESERVED_KEYS = T.let(
+ [LogField::Source, LogField::Event, LogField::Level, LogField::Timestamp, LogField::RequestId].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ NO_FIELD_KEYS = T.let(Set.new.freeze, T::Set[Symbol])
+
+ # The keys of every field of the struct's type, set or not on this line, which additional_data can't
+ # set: it would replace the field, or stand in for one the type didn't write. LogStruct's log types list
+ # theirs. A custom type's serialize writes its own fields after merging additional_data, so they win.
+ sig { overridable.returns(T::Set[Symbol]) }
+ def field_keys
+ NO_FIELD_KEYS
+ end
sig { params(hash: T::Hash[Symbol, T.untyped]).void }
def merge_additional_data_fields(hash)
ad = additional_data
return unless ad
- ad.each do |key, value|
+ ad.each_pair.with_index do |(key, value), index|
+ break if index >= LogStruct.config.limits.max_hash_keys
+ next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
+
sym_key = key.to_sym
- if RESERVED_KEYS.include?(sym_key)
+ if RESERVED_KEYS.include?(sym_key) || field_keys.include?(sym_key)
LogStruct.handle_exception(
ArgumentError.new("additional_data attempted to overwrite reserved key: #{sym_key}"),
source: Source::Internal
diff --git a/lib/log_struct/version.rb b/lib/log_struct/version.rb
index a1269418..29f98cb9 100644
--- a/lib/log_struct/version.rb
+++ b/lib/log_struct/version.rb
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module LogStruct
- VERSION = "0.2.2"
+ VERSION = "0.3.0"
end
diff --git a/schemas/log_sources/carrierwave.yml b/schemas/log_sources/carrierwave.yml
index d9a56db4..e2847704 100644
--- a/schemas/log_sources/carrierwave.yml
+++ b/schemas/log_sources/carrierwave.yml
@@ -17,16 +17,11 @@ base:
Model: String
MountPoint: String
Version: String
- StorePath: String
- Extension: String
events:
Upload:
default_required: false
fields:
- Filename:
- type: String
- required: false
MimeType:
type: String
required: false
@@ -47,9 +42,6 @@ events:
Download:
default_required: false
fields:
- Filename:
- type: String
- required: false
MimeType:
type: String
required: false
diff --git a/schemas/meta/log-fields.json b/schemas/meta/log-fields.json
index fdd9914f..6e5319b2 100644
--- a/schemas/meta/log-fields.json
+++ b/schemas/meta/log-fields.json
@@ -45,10 +45,8 @@
"ExecutionTime",
"Executions",
"Exist",
- "Extension",
"File",
"FileId",
- "Filename",
"FinishedAt",
"Format",
"From",
@@ -104,7 +102,6 @@
"StartedAt",
"Status",
"Storage",
- "StorePath",
"Subject",
"TableNames",
"ThreadId",
diff --git a/sorbet/rbi/gems/bundler-audit@0.9.3.rbi b/sorbet/rbi/gems/bundler-audit@0.9.3.rbi
new file mode 100644
index 00000000..7527df3a
--- /dev/null
+++ b/sorbet/rbi/gems/bundler-audit@0.9.3.rbi
@@ -0,0 +1,315 @@
+# typed: true
+
+# DO NOT EDIT MANUALLY
+# This is an autogenerated file for types exported from the `bundler-audit` gem.
+# Please instead update this file by running `bin/tapioca gem bundler-audit`.
+
+
+# source://bundler-audit//lib/bundler/audit/advisory.rb#22
+module Bundler::Audit; end
+
+# Represents an advisory loaded from the {Database}.
+#
+# source://bundler-audit//lib/bundler/audit/advisory.rb#26
+class Bundler::Audit::Advisory < ::Struct
+ # Compares two advisories.
+ #
+ # @param other [Advisory]
+ # @return [Boolean]
+ #
+ # source://bundler-audit//lib/bundler/audit/advisory.rb#210
+ def ==(other); end
+
+ # Determines how critical the vulnerability is.
+ #
+ # @return [:none, :low, :medium, :high, :critical, nil] The criticality of the vulnerability based on the CVSS score.
+ #
+ # source://bundler-audit//lib/bundler/audit/advisory.rb#138
+ def criticality; end
+
+ # The CVE identifier.
+ #
+ # @return [String, nil]
+ #
+ # source://bundler-audit//lib/bundler/audit/advisory.rb#93
+ def cve_id; end
+
+ # The GHSA (GitHub Security Advisory) identifier
+ #
+ # @return [String, nil]
+ # @since 0.7.0
+ #
+ # source://bundler-audit//lib/bundler/audit/advisory.rb#113
+ def ghsa_id; end
+
+ # Return a compacted list of all ids
+ #
+ # @return [Array