logsy 0.1.0 → 0.2.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: eee92d3c7715964e65bd1a5f5efb80e07328bd8fdf3de735d07664c429f19623
4
- data.tar.gz: 3a5a149095b25d6cb00e662f96c5d235c1e35dcb1e728f9977676a49ed039e6b
3
+ metadata.gz: 8d9d026e66871c418c7315bbec120182f0aa3a2e4caa247e9fa533a4a6b99361
4
+ data.tar.gz: caf2ad615f13cd2f75954e43dfcab5d61e93ec40b11c004bf9723bd8c4736721
5
5
  SHA512:
6
- metadata.gz: 6035309f83d88ad2a07745d01331d38ecff83254685e9b76d204de8f65b2cba0e86246b192dea878ee1231b8e7f98a0d435cd0d80166e1f37422046ab588db2a
7
- data.tar.gz: 2fd3061b4df673dd3bc186d0f61bc4e4ac945de8d1f45a6b1d4cf24025f95db834a65a3e453e6aefdc088bcc079a06c0fe60e2635a2b510e830449dcee470a6d
6
+ metadata.gz: 9bed718fb7a7618cd420220579e7edd7ce92b44d46f538a0ef2459644014fc207febe0c09cc51a3b72624d083930dc78a37e00752bab2ef6fd34f4291c6677f0
7
+ data.tar.gz: 2e2d8bd7a250600dedbf8e704e87227cd5ff590c8f9fee39276a1a909b8b1973d51db5f09e6a415ccd375a5222b9bef87039958e80a9c44b3718d4c5724c7d05
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-06-07
4
+
5
+ - **`Logsy::RackMiddleware` + Railtie**: request_id is now captured in Rack middleware (auto-inserted after `ActionDispatch::RequestId`) instead of a controller `before_action`. Every log line of a request now carries `request_id` — including "Started GET ..." and "Processing by ..." which a controller callback fires too late to tag. Falls back to `X-Request-Id`, then a generated UUID, so the tag is always present.
6
+ - **Breaking-ish**: `Logsy::ControllerHooks` no longer registers the request-id `before_action` (the middleware supersedes it). It still emits the wide event.
7
+ - `JsonFormatter`: `file` and `line` merged into a single `file` field (`"app/models/order.rb:42"`).
8
+ - `JsonFormatter`: caller-location now skips gem/stdlib frames and attributes the line to the nearest *app* frame (under `Rails.root`, excluding `vendor/bundle`) — an ActiveRecord SQL line points at your controller/model code, not `active_record/log_subscriber.rb`. Omitted when no app frame exists.
9
+ - `JsonFormatter`: String messages are cleaned — ANSI color codes stripped (ActiveRecord SQL highlighting) and surrounding whitespace removed.
10
+ - `JsonFormatter`: caller-location lookup walks the stack in batches of 16 frames and gives up after `caller_location_max_depth` frames (default 64, configurable) instead of capturing the full 100+-frame Rails stack on every line. Typical app log line: ~7µs; framework-only line: bounded by the cap.
11
+ - `ControllerHooks`: the wide event no longer reports a wrong `status` when the action raised (the final status is decided by `rescue_from` after the hook unwinds); `status` is omitted on error, with `error` carrying the class/message.
12
+
3
13
  ## [0.1.0] - Unreleased
4
14
 
5
15
  - Initial release.
data/README.md CHANGED
@@ -25,7 +25,11 @@ config.logger = ActiveSupport::Logger.new($stdout)
25
25
  .tap { |l| l.formatter = Logsy::JsonFormatter.new }
26
26
  ```
27
27
 
28
- ### 2. Include the controller hooks
28
+ ### 2. Request id automatic
29
+
30
+ In a Rails app there is nothing to do: a Railtie inserts `Logsy::RackMiddleware` right after `ActionDispatch::RequestId`, so `Logsy[:request_id]` is set before *any* request log line is emitted — including Rails' own "Started GET ..." and "Processing by ..." lines. It uses `X-Request-Id` when the caller (or your proxy) sends one, otherwise the UUID Rails generated, otherwise generates one itself — the tag is always present.
31
+
32
+ ### 3. Include the controller hooks (wide event)
29
33
 
30
34
  ```ruby
31
35
  # app/controllers/application_controller.rb
@@ -34,11 +38,9 @@ class ApplicationController < ActionController::API
34
38
  end
