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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
logstruct (0.2.2)
logstruct (0.3.0)
actionpack (>= 7.1)
activesupport (>= 7.1)
globalid (>= 1.0)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 13 additions & 9 deletions docs/app/docs/filtering-sensitive-data/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -463,8 +462,8 @@ export default function FilteringSensitiveDataPage() {
).
</li>
<li>
<strong>Hash</strong>: its class, how many keys it has (<code>_keys_count</code>) and its
first 10 keys (<code>_keys</code>), scrubbed like any string. An empty Hash is{' '}
<strong>Hash</strong>: its class and how many keys it has (<code>_keys_count</code>).
Child keys are omitted because they may contain secrets. An empty Hash is{' '}
<code>{'{"_class": "Hash", "_empty": true}'}</code>.
</li>
<li>
Expand Down Expand Up @@ -577,7 +576,10 @@ export default function FilteringSensitiveDataPage() {
when the block returns a truthy value or changes the String it was given. A block that
takes three arguments gets the parameters around the key, with indifferent access: the
value of the line&apos;s top-level field (a request line&apos;s params), or the line
itself for a top-level key.
itself for a top-level key. They&apos;re all there, including what the size limits leave
out of the line. Parameters with more than 10,000 values, nested more than 20 levels deep
or containing themselves aren&apos;t copied for the block: every key it would check is
filtered.
</li>
<li>
The partial matches of Symbols and Strings don&apos;t apply to the names of
Expand Down Expand Up @@ -630,8 +632,10 @@ export default function FilteringSensitiveDataPage() {
</p>
<RubyCodeExample name="filtering_hash_salt" />
<p className="text-neutral-600 dark:text-neutral-300">
Outside a Rails app, without <code>hash_salt</code>, LogStruct uses a fixed key that is
public in its source code.
Outside a Rails app, without <code>hash_salt</code>, LogStruct uses a random process key.
Set <code>hash_salt</code> if hashes must be stable across processes or restarts. Values
longer than <code>max_string_bytes</code> are hashed using their bounded prefix and total
byte length.
</p>
<Callout type="warning">
Hashes are pseudonyms, not anonymous data: anyone who has the key and a list of email
Expand Down Expand Up @@ -814,8 +818,8 @@ export default function FilteringSensitiveDataPage() {
(its key or location), not the name it was uploaded with, which often names a person.
Shrine&apos;s upload options are logged with the file names taken out of a{' '}
<code>content_disposition</code>. A location your uploader builds from a file&apos;s name
(and CarrierWave&apos;s paths, which use the uploaded name by default) is logged as it is:
scrub it with a <code>string_scrubbing_handler</code>.
is logged as it is: scrub it with a <code>string_scrubbing_handler</code>. CarrierWave
logs an HMAC of the file ID and omits the file name and storage path.
</li>
<li>
<strong>Mail delivery job arguments</strong>: a <code>deliver_later</code> job (
Expand Down
17 changes: 4 additions & 13 deletions docs/app/docs/log-fields/example-lines.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down Expand Up @@ -791,8 +783,7 @@
"credentials": {
"_filtered": {
"_class": "Hash",
"_keys_count": 2,
"_keys": ["client_id", "client_secret"]
"_keys_count": 2
}
},
"note": "Call Linda on [PHONE]",
Expand Down
24 changes: 15 additions & 9 deletions docs/app/docs/log-fields/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,17 @@ export default function LogFieldsPage() {
<CodeBlock language="json">{json(FORMATS.additional_data)}</CodeBlock>
<ul className={list}>
<li>
A key with the same name as one of the type&apos;s fields replaces that field: an{' '}
<code>additional_data</code> of <code>{'{msg: "..."}'}</code> replaces the message.
Use <code>additional_data</code> only for unknown fields. Put known fields in the log
type&apos;s schema.
</li>
<li>
<code>src</code>, <code>evt</code>, <code>lvl</code> and <code>ts</code> are reserved.
LogStruct drops them and handles the <code>ArgumentError</code> as a LogStruct error
(raised in <code>local_environments</code>, logged elsewhere).
<code>src</code>, <code>evt</code>, <code>lvl</code>, <code>ts</code>, <code>req_id</code>{' '}
and the fields of the line&apos;s own type are reserved, whether or not the line has them:
an error line&apos;s <code>msg</code>, <code>backtrace</code> and{' '}
<code>error_message</code>. LogStruct drops them and handles the{' '}
<code>ArgumentError</code> as a LogStruct error (raised in <code>local_environments</code>
, logged elsewhere). Other types&apos; field names, like <code>name</code> on a plain
line, are free to use.
</li>
</ul>

Expand Down Expand Up @@ -328,8 +332,8 @@ export default function LogFieldsPage() {
it&apos;s capitalized.
</li>
<li>
A Hash: <code>_class</code>, <code>_keys_count</code> and its first 10 keys in{' '}
<code>_keys</code>. An empty Hash is <code>{'{"_class": "Hash", "_empty": true}'}</code>.
A Hash: <code>_class</code> and <code>_keys_count</code>. An empty Hash is{' '}
<code>{'{"_class": "Hash", "_empty": true}'}</code>.
</li>
<li>
An Array: <code>_class</code> and <code>_count</code>. An empty Array is{' '}
Expand All @@ -344,7 +348,7 @@ export default function LogFieldsPage() {
</li>
<li>
Values nested more than 20 levels deep, and values that contain themselves, are written as
a summary without <code>_keys</code>.
a summary.
</li>
</ul>
<p className={text}>
Expand Down Expand Up @@ -380,7 +384,9 @@ export default function LogFieldsPage() {
<li>
<code>max_string_bytes</code> (default 32768, 32 KB): a longer string is cut to that many
bytes after it&apos;s scrubbed, and ends with{' '}
<code>&quot;... (cut from N bytes)&quot;</code>.
<code>&quot;... (cut from N bytes)&quot;</code>. A longer Hash key and its value are
omitted;
<code>_oversized_keys</code> counts omitted keys.
</li>
</ul>
<RubyCodeExample name="log_fields_limits" />
Expand Down
10 changes: 3 additions & 7 deletions docs/app/docs/soc2-compliance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ export default function SOC2CompliancePage() {
keys, cookies, sessions, authorization headers, card numbers, social security numbers and
the like), or a key in Rails&apos; <code>filter_parameters</code> or your{' '}
<code>filter_keys</code>, is replaced by a summary: its class, plus a Hash&apos;s key
count and first 10 keys or an Array&apos;s item count. <code>email</code> and{' '}
<code>email_address</code> values get a hash of the address too.
count or an Array&apos;s item count. <code>email</code> and <code>email_address</code>{' '}
values get a hash of the address too.
</li>
<li>
<strong>Strings.</strong> In every string, LogStruct replaces email addresses with{' '}
Expand All @@ -113,11 +113,7 @@ export default function SOC2CompliancePage() {
"credentials": {
"_filtered": {
"_class": "Hash",
"_keys_count": 2,
"_keys": [
"username",
"password"
]
"_keys_count": 2
}
},
"reset_url": "https://app.example.com/reset?token=[FILTERED]&step=2",
Expand Down
7 changes: 4 additions & 3 deletions docs/app/docs/sorbet-types/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ bundle exec srb tc`}
<code>LogStruct::Log::Interfaces::AdditionalDataField</code> and{' '}
<code>LogStruct::Log::Shared::MergeAdditionalDataFields</code>, for an optional{' '}
<code>additional_data</code> Hash whose keys go at the top level of the line. A key named{' '}
<code>src</code>, <code>evt</code>, <code>lvl</code> or <code>ts</code> is a LogStruct
error (<code>logstruct_errors</code>): raised in development and test, and logged and left
out in production.
<code>src</code>, <code>evt</code>, <code>lvl</code>, <code>ts</code> or{' '}
<code>req_id</code> is a LogStruct error (<code>logstruct_errors</code>): raised in
development and test, and logged and left out in production. Your own fields win over
additional data when your <code>serialize</code> writes them after merging it.
</li>
</ul>
<p className={pClass}>
Expand Down
4 changes: 0 additions & 4 deletions docs/lib/log-generation/sample-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down Expand Up @@ -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),
Expand All @@ -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' }),
Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading