logsy 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be9d1c01334a5549c684e6e0807eb2f0b1b00f7d481b0862a0d0d005de31b81f
4
- data.tar.gz: 993aadd7127a75825114c9cadb6db4ef211fcd1e582320661b73ba483653aed9
3
+ metadata.gz: 980d9365473b25931ea00736958f1980b5872ec25901dbd09a58347f4eca8c70
4
+ data.tar.gz: 0646f18af7c441b599e61ec5dfb133268cf4d371594b8a08363c6511a7f758d8
5
5
  SHA512:
6
- metadata.gz: 4d48f08e5b3c2f13c14ac9f8d3983c46bbb7361785343912e9840b8652cef8f6d24e3e98726dcf97fa00b1473aa776f9c0c4418add83b5888d60d398f0d081a5
7
- data.tar.gz: e420eec6c13c97386d41bfbd32d91f54511c137808ce486ce00518a00df09226e6055526e8957baad210903ad71011dfc0f99fd013393cdac747b2f5558b1471
6
+ metadata.gz: 6687e938d72ff9054851a86f10cc7450046110d472a88043e125aef5b4c3797d236b9406b1c3645798cb8486b15614a1fc454aaa252cfd2ff2c39714e5a6221d
7
+ data.tar.gz: 6cd7238d880da15217483ecbaefe19f21f1bb2f6a591059b4a9feb3507e1b1942f21ec936b5af59ee5c35179d6d0efa22bada6e423b616bd2bf0fe6ae0782f78
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-06-29
4
+
5
+ - **Sidekiq middleware is now auto-registered** — the Railtie calls `Logsy::SidekiqMiddleware.install!` for you once Sidekiq is loaded, so `request_id` (and any `job_propagated_keys`) propagates into jobs with zero per-app wiring. No more `require 'logsy/sidekiq_middleware'` + three `chain.add` lines in `config/initializers/sidekiq.rb`. Opt out with `config.auto_register_sidekiq_middleware = false`. The registration initializer runs `after: :load_config_initializers`, so it works even when the app uses `gem 'sidekiq', require: false`.
6
+ - **`Logsy::SidekiqMiddleware.install!(sidekiq = ::Sidekiq)`**: new single entry point that registers the client + server middleware (client on both client and server chains, server on the server chain). Non-Rails apps now wire Sidekiq with one line instead of two config blocks. Idempotent — Sidekiq's `Chain#add` replaces, so it's safe alongside a stray manual registration.
7
+ - **`Logsy.tag(**tags)`**: set several tags at once (`Logsy.tag(user_id: u.id, plan: 'pro')`), with the same String coercion as `Logsy[]=`.
8
+ - **`Logsy.with(**tags) { ... }`**: set tags for the duration of a block and restore the previous values afterwards (deleting keys that were unset before), even if the block raises. Scopes context to a unit of work without leaking it — useful in service objects, rake tasks, and threads the Rails executor / Sidekiq middleware don't reset. Returns the block's value.
9
+ - `Configuration`: new `auto_register_sidekiq_middleware` (default `true`).
10
+ - **Performance** (`JsonFormatter`): no behaviour or output change, just less work on the hot path —
11
+ - Hash (wide-event) messages now skip the caller-location stack walk entirely. They're logged from a Logsy hook frame with no app frame to attribute, so the walk only ever scanned the full stack to emit no `file` anyway. Cuts a wide-event line with `include_caller_location` on from ~6.0µs to ~2.5µs (~2.4x) — and these fire once per request and once per job.
12
+ - The ANSI-code strip on String messages is now guarded by a cheap `include?("\e")` byte scan, so the common plain log line skips the regex and its allocation (~2.3x faster on that step).
13
+ - `app_root` is computed once per log line and threaded through the frame walk instead of being rebuilt for every stack frame inspected, removing an allocation per frame on the caller-location path.
14
+
3
15
  ## [0.3.0] - 2026-06-21
4
16
 
