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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +20 -9
- data/lib/logsy/configuration.rb +7 -1
- data/lib/logsy/controller_hooks.rb +12 -12
- data/lib/logsy/json_formatter.rb +62 -9
- data/lib/logsy/rack_middleware.rb +34 -0
- data/lib/logsy/railtie.rb +12 -0
- data/lib/logsy/version.rb +1 -1
- data/lib/logsy.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d9d026e66871c418c7315bbec120182f0aa3a2e4caa247e9fa533a4a6b99361
|
|
4
|
+
data.tar.gz: caf2ad615f13cd2f75954e43dfcab5d61e93ec40b11c004bf9723bd8c4736721
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
|
96
|
-
"file":"app/
|
|
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/}]
|
data/lib/logsy/configuration.rb
CHANGED
|
@@ -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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
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
|
data/lib/logsy/json_formatter.rb
CHANGED
|
@@ -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
|
|
15
|
-
#
|
|
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
|
|
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 =
|
|
57
|
+
location = find_user_frame
|
|
45
58
|
return unless location
|
|
46
59
|
|
|
47
|
-
payload[:file] = relative_path(location.path)
|
|
48
|
-
|
|
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.
|
|
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 =
|
|
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
data/lib/logsy.rb
CHANGED
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.
|
|
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
|