logsy 0.1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +199 -0
- data/lib/logsy/configuration.rb +28 -0
- data/lib/logsy/controller_hooks.rb +68 -0
- data/lib/logsy/json_formatter.rb +93 -0
- data/lib/logsy/sidekiq_middleware/client.rb +18 -0
- data/lib/logsy/sidekiq_middleware/server.rb +25 -0
- data/lib/logsy/sidekiq_middleware.rb +21 -0
- data/lib/logsy/store.rb +20 -0
- data/lib/logsy/version.rb +5 -0
- data/lib/logsy.rb +64 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eee92d3c7715964e65bd1a5f5efb80e07328bd8fdf3de735d07664c429f19623
|
|
4
|
+
data.tar.gz: 3a5a149095b25d6cb00e662f96c5d235c1e35dcb1e728f9977676a49ed039e6b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6035309f83d88ad2a07745d01331d38ecff83254685e9b76d204de8f65b2cba0e86246b192dea878ee1231b8e7f98a0d435cd0d80166e1f37422046ab588db2a
|
|
7
|
+
data.tar.gz: 2fd3061b4df673dd3bc186d0f61bc4e4ac945de8d1f45a6b1d4cf24025f95db834a65a3e453e6aefdc088bcc079a06c0fe60e2635a2b510e830449dcee470a6d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - Unreleased
|
|
4
|
+
|
|
5
|
+
- Initial release.
|
|
6
|
+
- `Logsy[]` / `Logsy[]=` / `Logsy.tags` dictionary-style API for per-request tags — no subclass required.
|
|
7
|
+
- `Logsy::JsonFormatter` for structured JSON log output (one line per call, all current tags merged in).
|
|
8
|
+
- `Logsy::ControllerHooks` for Rails request_id capture and wide-event emission.
|
|
9
|
+
- `Logsy::SidekiqMiddleware::Client` / `Server` for Sidekiq context propagation. Sets `Logsy[:job_id]` automatically. No Sidekiq dependency at runtime.
|
|
10
|
+
- Default `ignored_caller_paths` covers Logger, ActiveSupport, lograge, sprockets, quiet_assets, and Logsy itself.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ruby_is_love
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Logsy
|
|
2
|
+
|
|
3
|
+
Tagged structured logging for Rails apps. JSON output, one line per log call.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem 'logsy'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### 1. Wire up the JSON formatter
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# config/environments/production.rb
|
|
24
|
+
config.logger = ActiveSupport::Logger.new($stdout)
|
|
25
|
+
.tap { |l| l.formatter = Logsy::JsonFormatter.new }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Include the controller hooks
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# app/controllers/application_controller.rb
|
|
32
|
+
class ApplicationController < ActionController::API
|
|
33
|
+
include Logsy::ControllerHooks
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
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
|
|
40
|
+
|
|
41
|
+
### 3. Set tags wherever they become known
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# In a controller, model, service, anywhere:
|
|
45
|
+
def create
|
|
46
|
+
order = Order.create!(order_params)
|
|
47
|
+
Logsy[:order_id] = order.id # every log from here on includes order_id
|
|
48
|
+
...
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it. No `Current` model to define, no attribute declarations — just key/value.
|
|
53
|
+
|
|
54
|
+
### 4. (Optional) Background job propagation
|
|
55
|
+
|
|
56
|
+
If you use Sidekiq:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# config/initializers/sidekiq.rb
|
|
60
|
+
require 'logsy/sidekiq_middleware'
|
|
61
|
+
|
|
62
|
+
Sidekiq.configure_client do |config|
|
|
63
|
+
config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Sidekiq.configure_server do |config|
|
|
67
|
+
config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
|
|
68
|
+
config.server_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Server) }
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# config/initializers/logsy.rb (optional — only if you want to propagate more than request_id)
|
|
74
|
+
Logsy.configure do |c|
|
|
75
|
+
c.job_propagated_keys = %i[request_id user_id tenant_id]
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The bundled middleware:
|
|
80
|
+
- **Client side**: when a job is enqueued, copies the configured tags from `Logsy[]` into the job payload
|
|
81
|
+
- **Server side**: when the job runs, sets those tags back in `Logsy[]` AND sets `Logsy[:job_id] = job['jid']` automatically
|
|
82
|
+
|
|
83
|
+
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).
|
|
84
|
+
|
|
85
|
+
For other job systems (Resque, GoodJob, custom), write your own middleware using the same pattern — Logsy's `[]=` / `[]` / `tags` / `reset` API is generic.
|
|
86
|
+
|
|
87
|
+
## What it looks like in your logs
|
|
88
|
+
|
|
89
|
+
A regular log line:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"ts":"2026-05-02T10:00:00.123Z",
|
|
94
|
+
"level":"INFO",
|
|
95
|
+
"msg":"Calling SPG gateway",
|
|
96
|
+
"file":"app/lib/gateways/spg_gateway.rb",
|
|
97
|
+
"line":437,
|
|
98
|
+
"request_id":"abc-123",
|
|
99
|
+
"user_id":"u-1",
|
|
100
|
+
"message_id":"m-42",
|
|
101
|
+
"order_id":"o-9"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The wide event at end of request:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"ts":"2026-05-02T10:00:00.250Z",
|
|
110
|
+
"level":"INFO",
|
|
111
|
+
"event":"request",
|
|
112
|
+
"method":"POST",
|
|
113
|
+
"path":"/v1/orders",
|
|
114
|
+
"controller":"orders",
|
|
115
|
+
"action":"create",
|
|
116
|
+
"status":201,
|
|
117
|
+
"duration_ms":127.34,
|
|
118
|
+
"request_id":"abc-123",
|
|
119
|
+
"user_id":"u-1",
|
|
120
|
+
"message_id":"m-42",
|
|
121
|
+
"order_id":"o-9",
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
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
|
+
## API reference
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
Logsy[:user_id] = 'u-1' # set a tag
|
|
131
|
+
Logsy[:user_id] # read it
|
|
132
|
+
Logsy.tags # all tags as a hash
|
|
133
|
+
Logsy.reset # clear all tags (the controller/middleware do this for you)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Symbol and string keys are equivalent — `Logsy['user_id']` and `Logsy[:user_id]` access the same slot.
|
|
137
|
+
|
|
138
|
+
## Customizing the wide event
|
|
139
|
+
|
|
140
|
+
Override `logsy_request_summary_extras` in your controller to add fields:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class ApplicationController < ActionController::API
|
|
144
|
+
include Logsy::ControllerHooks
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def logsy_request_summary_extras
|
|
149
|
+
{
|
|
150
|
+
ip: request.remote_ip,
|
|
151
|
+
user_agent: request.user_agent,
|
|
152
|
+
idempotency_key: request.headers['Idempotency-Key']
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Configuration reference
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Logsy.configure do |c|
|
|
162
|
+
# Tag keys to copy into job payloads at enqueue and read back at execution.
|
|
163
|
+
# Default: [:request_id]
|
|
164
|
+
c.job_propagated_keys = %i[request_id user_id tenant_id]
|
|
165
|
+
|
|
166
|
+
# Capture caller file:line via Kernel#caller_locations on every log line.
|
|
167
|
+
# Disable if you measure overhead. Default: true
|
|
168
|
+
c.include_caller_location = true
|
|
169
|
+
|
|
170
|
+
# Regex patterns for caller frames to skip. Defaults already cover Logger,
|
|
171
|
+
# ActiveSupport, lograge, sprockets, quiet_assets, and Logsy itself.
|
|
172
|
+
c.ignored_caller_paths += [%r{/my_internal_logger/}]
|
|
173
|
+
|
|
174
|
+
# The "event" name on the request summary. Default: "request"
|
|
175
|
+
c.request_summary_event_name = 'http_request'
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Why Logsy?
|
|
180
|
+
|
|
181
|
+
Existing options each cover one piece:
|
|
182
|
+
|
|
183
|
+
- **`tagged_logging`** (ActiveSupport) — push/pop tag stack, doesn't compose with mutable per-request context
|
|
184
|
+
- **`lograge`** — one wide event per request, but doesn't help your in-action `Rails.logger.info(...)` calls
|
|
185
|
+
- **`semantic_logger`** — heavy, opinionated, replaces the whole logger framework
|
|
186
|
+
|
|
187
|
+
Logsy fills the gap: a small, dictionary-style API (`Logsy[:foo] = bar`) plus a JSON formatter that reads from it on every log line. No subclasses to define, no DSL to learn — just set tags as you discover them.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
bundle install
|
|
193
|
+
bundle exec rspec # 34 specs
|
|
194
|
+
bundle exec rubocop # lint
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logsy
|
|
4
|
+
class Configuration
|
|
5
|
+
DEFAULT_IGNORED_CALLER_PATHS = [
|
|
6
|
+
%r{/logger\.rb\z},
|
|
7
|
+
%r{/active_support/(tagged_logging|logger|broadcast_logger|log_subscriber)},
|
|
8
|
+
%r{/lograge/},
|
|
9
|
+
%r{/sprockets/},
|
|
10
|
+
%r{/quiet_assets/},
|
|
11
|
+
%r{/lib/logsy/}
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
# Tag keys that should be carried across job boundaries (e.g. when a
|
|
15
|
+
# background job is enqueued from a web request, the job inherits
|
|
16
|
+
# these). The middleware that does the actual carrying is job-runner
|
|
17
|
+
# specific (Logsy ships one for Sidekiq).
|
|
18
|
+
attr_accessor :job_propagated_keys, :ignored_caller_paths,
|
|
19
|
+
:include_caller_location, :request_summary_event_name
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@job_propagated_keys = [:request_id]
|
|
23
|
+
@ignored_caller_paths = DEFAULT_IGNORED_CALLER_PATHS.dup
|
|
24
|
+
@include_caller_location = true
|
|
25
|
+
@request_summary_event_name = 'request'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Logsy
|
|
6
|
+
# Mix into your ApplicationController (or specific controllers) to:
|
|
7
|
+
#
|
|
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.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
#
|
|
16
|
+
# class ApplicationController < ActionController::API
|
|
17
|
+
# include Logsy::ControllerHooks
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Override `logsy_request_summary_extras` in your controller to add
|
|
21
|
+
# custom fields to the wide event:
|
|
22
|
+
#
|
|
23
|
+
# def logsy_request_summary_extras
|
|
24
|
+
# { ip: request.remote_ip, user_agent: request.user_agent }
|
|
25
|
+
# end
|
|
26
|
+
module ControllerHooks
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
before_action :_logsy_capture_request_id
|
|
31
|
+
around_action :_logsy_emit_request_summary
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def _logsy_capture_request_id
|
|
37
|
+
Logsy[:request_id] = request.request_id || request.headers['X-Request-Id']
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def _logsy_emit_request_summary
|
|
41
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
error = nil
|
|
43
|
+
yield
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
error = e
|
|
46
|
+
raise
|
|
47
|
+
ensure
|
|
48
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
|
|
49
|
+
payload = {
|
|
50
|
+
event: Logsy.configuration.request_summary_event_name,
|
|
51
|
+
method: request.request_method,
|
|
52
|
+
path: request.path,
|
|
53
|
+
controller: controller_name,
|
|
54
|
+
action: action_name,
|
|
55
|
+
status: response&.status,
|
|
56
|
+
duration_ms:,
|
|
57
|
+
error: error && "#{error.class}: #{error.message}"
|
|
58
|
+
}.merge(logsy_request_summary_extras).compact
|
|
59
|
+
|
|
60
|
+
Rails.logger.info(payload)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Override in subclasses to add fields to the request summary wide event.
|
|
64
|
+
def logsy_request_summary_extras
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'logger'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Logsy
|
|
8
|
+
# Logger formatter that emits one JSON object per log call.
|
|
9
|
+
#
|
|
10
|
+
# Each line includes:
|
|
11
|
+
# - ts: UTC ISO-8601 timestamp with millis
|
|
12
|
+
# - level: severity ('INFO', 'ERROR', ...)
|
|
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
|
|
16
|
+
#
|
|
17
|
+
# When the message is a Hash, its keys are merged directly into the
|
|
18
|
+
# top-level payload (useful for "wide event" emissions like a request
|
|
19
|
+
# summary). Symbols and strings are accepted as keys.
|
|
20
|
+
#
|
|
21
|
+
# Example output:
|
|
22
|
+
# {"ts":"2026-05-02T10:00:00.123Z","level":"INFO","msg":"hello",
|
|
23
|
+
# "file":"app/controllers/orders_controller.rb","line":34,
|
|
24
|
+
# "request_id":"abc-123","user_id":"u-1"}
|
|
25
|
+
class JsonFormatter < ::Logger::Formatter
|
|
26
|
+
def call(severity, time, _progname, message)
|
|
27
|
+
payload = {
|
|
28
|
+
ts: time.utc.iso8601(3),
|
|
29
|
+
level: severity
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
add_caller_location!(payload) if Logsy.configuration.include_caller_location
|
|
33
|
+
merge_context!(payload)
|
|
34
|
+
merge_message!(payload, message)
|
|
35
|
+
|
|
36
|
+
"#{JSON.dump(payload)}\n"
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
fallback_line(severity, time, e)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def add_caller_location!(payload)
|
|
44
|
+
location = caller_locations.find { |loc| user_frame?(loc.path) }
|
|
45
|
+
return unless location
|
|
46
|
+
|
|
47
|
+
payload[:file] = relative_path(location.path)
|
|
48
|
+
payload[:line] = location.lineno
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def user_frame?(path)
|
|
52
|
+
Logsy.configuration.ignored_caller_paths.none? { |pattern| path.match?(pattern) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def relative_path(path)
|
|
56
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? "#{Rails.root}/" : nil
|
|
57
|
+
return path unless root && path.start_with?(root)
|
|
58
|
+
|
|
59
|
+
path[root.length..]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def merge_context!(payload)
|
|
63
|
+
Logsy.tags.each do |key, value|
|
|
64
|
+
next if value.nil?
|
|
65
|
+
|
|
66
|
+
payload[key.to_sym] = value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def merge_message!(payload, message)
|
|
71
|
+
case message
|
|
72
|
+
when ::String
|
|
73
|
+
payload[:msg] = message
|
|
74
|
+
when ::Hash
|
|
75
|
+
message.each { |k, v| payload[k.to_sym] = v }
|
|
76
|
+
when ::Exception
|
|
77
|
+
payload[:msg] = "#{message.class}: #{message.message}"
|
|
78
|
+
else
|
|
79
|
+
payload[:msg] = message.inspect
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fallback_line(severity, time, error)
|
|
84
|
+
safe = {
|
|
85
|
+
ts: time.utc.iso8601(3),
|
|
86
|
+
level: severity,
|
|
87
|
+
msg: 'logsy_formatter_error',
|
|
88
|
+
error: "#{error.class}: #{error.message}"
|
|
89
|
+
}
|
|
90
|
+
"#{JSON.dump(safe)}\n"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logsy
|
|
4
|
+
module SidekiqMiddleware
|
|
5
|
+
# Client-side middleware: captures configured tags from the enqueueing
|
|
6
|
+
# process (a web request, a console session, another job) and writes
|
|
7
|
+
# them into the job payload.
|
|
8
|
+
class Client
|
|
9
|
+
def call(_worker_class, job, _queue, _redis_pool)
|
|
10
|
+
Logsy.configuration.job_propagated_keys.each do |key|
|
|
11
|
+
value = Logsy[key]
|
|
12
|
+
job[key.to_s] ||= value if value
|
|
13
|
+
end
|
|
14
|
+
yield
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logsy
|
|
4
|
+
module SidekiqMiddleware
|
|
5
|
+
# Server-side middleware: reads propagated tags back from the job
|
|
6
|
+
# payload, sets them on the per-job Logsy store, sets job_id from
|
|
7
|
+
# Sidekiq's `jid`, and resets the store after the job runs (even on
|
|
8
|
+
# error) so tags don't leak between jobs sharing a worker thread.
|
|
9
|
+
#
|
|
10
|
+
# Anything the job code writes via `Logsy[:foo] = bar` while running
|
|
11
|
+
# will appear on every log line emitted during the job.
|
|
12
|
+
class Server
|
|
13
|
+
def call(_worker_instance, job, _queue)
|
|
14
|
+
Logsy[:job_id] = job['jid']
|
|
15
|
+
Logsy.configuration.job_propagated_keys.each do |key|
|
|
16
|
+
value = job[key.to_s]
|
|
17
|
+
Logsy[key] = value if value
|
|
18
|
+
end
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
Logsy.reset
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logsy/sidekiq_middleware/client'
|
|
4
|
+
require 'logsy/sidekiq_middleware/server'
|
|
5
|
+
|
|
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:
|
|
10
|
+
#
|
|
11
|
+
# Sidekiq.configure_client do |config|
|
|
12
|
+
# config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
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
|
|
19
|
+
module SidekiqMiddleware
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/logsy/store.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/current_attributes'
|
|
5
|
+
|
|
6
|
+
module Logsy
|
|
7
|
+
# Internal per-request / per-job key-value store. Backed by
|
|
8
|
+
# ActiveSupport::CurrentAttributes so it is fiber-local and gets reset
|
|
9
|
+
# automatically by the Rails executor between requests.
|
|
10
|
+
#
|
|
11
|
+
# Consumers should not access this class directly — use `Logsy[:key]`,
|
|
12
|
+
# `Logsy[:key] = value`, `Logsy.tags`, and `Logsy.reset` instead.
|
|
13
|
+
class Store < ActiveSupport::CurrentAttributes
|
|
14
|
+
attribute :tags
|
|
15
|
+
|
|
16
|
+
def tags
|
|
17
|
+
super || (self.tags = {})
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/logsy.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logsy/version'
|
|
4
|
+
require 'logsy/store'
|
|
5
|
+
require 'logsy/configuration'
|
|
6
|
+
require 'logsy/json_formatter'
|
|
7
|
+
require 'logsy/controller_hooks'
|
|
8
|
+
|
|
9
|
+
module Logsy
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Read a tag from the per-request store.
|
|
14
|
+
#
|
|
15
|
+
# Logsy[:user_id] # => 'u-1' or nil
|
|
16
|
+
def [](key)
|
|
17
|
+
Store.tags[key.to_sym]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Write a tag into the per-request store. From this point on, every
|
|
21
|
+
# log line emitted during the current request (or job) carries it.
|
|
22
|
+
# Non-nil values are coerced to String so log fields have a consistent
|
|
23
|
+
# type when ingested by structured log stores (which often infer field
|
|
24
|
+
# type from the first value they see).
|
|
25
|
+
#
|
|
26
|
+
# Logsy[:user_id] = user.id # 42 -> "42"
|
|
27
|
+
# Logsy[:order_id] = nil # nil stays nil
|
|
28
|
+
def []=(key, value)
|
|
29
|
+
Store.tags[key.to_sym] = value&.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# All tags currently set, as a hash. Used by the formatter on every
|
|
33
|
+
# log line; consumers can call it for debugging.
|
|
34
|
+
def tags
|
|
35
|
+
Store.tags
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear all tags. The Rails executor calls this between requests, and
|
|
39
|
+
# the Sidekiq middleware calls it between jobs. You usually don't need
|
|
40
|
+
# to call it yourself.
|
|
41
|
+
def reset
|
|
42
|
+
Store.reset
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configure the gem. Yields the {Configuration} instance.
|
|
46
|
+
#
|
|
47
|
+
# Logsy.configure do |c|
|
|
48
|
+
# c.job_propagated_keys = %i[request_id user_id]
|
|
49
|
+
# end
|
|
50
|
+
def configure
|
|
51
|
+
yield(configuration)
|
|
52
|
+
configuration
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configuration
|
|
56
|
+
@configuration ||= Configuration.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Test helper.
|
|
60
|
+
def reset_configuration!
|
|
61
|
+
@configuration = nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: logsy
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ruby_is_love
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionpack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.13'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.13'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.60'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.60'
|
|
82
|
+
description: |
|
|
83
|
+
Logsy gives Rails apps one wide JSON event per request plus correlated
|
|
84
|
+
breadcrumb logs, all tagged with whatever per-request context you set
|
|
85
|
+
via Logsy[:key] = value. Tags propagate across background jobs.
|
|
86
|
+
Generic JSON output to stdout — bring your own log shipper.
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- CHANGELOG.md
|
|
92
|
+
- LICENSE.txt
|
|
93
|
+
- README.md
|
|
94
|
+
- lib/logsy.rb
|
|
95
|
+
- lib/logsy/configuration.rb
|
|
96
|
+
- lib/logsy/controller_hooks.rb
|
|
97
|
+
- lib/logsy/json_formatter.rb
|
|
98
|
+
- lib/logsy/sidekiq_middleware.rb
|
|
99
|
+
- lib/logsy/sidekiq_middleware/client.rb
|
|
100
|
+
- lib/logsy/sidekiq_middleware/server.rb
|
|
101
|
+
- lib/logsy/store.rb
|
|
102
|
+
- lib/logsy/version.rb
|
|
103
|
+
homepage: https://rubygems.org/profiles/ruby_is_love
|
|
104
|
+
licenses:
|
|
105
|
+
- MIT
|
|
106
|
+
metadata:
|
|
107
|
+
rubygems_mfa_required: 'true'
|
|
108
|
+
homepage_uri: https://rubygems.org/profiles/ruby_is_love
|
|
109
|
+
rdoc_options: []
|
|
110
|
+
require_paths:
|
|
111
|
+
- lib
|
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: 3.2.0
|
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0'
|
|
122
|
+
requirements: []
|
|
123
|
+
rubygems_version: 3.6.9
|
|
124
|
+
specification_version: 4
|
|
125
|
+
summary: Tagged structured logging for Rails apps with cross-process context propagation
|
|
126
|
+
test_files: []
|