5
17
  - **`Logsy::JobHooks`**: new ActiveJob concern — the background-job counterpart of `ControllerHooks`. Emits one wide event (`event: "job"`) when each job finishes, carrying `job_class`, `queue`, `active_job_id`, `executions`, `duration_ms`, `status` (`"ok"`/`"error"`), `error`, and every Logsy tag set during the run. Emitted even when the job raises (then re-raised). Override `logsy_job_summary_extras` to add fields. Job arguments are deliberately omitted — ActiveJob already logs them at enqueue time, correlatable by `active_job_id`.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Tagged structured logging for Rails apps. JSON output, one line per log call.
4
4
 
5
- Logsy gives you **one wide JSON event per request** plus **correlated breadcrumb logs**, all tagged with whatever per-request context you set via `Logsy[:key] = value`. Tags propagate across background jobs (Sidekiq middleware bundled). Where you ship the JSON is your call — Logsy just writes structured lines to stdout.
5
+ Logsy gives you **one wide JSON event per request** plus **correlated breadcrumb logs**, all tagged with whatever per-request context you set via `Logsy[:key] = value`. Tags propagate across background jobs (Sidekiq middleware auto-wired — zero config). Where you ship the JSON is your call — Logsy just writes structured lines to stdout.
6
6
 
7
7
  ## Installation
8
8
 
@@ -53,26 +53,14 @@ end
53
53
 
54
54
  That's it. No `Current` model to define, no attribute declarations — just key/value.
55
55
 
56
- ### 5. (Optional) Background job propagation
56
+ ### 5. Background job propagation — automatic for Sidekiq
57
57
 
58
- If you use Sidekiq:
58
+ If you use Sidekiq, there's **nothing to do**: the Railtie registers the bundled middleware for you once Sidekiq is loaded, so `request_id` (and any other `job_propagated_keys`) flows from the enqueueing request into the job — and on into any jobs that job enqueues.
59
59
 
60
- ```ruby
61
- # config/initializers/sidekiq.rb
62
- require 'logsy/sidekiq_middleware'
63
-
64
- Sidekiq.configure_client do |config|
65
- config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
66
- end
67
-
68
- Sidekiq.configure_server do |config|
69
- config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
70
- config.server_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Server) }
71
- end
72
- ```
60
+ Want to propagate more than `request_id`? That's the only thing you'd configure:
73
61
 
74
62
  ```ruby
75
- # config/initializers/logsy.rb (optional — only if you want to propagate more than request_id)
63
+ # config/initializers/logsy.rb (optional)
76
64
  Logsy.configure do |c|
77
65
  c.job_propagated_keys = %i[request_id user_id tenant_id]
78
66
  end
@@ -84,7 +72,14 @@ The bundled middleware:
84
72
 
85
73
  Anything the job code writes via `Logsy[:foo] = bar` while running shows up on every log line emitted during the job. Tags reset automatically after each job (no leak between jobs sharing a worker thread). The server middleware also sets `Logsy[:job_class]` (the wrapped ActiveJob class name, e.g. `"GenerateEodJob"`, falling back to the worker class for plain Sidekiq jobs).
86
74
 
87
- For other job systems (Resque, GoodJob, custom), write your own middleware using the same pattern — Logsy's `[]=` / `[]` / `tags` / `reset` API is generic.
75
+ To wire it up yourself (non-Rails, or after opting out with `config.auto_register_sidekiq_middleware = false`) it's a one-liner:
76
+
77
+ ```ruby
78
+ require 'logsy/sidekiq_middleware'
79
+ Logsy::SidekiqMiddleware.install! # registers client + server middleware
80
+ ```
81
+
82
+ For other job systems (Resque, GoodJob, custom), write your own middleware using the same pattern — Logsy's `[]=` / `[]` / `tag` / `with` / `tags` / `reset` API is generic.
88
83
 
89
84
  ### 6. Job wide event — automatic
90
85
 
@@ -175,14 +170,24 @@ Notes on the fields:
175
170
  ## API reference
176
171
 
177
172
  ```ruby
