From 0dd1a5376c22477e454cecfca2cf8a98b1f7c003 Mon Sep 17 00:00:00 2001
From: Nathan Broadbent _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..6a79708d 100644
--- a/docs/app/docs/log-fields/page.tsx
+++ b/docs/app/docs/log-fields/page.tsx
@@ -192,13 +192,14 @@ 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).
+ All built-in field names, including src, evt, msg
+ and backtrace, are reserved. LogStruct drops them and handles the{' '}
+ ArgumentError as a LogStruct error (raised in local_environments
+ , logged elsewhere).
_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 +345,7 @@ export default function LogFieldsPage() {
_keys.
+ a summary.
@@ -366,6 +367,8 @@ export default function LogFieldsPage() {
max_hash_keys (default 100): a Hash writes its first 100 keys, then{' '}
_more_keys with the number left out. The line's own keys count too.
+ Rails parameters use true for _more_keys because their count is
+ unavailable without traversing the whole input.
max_array_items (default 10): an Array writes its first 10 items, then a last
@@ -380,7 +383,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.
+ additional_data Hash whose keys go at the top level of the line. A key that
+ matches any built-in log field, such as msg or ts, is a
+ LogStruct error (logstruct_errors): raised in development and test, and
+ logged and left out in production.
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..371d77e8
--- /dev/null
+++ b/lib/log_struct/bounded_input.rb
@@ -0,0 +1,66 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "action_controller"
+
+module LogStruct
+ module BoundedInput
+ extend T::Sig
+
+ MAX_DEPTH = 20
+ MAX_NODES = 1_000
+
+ 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 = {}
+ limit = LogStruct.config.limits.max_hash_keys
+ value.each_pair.with_index do |(key, child), index|
+ if index >= limit
+ state.truncated = true
+ break
+ end
+ if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
+ state.truncated = true
+ next
+ end
+
+ result[key] = copy(child, state: state, depth: depth + 1, ancestors: ancestors)
+ end
+ result
+ ensure
+ ancestors.delete(value)
+ end
+ when Array
+ ancestors[value] = true
+ begin
+ limit = LogStruct.config.limits.max_array_items
+ state.truncated = true if value.length > limit
+ value.first(limit).map do |child|
+ copy(child, state: state, depth: depth + 1, ancestors: ancestors)
+ end
+ ensure
+ ancestors.delete(value)
+ end
+ else
+ value
+ end
+ end
+ end
+end
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/formatter.rb b/lib/log_struct/formatter.rb
index 3f0236d8..cccc8531 100644
--- a/lib/log_struct/formatter.rb
+++ b/lib/log_struct/formatter.rb
@@ -80,13 +80,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)
@@ -125,7 +125,7 @@ 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
@@ -138,13 +138,15 @@ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, p
value_type: arg.class.name,
recursion_depth: recursion_depth
}
- LogStruct.handle_exception(e, source: Source::Internal, context: context)
+ safe_error = RuntimeError.new("#{e.class}: value formatting failed")
+ safe_error.set_backtrace(e.backtrace)
+ LogStruct.handle_exception(safe_error, source: Source::Internal, 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 +160,24 @@ 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] = hash.is_a?(Hash) ? hash.size - max_keys : true
break
end
+ if key.to_s.bytesize > max_key_bytes
+ oversized_keys += 1
+ next
+ end
+
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 +186,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 +198,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
@@ -296,14 +313,13 @@ def log_value_to_hash(log_value, time:)
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
+ context = {object_class: log_value.class.name}
+ safe_error = RuntimeError.new("#{e.class}: value serialization failed")
+ safe_error.set_backtrace(e.backtrace)
+ LogStruct.handle_exception(safe_error, source: Source::Internal, context: context)
+
+ # 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..e56f458e 100644
--- a/lib/log_struct/hash_utils.rb
+++ b/lib/log_struct/hash_utils.rb
@@ -2,13 +2,12 @@
# 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"
+ FALLBACK_KEY = T.let(::SecureRandom.random_bytes(32).freeze, String)
class << self
extend T::Sig
@@ -17,7 +16,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..96195a21 100644
--- a/lib/log_struct/integrations/ahoy.rb
+++ b/lib/log_struct/integrations/ahoy.rb
@@ -45,10 +45,24 @@ 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
+ result = T.let({}, T::Hash[Symbol, T.untyped])
+ truncated = T.let(false, T::Boolean)
+ properties.each_pair.with_index do |(key, value), index|
+ if index >= LogStruct.config.limits.max_hash_keys
+ truncated = true
+ break
+ end
+ next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
+
+ result[key.to_s.to_sym] = value
+ end
+ if truncated
+ result[:_more_keys] = properties.is_a?(Hash) ? properties.size - LogStruct.config.limits.max_hash_keys : true
+ end
+ result
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..4242f62a 100644
--- a/lib/log_struct/integrations/lograge.rb
+++ b/lib/log_struct/integrations/lograge.rb
@@ -196,7 +196,7 @@ 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!(normalize_lograge_data(result))
end
sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(Log::Request) }
@@ -210,7 +210,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 = normalize_lograge_data(params) if params.is_a?(Hash) || params.is_a?(::ActionController::Parameters)
additional_data = extract_additional_data(normalized_data)
@@ -237,11 +237,23 @@ def build_request_log(data)
)
end
- sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
+ sig { params(data: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
def normalize_lograge_data(data)
- data.each_with_object({}) do |(key, value), normalized|
+ normalized = T.let({}, T::Hash[Symbol, T.untyped])
+ truncated = T.let(false, T::Boolean)
+ data.each_pair.with_index do |(key, value), index|
+ if index >= LogStruct.config.limits.max_hash_keys
+ truncated = true
+ break
+ end
+ next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
+
normalized[key.to_s.to_sym] = value
end
+ if truncated
+ normalized[:_more_keys] = data.is_a?(Hash) ? data.size - LogStruct.config.limits.max_hash_keys : true
+ end
+ normalized
end
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
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/param_filters.rb b/lib/log_struct/param_filters.rb
index 6b0b22ce..c541a43e 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,11 @@ 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)
+ safe_error = RuntimeError.new("#{error.class}: filter matcher failed")
+ safe_error.set_backtrace(error.backtrace)
+ LogStruct.handle_exception(safe_error, source: Source::Internal, 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..97b59828 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,15 +15,16 @@ 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])
+ RESERVED_KEYS = T.let(LogField.values.map(&:serialize).to_set.freeze, T::Set[Symbol])
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)
LogStruct.handle_exception(
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/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
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/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/shared/merge_additional_data_fields.rb b/lib/log_struct/shared/merge_additional_data_fields.rb
index 97b59828..c0f74b0b 100644
--- a/lib/log_struct/shared/merge_additional_data_fields.rb
+++ b/lib/log_struct/shared/merge_additional_data_fields.rb
@@ -15,7 +15,21 @@ module MergeAdditionalDataFields
requires_ancestor { T::Struct }
requires_ancestor { Interfaces::AdditionalDataField }
- RESERVED_KEYS = T.let(LogField.values.map(&:serialize).to_set.freeze, T::Set[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)
@@ -26,7 +40,7 @@ def merge_additional_data_fields(hash)
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/test/code_examples/log_fields_test.rb b/test/code_examples/log_fields_test.rb
index 606fd4b3..fa6d7168 100644
--- a/test/code_examples/log_fields_test.rb
+++ b/test/code_examples/log_fields_test.rb
@@ -160,13 +160,24 @@ def test_additional_data
assert_equal %w[src evt lvl ts msg account_id rows prog], line.keys
- %i[src evt lvl ts msg backtrace].each do |key|
+ # The line's own fields and the request ID are reserved
+ %i[src evt lvl ts req_id msg].each do |key|
error = assert_raises(ArgumentError) do
LogStruct::Log::Plain.new(message: "Imported", additional_data: {key => "x"}).serialize
end
assert_equal "additional_data attempted to overwrite reserved key: #{key}", error.message
end
+ # So are the type's fields this line doesn't have: an Error's error_message and backtrace
+ %i[error_message backtrace].each do |key|
+ error = assert_raises(ArgumentError) do
+ LogStruct::Log::Error.new(source: Source::App, error_class: RuntimeError, message: "Failed", additional_data: {key => "x"}).serialize
+ end
+
+ assert_equal "additional_data attempted to overwrite reserved key: #{key}", error.message
+ end
+ # Another type's field names are free to use
+ assert_equal "Invoices", LogStruct::Log::Plain.new(message: "Imported", additional_data: {name: "Invoices"}).serialize[:name]
end
def test_filtered_summaries
diff --git a/test/log_struct/concerns/logging_test.rb b/test/log_struct/concerns/logging_test.rb
index 3c21a3be..f2d8a51e 100644
--- a/test/log_struct/concerns/logging_test.rb
+++ b/test/log_struct/concerns/logging_test.rb
@@ -142,7 +142,7 @@ def test_structured_logging_preserves_custom_fields
timestamp: Time.new(2023, 1, 1, 12, 0, 0),
additional_data: {
user_id: 123,
- integration_action: "test",
+ action: "test",
custom_field: "custom value"
}
)
@@ -157,7 +157,7 @@ def test_structured_logging_preserves_custom_fields
# Verify the message and custom fields were logged correctly
assert_equal "Message with extra fields", parsed_log["msg"]
assert_equal 123, parsed_log["user_id"]
- assert_equal "test", parsed_log["integration_action"]
+ assert_equal "test", parsed_log["action"]
assert_equal "custom value", parsed_log["custom_field"]
end
end
diff --git a/tools/codegen/templates/sorbet/_event_class_body.rb.erb b/tools/codegen/templates/sorbet/_event_class_body.rb.erb
index e7f435cd..62b5d438 100644
--- a/tools/codegen/templates/sorbet/_event_class_body.rb.erb
+++ b/tools/codegen/templates/sorbet/_event_class_body.rb.erb
@@ -26,6 +26,16 @@ extend T::Sig
include LogStruct::Log::Interfaces::AdditionalDataField
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
include LogStruct::Log::Shared::MergeAdditionalDataFields
+
+ FIELD_KEYS = T.let(
+ [<%= (base_fields + event_only(event)).map { |f| "LogField::#{f.enum_name}" }.join(", ") %>].to_set(&:serialize).freeze,
+ T::Set[Symbol]
+ )
+
+ sig { override.returns(T::Set[Symbol]) }
+ def field_keys
+ FIELD_KEYS
+ end
<% end -%>
<% if add_request_fields -%>
From 05b27bcde9aee1f1b274a8d5d36cff23c547eacf Mon Sep 17 00:00:00 2001
From: Nathan Broadbent max_hash_keys (default 100): a Hash writes its first 100 keys, then{' '}
_more_keys with the number left out. The line's own keys count too.
- Rails parameters use true for _more_keys because their count is
- unavailable without traversing the whole input.
max_array_items (default 10): an Array writes its first 10 items, then a last
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/lib/log_struct/bounded_input.rb b/lib/log_struct/bounded_input.rb
index 371d77e8..26b4f914 100644
--- a/lib/log_struct/bounded_input.rb
+++ b/lib/log_struct/bounded_input.rb
@@ -4,11 +4,20 @@
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 = 1_000
+ 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
@@ -30,16 +39,8 @@ def self.copy(value, state:, depth: 0, ancestors: {}.compare_by_identity)
ancestors[value] = true
begin
result = {}
- limit = LogStruct.config.limits.max_hash_keys
- value.each_pair.with_index do |(key, child), index|
- if index >= limit
- state.truncated = true
- break
- end
- if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
- state.truncated = true
- next
- end
+ value.each_pair do |key, child|
+ break if state.truncated
result[key] = copy(child, state: state, depth: depth + 1, ancestors: ancestors)
end
@@ -50,11 +51,13 @@ def self.copy(value, state:, depth: 0, ancestors: {}.compare_by_identity)
when Array
ancestors[value] = true
begin
- limit = LogStruct.config.limits.max_array_items
- state.truncated = true if value.length > limit
- value.first(limit).map do |child|
- copy(child, state: state, depth: depth + 1, ancestors: ancestors)
+ 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
@@ -62,5 +65,37 @@ def self.copy(value, state:, depth: 0, ancestors: {}.compare_by_identity)
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/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 77b73912..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"
@@ -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
@@ -131,16 +132,12 @@ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, p
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
}
- safe_error = RuntimeError.new("#{e.class}: value formatting failed")
- safe_error.set_backtrace(e.backtrace)
- LogStruct.handle_exception(safe_error, source: Source::Internal, context: context)
+ LogStruct.handle_value_error(e, "value formatting failed", context: context)
summarize_unprocessed_value(arg)
end
@@ -164,15 +161,21 @@ def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
oversized_keys = 0
hash.each_pair.with_index do |(key, value), index|
if index >= max_keys
- result[:_more_keys] = hash.is_a?(Hash) ? hash.size - max_keys : true
+ 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
@@ -312,11 +315,7 @@ 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}
- safe_error = RuntimeError.new("#{e.class}: value serialization failed")
- safe_error.set_backtrace(e.backtrace)
- LogStruct.handle_exception(safe_error, source: Source::Internal, context: context)
+ 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]"
diff --git a/lib/log_struct/hash_utils.rb b/lib/log_struct/hash_utils.rb
index e56f458e..6d80cdfc 100644
--- a/lib/log_struct/hash_utils.rb
+++ b/lib/log_struct/hash_utils.rb
@@ -7,6 +7,8 @@
module LogStruct
# Utility module for hashing sensitive data
module HashUtils
+ # 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
diff --git a/lib/log_struct/integrations/ahoy.rb b/lib/log_struct/integrations/ahoy.rb
index 96195a21..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
@@ -47,22 +49,7 @@ def track(name, *args)
def self.properties_for_log(properties)
case properties
when nil then nil
- when Hash, ::ActionController::Parameters
- result = T.let({}, T::Hash[Symbol, T.untyped])
- truncated = T.let(false, T::Boolean)
- properties.each_pair.with_index do |(key, value), index|
- if index >= LogStruct.config.limits.max_hash_keys
- truncated = true
- break
- end
- next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
-
- result[key.to_s.to_sym] = value
- end
- if truncated
- result[:_more_keys] = properties.is_a?(Hash) ? properties.size - LogStruct.config.limits.max_hash_keys : true
- end
- result
+ when Hash, ::ActionController::Parameters then BoundedInput.symbolize_keys(properties)
else {_class: properties.class.name}
end
end
diff --git a/lib/log_struct/integrations/lograge.rb b/lib/log_struct/integrations/lograge.rb
index 4242f62a..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!(normalize_lograge_data(result))
+ 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 = normalize_lograge_data(params) if params.is_a?(Hash) || params.is_a?(::ActionController::Parameters)
+ params = BoundedInput.symbolize_keys(params) if params.is_a?(Hash) || params.is_a?(::ActionController::Parameters)
additional_data = extract_additional_data(normalized_data)
@@ -237,25 +238,6 @@ def build_request_log(data)
)
end
- sig { params(data: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
- def normalize_lograge_data(data)
- normalized = T.let({}, T::Hash[Symbol, T.untyped])
- truncated = T.let(false, T::Boolean)
- data.each_pair.with_index do |(key, value), index|
- if index >= LogStruct.config.limits.max_hash_keys
- truncated = true
- break
- end
- next if key.to_s.bytesize > LogStruct.config.limits.max_string_bytes
-
- normalized[key.to_s.to_sym] = value
- end
- if truncated
- normalized[:_more_keys] = data.is_a?(Hash) ? data.size - LogStruct.config.limits.max_hash_keys : true
- end
- normalized
- 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/param_filters.rb b/lib/log_struct/param_filters.rb
index c541a43e..18df5514 100644
--- a/lib/log_struct/param_filters.rb
+++ b/lib/log_struct/param_filters.rb
@@ -120,9 +120,7 @@ def summarize_array(array)
sig { params(error: StandardError, label: String).void }
def handle_filter_matcher_error(error, label)
- safe_error = RuntimeError.new("#{error.class}: filter matcher failed")
- safe_error.set_backtrace(error.backtrace)
- LogStruct.handle_exception(safe_error, source: Source::Internal, context: {matcher: label})
+ LogStruct.handle_value_error(error, "filter matcher failed", context: {matcher: label})
end
end
end
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/test/log_struct/bounded_input_test.rb b/test/log_struct/bounded_input_test.rb
new file mode 100644
index 00000000..5b826e86
--- /dev/null
+++ b/test/log_struct/bounded_input_test.rb
@@ -0,0 +1,50 @@
+# typed: true
+# frozen_string_literal: true
+
+require "test_helper"
+
+module LogStruct
+ class BoundedInputTest < ActiveSupport::TestCase
+ test "symbolize_keys converts every key of a Hash within the limit" do
+ LogStruct.config.limits.max_hash_keys = 3
+
+ assert_equal({a: 1, b: {"c" => 2}, "3": 3}, BoundedInput.symbolize_keys({"a" => 1, :b => {"c" => 2}, 3 => 3}))
+ end
+
+ test "symbolize_keys leaves the last slot to the count of the keys it leaves out" do
+ LogStruct.config.limits.max_hash_keys = 3
+ symbolized = BoundedInput.symbolize_keys((1..10).to_h { |i| ["k#{i}", i] })
+
+ assert_equal({k1: 1, k2: 2, _more_keys: 8}, symbolized)
+ # The formatter writes it as it is, though the test app's filter_parameters have :key (Rails' default
+ # list has :_key), which matches _more_keys
+ assert_equal symbolized, Formatter.new.process_values(symbolized)
+ end
+
+ test "symbolize_keys counts oversized keys in the slots they leave" do
+ LogStruct.config.limits.max_hash_keys = 3
+ LogStruct.config.limits.max_string_bytes = 8
+ long_key = "k" * 9
+
+ within = BoundedInput.symbolize_keys({long_key => "hidden", "short" => 1})
+ past = BoundedInput.symbolize_keys({long_key => "hidden", "short" => 1, "next" => 2, "last" => 3})
+
+ assert_equal({short: 1, _oversized_keys: 1}, within)
+ assert_equal({short: 1, _more_keys: 2, _oversized_keys: 1}, past)
+ [within, past].each do |symbolized|
+ assert_equal symbolized, Formatter.new.process_values(symbolized)
+ end
+ end
+
+ test "symbolize_keys counts the keys of ActionController::Parameters without converting them" do
+ LogStruct.config.limits.max_hash_keys = 2
+ params = ::ActionController::Parameters.new("visible" => "yes", "nested" => {"token" => "secret"}, "last" => 1)
+
+ symbolized = params.stub(:to_unsafe_h, -> { raise "full conversion" }) do
+ BoundedInput.symbolize_keys(params)
+ end
+
+ assert_equal({visible: "yes", _more_keys: 2}, symbolized)
+ end
+ end
+end
diff --git a/test/log_struct/configuration_test.rb b/test/log_struct/configuration_test.rb
index 2b7acd1d..8a7fe84c 100644
--- a/test/log_struct/configuration_test.rb
+++ b/test/log_struct/configuration_test.rb
@@ -284,11 +284,14 @@ def test_failing_rails_filter_blocks_filter_the_value
LogStruct.config.filters.path_matchers = []
with_rails_filter_parameters([filter]) { LogStruct.merge_rails_filter_parameters! }
reported = []
- line = LogStruct.stub(:handle_exception, ->(error, **) { reported << error.message }) do
+ line = LogStruct.stub(:handle_exception, ->(error, source:, context:) { reported << [error.message, context] }) do
Formatter.new.call(:info, Time.now, nil, {private_data: "synthetic-secret", public_data: "visible"})
end
- assert_equal ["filter failed"], reported
+ assert_equal ["RuntimeError: filter_parameters entry failed"], reported.map(&:first)
+ # The key can be the secret
+ assert_equal [%i[filter filter_label]], reported.map { |(_message, context)| context.keys }
+ assert_not_includes reported.to_s, "private_data"
assert_not_includes line, "synthetic-secret"
assert_equal "visible", JSON.parse(line)["public_data"]
end
@@ -309,6 +312,37 @@ def test_rails_filter_blocks_with_three_arguments_get_the_parameters
assert_includes phone_line, "phone-1"
end
+ def test_rails_filter_blocks_with_three_arguments_get_the_parameters_the_line_leaves_out
+ number_filter = ->(key, _value, params) { key == "number" && params[:kinds].include?("card") }
+
+ with_rails_filter_parameters([number_filter]) do
+ LogStruct.merge_rails_filter_parameters!
+ end
+ # The size limits only cut what's written: "card" is past max_array_items
+ kinds = Array.new(LogStruct.config.limits.max_array_items, "phone") << "card"
+
+ line = Formatter.new.call(::Logger::INFO, Time.now, nil, {msg: "Saved", params: {kinds: kinds, number: "card-1"}})
+
+ assert_not_includes line, "card-1"
+ end
+
+ def test_rails_filter_blocks_with_three_arguments_leave_lines_with_long_backtraces_readable
+ with_rails_filter_parameters([->(key, _value, _params) { key == "pin" }]) do
+ LogStruct.merge_rails_filter_parameters!
+ end
+ error = Log::Error.new(
+ source: Source::App,
+ error_class: RuntimeError,
+ message: "Failed",
+ backtrace: Array.new(30) { |line| "app/models/user.rb:#{line + 1}:in 'save'" }
+ )
+
+ line = JSON.parse(Formatter.new.call(::Logger::ERROR, Time.now, nil, error))
+
+ assert_equal %w[app error Failed RuntimeError], line.values_at("src", "evt", "msg", "error_class")
+ assert_equal LogStruct.config.limits.max_backtrace_lines + 1, line["backtrace"].size
+ end
+
def test_rails_dotted_filters_match_nested_keys_by_their_path
with_rails_filter_parameters(["profile.nickname", /\Ateam\.motto\z/]) do
LogStruct.merge_rails_filter_parameters!
diff --git a/test/log_struct/formatter_test.rb b/test/log_struct/formatter_test.rb
index f6e691f8..3d96d67e 100644
--- a/test/log_struct/formatter_test.rb
+++ b/test/log_struct/formatter_test.rb
@@ -116,13 +116,13 @@ def to_s
# Create an instance of it
error_obj = my_gid_class.new
- # Need to stub LogStruct.handle_exception to avoid actually reporting an error
- LogStruct.stub(:handle_exception, nil) do
- # Test the error handling path in process_values
+ reported = []
+ LogStruct.stub(:handle_exception, ->(error, **) { reported << error.message }) do
result = @formatter.process_values(error_obj)
assert_equal "[GLOBALID_ERROR]", result
end
+ assert_equal ["StandardError: GlobalID formatting failed"], reported
end
def test_tagged_logging_support
@@ -651,7 +651,7 @@ def test_action_controller_parameters_obey_hash_limits_without_full_conversion
params.stub(:to_unsafe_h, -> { raise "full conversion" }) do
result = @formatter.process_values(params)
- assert_equal({visible: "yes", _more_keys: true}, result)
+ assert_equal({visible: "yes", _more_keys: 1}, result)
end
end
@@ -756,11 +756,14 @@ def as_json(*)
end
end.new
- log_line = LogStruct.stub(:is_production?, true) do
+ reported = []
+ log_line = LogStruct.stub(:handle_exception, ->(error, **) { reported << error }) do
@formatter.call(@severity, @time, @progname, {msg: "Loop", value: looping})
end
assert_equal "Loop", JSON.parse(log_line)["msg"]
+ assert_equal ["SystemStackError: value formatting failed"], reported.map(&:message)
+ assert_match(/in 'as_json'/, reported.first.backtrace.first)
end
private
diff --git a/test/log_struct/integrations/ahoy_test.rb b/test/log_struct/integrations/ahoy_test.rb
index 155ee3c2..408052f4 100644
--- a/test/log_struct/integrations/ahoy_test.rb
+++ b/test/log_struct/integrations/ahoy_test.rb
@@ -73,14 +73,21 @@ def track(name, properties = {}, _options = {})
end
test "parameter properties are bounded without converting the full input" do
- LogStruct.config.limits.max_hash_keys = 1
- params = ::ActionController::Parameters.new("visible" => "yes", "ignored" => {"password" => "secret"})
+ LogStruct.config.limits.max_hash_keys = 2
+ params = ::ActionController::Parameters.new("visible" => "yes", "ignored" => {"password" => "secret"}, "also_ignored" => 1)
params.stub(:to_unsafe_h, -> { raise "full conversion" }) do
- assert_equal({visible: "yes", _more_keys: true}, Integrations::Ahoy.properties_for_log(params))
+ assert_equal({visible: "yes", _more_keys: 2}, Integrations::Ahoy.properties_for_log(params))
end
end
+ test "the line counts every property left out" do
+ LogStruct.config.limits.max_hash_keys = 3
+ properties = Integrations::Ahoy.properties_for_log((1..10).to_h { |i| ["k#{i}", i] })
+
+ assert_equal({k1: 1, k2: 2, _more_keys: 8}, Formatter.new.process_values(properties))
+ end
+
test "Ahoy gets the arguments as they were given, so its defaults still apply" do
LogStruct.stub(:info, ->(_log) {}) do
assert_equal [:ok, "signup", {}], ::Ahoy::Tracker.new.track("signup")
diff --git a/test/log_struct/param_filters_test.rb b/test/log_struct/param_filters_test.rb
index b7f75c2d..b31b6dd8 100644
--- a/test/log_struct/param_filters_test.rb
+++ b/test/log_struct/param_filters_test.rb
@@ -66,18 +66,33 @@ class ParamFiltersTest < ActiveSupport::TestCase
assert_failing_matcher_filters_value
end
- test "path matcher roots fail closed when limits omit context" do
+ test "path matcher roots keep what the size limits leave out of the line" do
LogStruct.config.limits.max_hash_keys = 1
- root = ConfigStruct::LazyRoot.new({"visible" => {"name" => "Ada", "ignored" => "hidden"}, "ignored" => {"secret" => "hidden"}})
+ LogStruct.config.limits.max_array_items = 1
+ LogStruct.config.limits.max_string_bytes = 8
+ long_key = "k" * 100
+ root = ConfigStruct::LazyRoot.new({"first" => [1, 2, 3], "second" => {long_key => "v"}})
- assert_same ConfigStruct::LazyRoot::UNAVAILABLE, root.value
- matcher = ConfigStruct::PathFilterMatcher.new(callable: ->(_path, _value, _root) { false }, label: "root matcher")
-
- assert matcher.matches?(["visible"], "secret", root)
-
- complete_root = ConfigStruct::LazyRoot.new({"visible" => {"name" => "Ada"}})
+ assert_equal({"first" => [1, 2, 3], "second" => {long_key => "v"}}, root.value)
+ assert_equal "v", root.value[:second][long_key]
+ end
- assert_equal "Ada", complete_root.value["visible"]["name"]
+ test "path matcher roots fail closed when the parameters are too large to copy" do
+ matcher = ConfigStruct::PathFilterMatcher.new(callable: ->(_path, _value, _root) { false }, label: "root matcher")
+ cyclic = {"name" => "Ada"}
+ cyclic["self"] = cyclic
+ too_deep = (BoundedInput::MAX_DEPTH + 1).times.reduce({}) { |inner, _level| {"nested" => inner} }
+ roots = [
+ ConfigStruct::LazyRoot.new({"items" => Array.new(BoundedInput::MAX_NODES, 1)}),
+ ConfigStruct::LazyRoot.new(too_deep),
+ ConfigStruct::LazyRoot.new(cyclic)
+ ]
+
+ roots.each do |root|
+ assert_same ConfigStruct::LazyRoot::UNAVAILABLE, root.value
+ assert matcher.matches?(["name"], "Ada", root)
+ end
+ refute matcher.matches?(["name"], "Ada", ConfigStruct::LazyRoot.new({"items" => Array.new(BoundedInput::MAX_NODES - 2, 1)}))
end
private def assert_failing_matcher_filters_value
From 8f527fbf5a554f5f962f3131a97381bb14dfbff5 Mon Sep 17 00:00:00 2001
From: Nathan Broadbent src, evt, msg
- and backtrace, are reserved. LogStruct drops them and handles the{' '}
+ 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).
+ , logged elsewhere). Other types' field names, like name on a plain
+ line, are free to use.
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 that
- matches any built-in log field, such as msg or ts, is a
- LogStruct error (logstruct_errors): raised in development and test, and
- logged and left out in production.
+ additional_data Hash whose keys go at the top level of the line. A key named{' '}
+ 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.