35
39
  ```
36
40
 
37
- This gives you:
38
- - `Logsy[:request_id]` automatically populated from `request.request_id` (or `X-Request-Id` header) on every request
39
- - One "wide event" log line at end of each request with `event: "request"`, method, path, controller, action, status, duration_ms, and any error class/message — plus every tag you set during the request
41
+ This emits one "wide event" log line at end of each request with `event: "request"`, method, path, controller, action, status, duration_ms, and any error class/message — plus every tag you set during the request. (On an unhandled error, `status` is omitted — the final status is decided by `rescue_from` after the hook unwinds — and `error` carries the class/message.)
40
42
 
41
- ### 3. Set tags wherever they become known
43
+ ### 4. Set tags wherever they become known
42
44
 
43
45
  ```ruby
44
46
  # In a controller, model, service, anywhere:
@@ -51,7 +53,7 @@ end
51
53
 
52
54
  That's it. No `Current` model to define, no attribute declarations — just key/value.
53
55
 
54
- ### 4. (Optional) Background job propagation
56
+ ### 5. (Optional) Background job propagation
55
57
 
56
58
  If you use Sidekiq:
57
59
 
@@ -92,9 +94,8 @@ A regular log line:
92
94
  {
93
95
  "ts":"2026-05-02T10:00:00.123Z",
94
96
  "level":"INFO",
95
- "msg":"Calling SPG gateway",
96
- "file":"app/lib/gateways/spg_gateway.rb",
97
- "line":437,
97
+ "msg":"Calling payment gateway",
98
+ "file":"app/services/billing_service.rb:437",
98
99
  "request_id":"abc-123",
99
100
  "user_id":"u-1",
100
101
  "message_id":"m-42",
@@ -124,6 +125,11 @@ The wide event at end of request:
124
125
 
125
126
  Search your log store by `message_id` to find every request from that message. Pivot from a request's `request_id` to see every breadcrumb log line emitted while it ran.
126
127
 
128
+ Notes on the fields:
129
+
130
+ - **`file`** points at the nearest *app* frame — gem, stdlib, and logging-machinery frames are skipped, so an ActiveRecord SQL line is attributed to the controller/model code that ran the query, not to `active_record/log_subscriber.rb`. Framework-only lines with no app frame (e.g. "Started GET ...") omit `file` rather than show a useless gem path.
131
+ - **`msg`** is cleaned: ANSI color codes (ActiveRecord's SQL highlighting) are stripped and surrounding whitespace removed, so the text is plain and searchable.
132
+
127
133
  ## API reference
128
134
 
129
135
  ```ruby
@@ -167,6 +173,11 @@ Logsy.configure do |c|
167
173
  # Disable if you measure overhead. Default: true
168
174
  c.include_caller_location = true
169
175
 
176
+ # How many stack frames to inspect before giving up on attributing a log
177
+ # line to app code. Bounds the cost of framework-only lines that have no
178
+ # app frame anywhere in the stack. Default: 64
179
+ c.caller_location_max_depth = 64
180
+
170
181
  # Regex patterns for caller frames to skip. Defaults already cover Logger,
171
182
  # ActiveSupport, lograge, sprockets, quiet_assets, and Logsy itself.
172
183
  c.ignored_caller_paths += [%r{/my_internal_logger/}]
@@ -16,13 +16,19 @@ module Logsy
16
16
  # these). The middleware that does the actual carrying is job-runner
17
17
  # specific (Logsy ships one for Sidekiq).
18
18
  attr_accessor :job_propagated_keys, :ignored_caller_paths,
19
- :include_caller_location, :request_summary_event_name
19
+ :include_caller_location, :request_summary_event_name,
20
+ :caller_location_max_depth
20
21
 
21
22
  def initialize
22
23
  @job_propagated_keys = [:request_id]
23
24
  @ignored_caller_paths = DEFAULT_IGNORED_CALLER_PATHS.dup
24
25
  @include_caller_location = true
25
26
  @request_summary_event_name = 'request'
27
+ # How many stack frames to inspect before giving up on attributing
28
+ # the log line to app code. App frames sit within a few dozen frames
29
+ # of the logger; framework-only lines (no app frame at all) would
30
+ # otherwise pay a full walk of a 100+ deep Rails stack on every line.
31
+ @caller_location_max_depth = 64
26
32
  end
27
33
  end
28
34
  end
@@ -3,13 +3,14 @@
3
3
  require 'active_support/concern'
4
4
 
5
5
  module Logsy
6
- # Mix into your ApplicationController (or specific controllers) to:
6
+ # Mix into your ApplicationController (or specific controllers) to emit
7
+ # a single "wide event" log line at end of request with the method,
8
+ # path, status, duration_ms, controller, action, error, and every Logsy
9
+ # tag that was set during the request.
7
10
  #
8
- # 1. Capture the request's UUID into Logsy.context.request_id, so every
9
- # log line emitted during the request carries it.
10
- # 2. Emit a single "wide event" log line at end of request with the
11
- # method, path, status, duration_ms, controller, action, error, and
12
- # every Current.* attribute that was set during the request.
11
+ # (Request-id capture lives in Logsy::RackMiddleware, inserted
12
+ # automatically by the Railtie not here. A controller callback fires
13
+ # too late to tag the "Started GET" / "Processing by" lines.)
13
14
  #
14
15
  # Usage:
15
16
  #
@@ -27,16 +28,11 @@ module Logsy
27
28
  extend ActiveSupport::Concern
28
29
 
29
30
  included do
30
- before_action :_logsy_capture_request_id
31
31
  around_action :_logsy_emit_request_summary
32
32
  end
33
33
 
34
34
  private
35
35
 
36
- def _logsy_capture_request_id
37
- Logsy[:request_id] = request.request_id || request.headers['X-Request-Id']
38
- end
39
-
40
36
  def _logsy_emit_request_summary
41
37
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
38
  error = nil
@@ -52,7 +48,11 @@ module Logsy
52
48
  path: request.path,
53
49
  controller: controller_name,
54
50
  action: action_name,
55
- status: response&.status,
51
+ # On an unhandled error the final status is decided by rescue_from /
52
+ # the exception app *after* this around_action unwinds, so
53
+ # response.status still holds the pre-error default — omit it
54
+ # rather than report it wrong.
55
+ status: error ? nil : response&.status,
56
56
  duration_ms:,
57
57
  error: error && "#{error.class}: #{error.message}"
58
58
  }.merge(logsy_request_summary_extras).compact
@@ -11,8 +11,18 @@ module Logsy
11
11
  # - ts: UTC ISO-8601 timestamp with millis
12
12
  # - level: severity ('INFO', 'ERROR', ...)
13
13
  # - msg: the log message (when message is a String or Exception)
14
- # - file/line: source location of the call (when include_caller_location)
15
- # - any non-nil attributes from the configured Logsy.context
14
+ # - file: "path:line" of the nearest app frame (when
15
+ # include_caller_location). Gem, stdlib, and ignored frames
16
+ # are skipped, so an ActiveRecord SQL line points at the
17
+ # controller/model code that ran the query — not at
18
+ # active_record/log_subscriber.rb. Omitted entirely for
19
+ # framework-only lines (e.g. "Started GET ..."), which have
20
+ # no app frame.
21
+ # - any non-nil tags from Logsy[]
22
+ #
23
+ # String messages are cleaned before emission: ANSI color codes (e.g.
24
+ # ActiveRecord's SQL highlighting) are stripped and surrounding
25
+ # whitespace removed, so log stores get plain searchable text.
16
26
  #
17
27
  # When the message is a Hash, its keys are merged directly into the
18
28
  # top-level payload (useful for "wide event" emissions like a request
@@ -20,9 +30,12 @@ module Logsy
20
30
  #
21
31
  # Example output:
22
32
  # {"ts":"2026-05-02T10:00:00.123Z","level":"INFO","msg":"hello",
23
- # "file":"app/controllers/orders_controller.rb","line":34,
33
+ # "file":"app/controllers/orders_controller.rb:34",
24
34
  # "request_id":"abc-123","user_id":"u-1"}
25
35
  class JsonFormatter < ::Logger::Formatter
36
+ ANSI_ESCAPE = /\e\[[0-9;]*m/
37
+ FRAME_BATCH_SIZE = 16
38
+
26
39
  def call(severity, time, _progname, message)
27
40
  payload = {
28
41
  ts: time.utc.iso8601(3),
@@ -41,19 +54,59 @@ module Logsy
41
54
  private
42
55
 
43
56
  def add_caller_location!(payload)
44
- location = caller_locations.find { |loc| user_frame?(loc.path) }
57
+ location = find_user_frame
45
58
  return unless location
46
59
 
47
- payload[:file] = relative_path(location.path)
48
- payload[:line] = location.lineno
60
+ payload[:file] = "#{relative_path(location.path)}:#{location.lineno}"
61
+ end
62
+
63
+ # Walk the stack in small batches instead of capturing it all at once:
64
+ # the frame we want is usually a handful of frames above the logger
65
+ # machinery, while Rails request stacks run 100+ frames deep —
66
+ # capturing everything costs ~3x more per log line. The walk is capped
67
+ # at caller_location_max_depth so framework-only lines (no app frame
68
+ # anywhere) don't pay for a full-stack scan either.
69
+ def find_user_frame
70
+ start = 2 # skip find_user_frame and add_caller_location!
71
+ limit = start + Logsy.configuration.caller_location_max_depth
72
+ while start < limit
73
+ batch = caller_locations(start, [FRAME_BATCH_SIZE, limit - start].min)
74
+ return nil if batch.nil? || batch.empty?
75
+
76
+ batch.each { |loc| return loc if user_frame?(loc.path) }
77
+ return nil if batch.size < FRAME_BATCH_SIZE
78
+
79
+ start += FRAME_BATCH_SIZE
80
+ end
81
+ nil
49
82
  end
50
83
 
84
+ # A frame worth attributing the log line to: app code, not gems,
85
+ # stdlib, or logging machinery. With an app root (Rails), only frames
86
+ # under it count (vendored gems under root excluded); without one,
87
+ # any non-gem frame counts.
51
88
  def user_frame?(path)
52
- Logsy.configuration.ignored_caller_paths.none? { |pattern| path.match?(pattern) }
89
+ return false if Logsy.configuration.ignored_caller_paths.any? { |pattern| path.match?(pattern) }
90
+
91
+ if (root = app_root)
92
+ path.start_with?(root) && !path.include?('/vendor/bundle/')
93
+ else
94
+ !gem_frame?(path)
95
+ end
96
+ end
97
+
98
+ def gem_frame?(path)
99
+ path.include?('/gems/') ||
100
+ path.include?('/rubygems/') ||
101
+ path.start_with?('<internal:', RbConfig::CONFIG['rubylibdir'])
102
+ end
103
+
104
+ def app_root
105
+ defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? "#{Rails.root}/" : nil
53
106
  end
54
107
 
55
108
  def relative_path(path)
56
- root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? "#{Rails.root}/" : nil
109
+ root = app_root
57
110
  return path unless root && path.start_with?(root)
58
111
 
59
112
  path[root.length..]
@@ -70,7 +123,7 @@ module Logsy
70
123
  def merge_message!(payload, message)
71
124
  case message
72
125
  when ::String
73
- payload[:msg] = message
126
+ payload[:msg] = message.gsub(ANSI_ESCAPE, '').strip
74
127
  when ::Hash
75
128
  message.each { |k, v| payload[k.to_sym] = v }
76
129
  when ::Exception
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Logsy
6
+ # Rack middleware that captures the request id into Logsy[:request_id]
7
+ # at the earliest useful point in the stack — before Rails::Rack::Logger
8
+ # emits "Started GET ..." and before ActionController's start_processing
9
+ # event fires — so *every* log line of the request carries it, not just
10
+ # the ones emitted after controller callbacks run.
11
+ #
12
+ # The Railtie inserts this automatically right after
13
+ # ActionDispatch::RequestId (which populates `action_dispatch.request_id`
14
+ # from X-Request-Id or generates a UUID). Outside Rails, falls back to
15
+ # the X-Request-Id header, then to a generated UUID, so the tag is
16
+ # always present.
17
+ #
18
+ # Tag cleanup between requests is handled by the Rails executor
19
+ # (ActionDispatch::Executor wraps this middleware and resets
20
+ # CurrentAttributes); plain Rack apps should call Logsy.reset themselves.
21
+ class RackMiddleware
22
+ def initialize(app)
23
+ @app = app
24
+ end
25
+
26
+ def call(env)
27
+ Logsy[:request_id] = env['action_dispatch.request_id'] ||
28
+ env['HTTP_X_REQUEST_ID'] ||
29
+ SecureRandom.uuid
30
+
31
+ @app.call(env)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logsy
4
+ # Inserts the request-id capturing middleware right after
5
+ # ActionDispatch::RequestId, before Rails::Rack::Logger — early enough
6
+ # that the "Started GET ..." line is already tagged.
7
+ class Railtie < ::Rails::Railtie
8
+ initializer 'logsy.insert_rack_middleware' do |app|
9
+ app.middleware.insert_after(ActionDispatch::RequestId, Logsy::RackMiddleware)
10
+ end
11
+ end
12
+ 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.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/logsy.rb CHANGED
@@ -5,6 +5,8 @@ require 'logsy/store'
5
5
  require 'logsy/configuration'
6
6
  require 'logsy/json_formatter'
7
7
  require 'logsy/controller_hooks'
8
+ require 'logsy/rack_middleware'
9
+ require 'logsy/railtie' if defined?(Rails::Railtie)
8
10
 
9
11
  module Logsy
10
12
  class Error < StandardError; end
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ruby_is_love
@@ -95,6 +95,8 @@ files:
95
95
  - lib/logsy/configuration.rb
96
96
  - lib/logsy/controller_hooks.rb
97
97
  - lib/logsy/json_formatter.rb
98
+ - lib/logsy/rack_middleware.rb
99
+ - lib/logsy/railtie.rb
98
100
  - lib/logsy/sidekiq_middleware.rb
99
101
  - lib/logsy/sidekiq_middleware/client.rb
100
102
  - lib/logsy/sidekiq_middleware/server.rb