178
- Logsy[:user_id] = 'u-1' # set a tag
179
- Logsy[:user_id] # read it
180
- Logsy.tags # all tags as a hash
181
- Logsy.reset # clear all tags (the controller/middleware do this for you)
173
+ Logsy[:user_id] = 'u-1' # set a tag
174
+ Logsy[:user_id] # read it
175
+ Logsy.tag(user_id: 'u-1', plan: 'pro') # set several at once
176
+ Logsy.with(order_id: 'o-9') { ... } # set for the block, restore afterwards
177
+ Logsy.tags # all tags as a hash
178
+ Logsy.reset # clear all tags (the controller/middleware do this for you)
182
179
  ```
183
180
 
184
181
  Symbol and string keys are equivalent — `Logsy['user_id']` and `Logsy[:user_id]` access the same slot.
185
182
 
183
+ `Logsy.with` scopes tags to a block and restores the prior values on exit (even if the block raises) — handy in service objects, rake tasks, or threads the Rails executor / Sidekiq middleware don't reset for you:
184
+
185
+ ```ruby
186
+ Logsy.with(order_id: order.id) do
187
+ process(order) # every log line here carries order_id; gone afterwards
188
+ end
189
+ ```
190
+
186
191
  ## Customizing the wide event
187
192
 
188
193
  Override `logsy_request_summary_extras` in your controller to add fields:
@@ -234,6 +239,12 @@ Logsy.configure do |c|
234
239
  # so jobs emit summaries with no setup). Set false to wire it yourself.
235
240
  # Default: true
236
241
  c.auto_include_job_hooks = true
242
+
243
+ # Register the bundled Sidekiq middleware automatically when Sidekiq is
244
+ # loaded (the Railtie does this so request_id propagates into jobs with
245
+ # no setup). Set false to call Logsy::SidekiqMiddleware.install! yourself.
246
+ # Default: true
247
+ c.auto_register_sidekiq_middleware = true
237
248
  end
238
249
  ```
239
250
 
@@ -251,7 +262,7 @@ Logsy fills the gap: a small, dictionary-style API (`Logsy[:foo] = bar`) plus a
251
262
 
252
263
  ```bash
253
264
  bundle install
254
- bundle exec rspec # 34 specs
265
+ bundle exec rspec # 64 specs
255
266
  bundle exec rubocop # lint
256
267
  ```
257
268
 
@@ -23,7 +23,7 @@ module Logsy
23
23
  attr_accessor :job_propagated_keys, :ignored_caller_paths,
24
24
  :include_caller_location, :request_summary_event_name,
25
25
  :job_summary_event_name, :caller_location_max_depth,
26
- :auto_include_job_hooks
26
+ :auto_include_job_hooks, :auto_register_sidekiq_middleware
27
27
 
28
28
  def initialize
29
29
  @job_propagated_keys = [:request_id]
@@ -35,11 +35,16 @@ module Logsy
35
35
  # into ActiveJob::Base, so every job emits a summary wide event with
36
36
  # no per-app setup. Set to false to opt out and include it manually.
37
37
  @auto_include_job_hooks = true
38
+ # When true (the default), the Railtie registers the bundled Sidekiq
39
+ # middleware (client + server) for you when Sidekiq is loaded, so
40
+ # request_id and friends propagate into jobs with no per-app wiring.
41
+ # Set to false to register them yourself (or not at all).
42
+ @auto_register_sidekiq_middleware = true
38
43
  # How many stack frames to inspect before giving up on attributing
39
44
  # the log line to app code. App frames sit within a few dozen frames
40
45
  # of the logger; framework-only lines (no app frame at all) would
41
46
  # otherwise pay a full walk of a 100+ deep Rails stack on every line.
42
- @caller_location_max_depth = 64
47
+ @caller_location_max_depth = 64
43
48
  end
44
49
  end
45
50
  end
@@ -42,7 +42,11 @@ module Logsy
42
42
  level: severity
43
43
  }
44
44
 
45
- add_caller_location!(payload) if Logsy.configuration.include_caller_location
45
+ # Hash messages are structured wide-event emissions from our own
46
+ # ControllerHooks/JobHooks. They're logged from a logsy frame with no
47
+ # app frame above to attribute, so the caller walk only ever scans the
48
+ # full stack to emit no :file — skip it (output is identical either way).
49
+ add_caller_location!(payload) if Logsy.configuration.include_caller_location && !message.is_a?(::Hash)
46
50
  merge_context!(payload)
47
51
  merge_message!(payload, message)
48
52
 
@@ -54,10 +58,11 @@ module Logsy
54
58
  private
55
59
 
56
60
  def add_caller_location!(payload)
57
- location = find_user_frame
61
+ root = app_root # computed once per line, not rebuilt for every frame inspected
62
+ location = find_user_frame(root)
58
63
  return unless location
59
64
 
60
- payload[:file] = "#{relative_path(location.path)}:#{location.lineno}"
65
+ payload[:file] = "#{relative_path(location.path, root)}:#{location.lineno}"
61
66
  end
62
67
 
63
68
  # Walk the stack in small batches instead of capturing it all at once:
@@ -66,14 +71,14 @@ module Logsy
66
71
  # capturing everything costs ~3x more per log line. The walk is capped
67
72
  # at caller_location_max_depth so framework-only lines (no app frame
68
73
  # anywhere) don't pay for a full-stack scan either.
69
- def find_user_frame
74
+ def find_user_frame(root)
70
75
  start = 2 # skip find_user_frame and add_caller_location!
71
76
  limit = start + Logsy.configuration.caller_location_max_depth
72
77
  while start < limit
73
78
  batch = caller_locations(start, [FRAME_BATCH_SIZE, limit - start].min)
74
79
  return nil if batch.nil? || batch.empty?
75
80
 
76
- batch.each { |loc| return loc if user_frame?(loc.path) }
81
+ batch.each { |loc| return loc if user_frame?(loc.path, root) }
77
82
  return nil if batch.size < FRAME_BATCH_SIZE
78
83
 
79
84
  start += FRAME_BATCH_SIZE
@@ -85,10 +90,10 @@ module Logsy
85
90
  # stdlib, or logging machinery. With an app root (Rails), only frames
86
91
  # under it count (vendored gems under root excluded); without one,
87
92
  # any non-gem frame counts.
88
- def user_frame?(path)
93
+ def user_frame?(path, root)
89
94
  return false if Logsy.configuration.ignored_caller_paths.any? { |pattern| path.match?(pattern) }
90
95
 
91
- if (root = app_root)
96
+ if root
92
97
  path.start_with?(root) && !path.include?('/vendor/bundle/')
93
98
  else
94
99
  !gem_frame?(path)
@@ -105,8 +110,7 @@ module Logsy
105
110
  defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? "#{Rails.root}/" : nil
106
111
  end
107
112
 
108
- def relative_path(path)
109
- root = app_root
113
+ def relative_path(path, root)
110
114
  return path unless root && path.start_with?(root)
111
115
 
112
116
  path[root.length..]
@@ -123,7 +127,10 @@ module Logsy
123
127
  def merge_message!(payload, message)
124
128
  case message
125
129
  when ::String
126
- payload[:msg] = message.gsub(ANSI_ESCAPE, '').strip
130
+ # Only ActiveRecord SQL lines carry ANSI codes; skip the regex (and
131
+ # its allocation) on the common plain line with a cheap byte scan.
132
+ cleaned = message.include?("\e") ? message.gsub(ANSI_ESCAPE, '') : message
133
+ payload[:msg] = cleaned.strip
127
134
  when ::Hash
128
135
  message.each { |k, v| payload[k.to_sym] = v }
129
136
  when ::Exception
data/lib/logsy/railtie.rb CHANGED
@@ -8,6 +8,8 @@ module Logsy
8
8
  # that the "Started GET ..." line is already tagged.
9
9
  # - Includes Logsy::JobHooks into ActiveJob::Base so every job emits a
10
10
  # summary wide event (disable via config.auto_include_job_hooks = false).
11
+ # - Registers the bundled Sidekiq middleware when Sidekiq is loaded
12
+ # (disable via config.auto_register_sidekiq_middleware = false).
11
13
  class Railtie < ::Rails::Railtie
12
14
  initializer 'logsy.insert_rack_middleware' do |app|
13
15
  app.middleware.insert_after(ActionDispatch::RequestId, Logsy::RackMiddleware)
@@ -21,5 +23,16 @@ module Logsy
21
23
  include(Logsy::JobHooks) if Logsy.configuration.auto_include_job_hooks
22
24
  end
23
25
  end
26
+
27
+ # Runs after config/initializers so it sees Sidekiq even when the app
28
+ # uses `gem 'sidekiq', require: false` and loads it in an initializer.
29
+ # Registering here still takes effect: Sidekiq reads its middleware
30
+ # chains when jobs are enqueued/run, which is always after boot.
31
+ initializer 'logsy.register_sidekiq_middleware', after: :load_config_initializers do
32
+ next unless Logsy.configuration.auto_register_sidekiq_middleware && defined?(::Sidekiq)
33
+
34
+ require 'logsy/sidekiq_middleware'
35
+ Logsy::SidekiqMiddleware.install!
36
+ end
24
37
  end
25
38
  end
@@ -4,18 +4,36 @@ require 'logsy/sidekiq_middleware/client'
4
4
  require 'logsy/sidekiq_middleware/server'
5
5
 
6
6
  module Logsy
7
- # Sidekiq middleware namespace. Require this file (or rely on a Rails
8
- # railtie) to make {Client} and {Server} available, then register them
9
- # with Sidekiq:
7
+ # Sidekiq middleware namespace.
10
8
  #
11
- # Sidekiq.configure_client do |config|
12
- # config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
13
- # end
9
+ # In a Rails app there is nothing to do: the Railtie calls {install!} for
10
+ # you once Sidekiq is loaded (opt out with
11
+ # `config.auto_register_sidekiq_middleware = false`).
14
12
  #
15
- # Sidekiq.configure_server do |config|
16
- # config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
17
- # config.server_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Server) }
18
- # end
13
+ # Outside Rails — or after opting out — require this file and register the
14
+ # middleware with a single call:
15
+ #
16
+ # require 'logsy/sidekiq_middleware'
17
+ # Logsy::SidekiqMiddleware.install!
19
18
  module SidekiqMiddleware
19
+ # Registers the bundled middleware with Sidekiq:
20
+ # - Client on the client chain (web/console enqueues a job)
21
+ # - Client on the server's client chain (a running job enqueues another)
22
+ # - Server on the server chain (a worker runs a job)
23
+ #
24
+ # Idempotent: Sidekiq's Chain#add replaces an existing entry, so calling
25
+ # this twice (e.g. auto-registration plus a stray manual call) won't
26
+ # duplicate the middleware. `sidekiq` defaults to the ::Sidekiq constant
27
+ # and is injectable for testing.
28
+ def self.install!(sidekiq = ::Sidekiq)
29
+ sidekiq.configure_client do |config|
30
+ config.client_middleware { |chain| chain.add(Client) }
31
+ end
32
+
33
+ sidekiq.configure_server do |config|
34
+ config.client_middleware { |chain| chain.add(Client) }
35
+ config.server_middleware { |chain| chain.add(Server) }
36
+ end
37
+ end
20
38
  end
21
39
  end
data/lib/logsy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Logsy
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/logsy.rb CHANGED
@@ -32,6 +32,35 @@ module Logsy
32
32
  Store.tags[key.to_sym] = value&.to_s
33
33
  end
34
34
 
35
+ # Set several tags at once. Keyword sugar over repeated `Logsy[]=`,
36
+ # so the same String coercion applies. Returns the given tags.
37
+ #
38
+ # Logsy.tag(user_id: user.id, order_id: order.id)
39
+ def tag(**tags)
40
+ tags.each { |key, value| self[key] = value }
41
+ tags
42
+ end
43
+
44
+ # Set tags for the duration of the block, then restore the previous
45
+ # values (deleting keys that were unset before). Use to scope context
46
+ # to a unit of work without leaking it to whatever runs next on the
47
+ # same thread/fiber — handy in service objects, rake tasks, or threads
48
+ # the Rails executor / Sidekiq middleware don't reset for you.
49
+ #
50
+ # Logsy.with(order_id: order.id) do
51
+ # process(order) # every log line here carries order_id
52
+ # end
53
+ #
54
+ # Returns the block's value.
55
+ def with(**tags)
56
+ keys = tags.keys.map(&:to_sym)
57
+ saved = keys.to_h { |key| [key, Store.tags[key]] }
58
+ tag(**tags)
59
+ yield
60
+ ensure
61
+ saved.each { |key, value| value.nil? ? Store.tags.delete(key) : Store.tags[key] = value }
62
+ end
63
+
35
64
  # All tags currently set, as a hash. Used by the formatter on every
36
65
  # log line; consumers can call it for debugging.
37
66
  def tags
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logsy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ruby_is_love