rails_semantic_logging 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 489018fd333371c700b41e42079eedb52ecae7560a0d0fbecd2f354b62cd4a85
4
+ data.tar.gz: 9d3b301a0275c7b0fa18348192413c8d7359fa4dbccac5ffbb6225d04e753187
5
+ SHA512:
6
+ metadata.gz: bf98ed4b1007f7a3085a0ea1d75bcc6c5a8158bd94ee2e246ec39ebb0e9934d89ee3b4287fc70f74aeff969e11e8c135d5c588940e72e34e37fc2539d5a4d8f9
7
+ data.tar.gz: bad27c3d7210034773745b1f8889ecba556af9db932e7c762cd69a86cc322449b90f2baf1a62285bcee583ca8c1d3c640a1369d2d5c906983a44a62cd6662dfd
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Fabio Napoleoni
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # RailsSemanticLogging
2
+
3
+ Opinionated Rails semantic logger configuration with Datadog support. Provides a consistent, structured JSON logging setup for Rails applications, with hooks for Sidekiq, ActiveJob, and Datadog-friendly formatters.
4
+
5
+ ## Features
6
+
7
+ - **Datadog formatter** with [Standard Attributes](https://docs.datadoghq.com/standard-attributes/) mapping
8
+ - **Default payload enrichment** for controllers (host, user_agent, referer) mapped to `http.*`
9
+ - **JSON formatter** for structured logging without Datadog-specific fields
10
+ - **ActiveJob integration** with named tags (`job_class`, `job_id`, `queue`) instead of array tags
11
+ - **Sidekiq integration** with job context in all log lines
12
+ - **Configurable** via [anyway_config](https://github.com/palkan/anyway_config) (YAML, env vars, or code)
13
+ - **Environment-aware** defaults (Datadog JSON in production, color in development, fatal in test)
14
+ - **RSpec matcher** for asserting log output in tests
15
+
16
+ ## Installation
17
+
18
+ Add to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'rails_semantic_logging'
22
+ ```
23
+
24
+ Then run `bundle install`.
25
+
26
+ ## Usage
27
+
28
+ ### Basic Configuration
29
+
30
+ The gem auto-configures via a Railtie when loaded by Rails. Out of the box:
31
+
32
+ - Datadog JSON formatter in production, color formatter elsewhere
33
+ - `request_id` and `client_ip` log tags
34
+ - Default payload enrichment (host, user_agent, referer) on all controller actions
35
+ - Quiet assets logging
36
+ - Sync mode in test environment
37
+ - Log level: INFO (production), DEBUG (development), FATAL (test)
38
+ - `LOG_LEVEL` env var override supported
39
+
40
+ ### Custom Configuration
41
+
42
+ ```ruby
43
+ # config/application.rb (inside class body)
44
+ RailsSemanticLogging.configure do |config|
45
+ config.application_name = 'My App'
46
+ config.environment_name = ENV.fetch('NAMESPACE', Rails.env)
47
+
48
+ # Add custom log tags (merged with default request_id + client_ip)
49
+ config.custom_log_tags = {
50
+ user: ->(request) { extract_user_id(request) },
51
+ tenant: ->(request) { request.headers['X-Tenant-ID'] }
52
+ }
53
+
54
+ # Override formatters (default: :datadog for production, :color for development)
55
+ # config.production_formatter = :json # plain JSON without Datadog mapping
56
+ # config.production_formatter = :datadog # Datadog Standard Attributes (default)
57
+ # config.development_formatter = :color # colorized console output (default)
58
+
59
+ # Disable automatic payload enrichment on controllers (default: true)
60
+ # config.default_payload = false
61
+ end
62
+ ```
63
+
64
+ Configuration can also be set via YAML (`config/rails_semantic_logging.yml`) or environment variables (`RAILS_SEMANTIC_LOGGING_QUIET_ASSETS=false`) thanks to [anyway_config](https://github.com/palkan/anyway_config).
65
+
66
+ #### Formatter override via YAML
67
+
68
+ ```yaml
69
+ # config/rails_semantic_logging.yml
70
+ production:
71
+ production_formatter: json
72
+
73
+ development:
74
+ development_formatter: color
75
+ ```
76
+
77
+ #### Formatter override via environment variables
78
+
79
+ ```bash
80
+ RAILS_SEMANTIC_LOGGING_PRODUCTION_FORMATTER=datadog bin/rails server
81
+ RAILS_SEMANTIC_LOGGING_DEVELOPMENT_FORMATTER=color bin/rails server
82
+ ```
83
+
84
+ Both string and symbol values are accepted for formatter options (e.g. `"datadog"` from YAML/ENV is equivalent to `:datadog` in Ruby).
85
+
86
+ ### Configuration Options
87
+
88
+ | Option | Default | Description |
89
+ |--------|---------|-------------|
90
+ | `application_name` | Rails app name | SemanticLogger application name |
91
+ | `environment_name` | `Rails.env` | SemanticLogger environment name |
92
+ | `custom_log_tags` | `{}` | Extra log tags (merged with `request_id` + `client_ip`) |
93
+ | `quiet_assets` | `true` | Silence asset pipeline logs |
94
+ | `sync_in_test` | `true` | Use synchronous logging in test environment |
95
+ | `default_payload` | `true` | Auto-include host, user_agent, referer in controller logs |
96
+ | `production_formatter` | `:datadog` | Formatter for production (`:datadog`, `:json`, or instance) |
97
+ | `development_formatter` | `:color` | Formatter for non-production environments |
98
+
99
+ ## Datadog Integration
100
+
101
+ ### Standard Attributes Mapping
102
+
103
+ The `:datadog` formatter maps all log fields to [Datadog Standard Attributes](https://docs.datadoghq.com/standard-attributes/) so logs are automatically parsed and correlated in Datadog without custom pipelines.
104
+
105
+ #### Core log fields
106
+
107
+ | SemanticLogger field | Datadog Standard Attribute | Notes |
108
+ |---------------------|---------------------------|-------|
109
+ | Logger name | `logger.name` | Class/module that emitted the log |
110
+ | Level | `status` | `debug`, `info`, `warn`, `error`, `fatal` |
111
+ | Duration | `duration` | Converted to **nanoseconds** |
112
+ | Exception class | `error.kind` | e.g. `RuntimeError` |
113
+ | Exception message | `error.message` | Human-readable description |
114
+ | Backtrace | `error.stack` | Full stack trace as string |
115
+
116
+ #### HTTP fields (from controller payload)
117
+
118
+ When `default_payload` is enabled (default), controller request logs include:
119
+
120
+ | Payload field | Datadog Standard Attribute | Source |
121
+ |--------------|---------------------------|--------|
122
+ | `status` | `http.status_code` | Rails built-in |
123
+ | `method` | `http.method` | Rails built-in |
124
+ | `path` | `http.url` | Rails built-in |
125
+ | `host` | `http.url_details.host` | `DefaultPayload` concern |
126
+ | `user_agent` | `http.useragent` | `DefaultPayload` concern |
127
+ | `referer` | `http.referer` | `DefaultPayload` concern |
128
+
129
+ #### Datadog trace correlation
130
+
131
+ When Datadog tracing is active, the formatter injects: `dd.trace_id`, `dd.span_id`, `dd.env`, `dd.service`, `dd.version`.
132
+
133
+ ### Example: Complete request log (production)
134
+
135
+ ```json
136
+ {
137
+ "timestamp": "2025-10-26T10:30:45.123Z",
138
+ "status": "info",
139
+ "host": "web-01",
140
+ "logger.name": "Rails",
141
+ "message": "Completed 200 OK in 42ms",
142
+ "duration": 42500000,
143
+ "http.status_code": 200,
144
+ "http.method": "GET",
145
+ "http.url": "/api/v1/bikes",
146
+ "http.url_details.host": "api.example.com",
147
+ "http.useragent": "Mozilla/5.0 (iPhone; iOS 17.0)",
148
+ "http.referer": "https://app.example.com/dashboard",
149
+ "dd.trace_id": "1234567890",
150
+ "dd.span_id": "9876543210"
151
+ }
152
+ ```
153
+
154
+ ### Example: Background job log
155
+
156
+ ```json
157
+ {
158
+ "timestamp": "2025-10-26T10:31:00.456Z",
159
+ "status": "info",
160
+ "host": "worker-01",
161
+ "logger.name": "Rails",
162
+ "message": "Performing ImportBikesJob from Sidekiq(default)",
163
+ "named_tags": {
164
+ "job_class": "ImportBikesJob",
165
+ "job_id": "abc-123",
166
+ "queue": "default"
167
+ }
168
+ }
169
+ ```
170
+
171
+ ### Example: Error log
172
+
173
+ ```json
174
+ {
175
+ "timestamp": "2025-10-26T10:32:15.789Z",
176
+ "status": "error",
177
+ "host": "web-01",
178
+ "logger.name": "BikeService",
179
+ "message": "Failed to import bike",
180
+ "error.kind": "ActiveRecord::RecordInvalid",
181
+ "error.message": "Validation failed: VIN is not unique",
182
+ "error.stack": "app/services/bike_service.rb:42:in `import'\n..."
183
+ }
184
+ ```
185
+
186
+ ## Formatters
187
+
188
+ ### JSON Formatter `:json`
189
+
190
+ Plain structured JSON without Datadog-specific field mapping. Useful for non-Datadog log pipelines.
191
+
192
+ ```json
193
+ {
194
+ "timestamp": "2025-10-26T10:30:45.123Z",
195
+ "level": "info",
196
+ "host": "server-1",
197
+ "name": "Rails",
198
+ "message": "Processing request",
199
+ "duration_ms": 42.5
200
+ }
201
+ ```
202
+
203
+ ### Color Formatter `:color` (development default)
204
+
205
+ Uses the built-in `SemanticLogger::Formatters::Color` for human-readable development output.
206
+
207
+ ## RSpec Matcher
208
+
209
+ Include the matcher module in your spec config:
210
+
211
+ ```ruby
212
+ require 'rails_semantic_logging/rspec/matchers'
213
+
214
+ RSpec.configure do |config|
215
+ config.include RailsSemanticLogging::RSpec::Matchers
216
+ end
217
+ ```
218
+
219
+ Usage:
220
+
221
+ ```ruby
222
+ expect { logger.info("hello") }.to log_semantic(level: :info, message: /hello/)
223
+ expect { logger.warn("oops", key: "val") }.to log_semantic(payload: { key: "val" })
224
+ expect { do_work }.to log_semantic(named_tags: { job_class: 'MyJob' })
225
+ ```
226
+
227
+ ## Puma Integration
228
+
229
+ When using Puma in clustered mode, reopen log appenders after forking:
230
+
231
+ ```ruby
232
+ # config/puma.rb
233
+ if workers_number.positive?
234
+ preload_app!
235
+
236
+ before_worker_boot do
237
+ SemanticLogger.reopen
238
+ end
239
+ end
240
+ ```
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ git clone https://github.com/fabn/rails_semantic_logging.git
246
+ cd rails_semantic_logging
247
+ bundle install
248
+ bundle exec rspec
249
+ bundle exec rubocop
250
+ ```
251
+
252
+ ## License
253
+
254
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,28 @@
1
+ module RailsSemanticLogging
2
+ module ActionController
3
+ # Concern that enriches controller log payload with standard HTTP request fields.
4
+ # Include in ApplicationController (or a base controller) to automatically add
5
+ # host, user_agent, and referer to every request log line.
6
+ #
7
+ # These fields are then mapped to Datadog Standard Attributes by the Datadog formatter:
8
+ # host -> http.url_details.host
9
+ # user_agent -> http.useragent
10
+ # referer -> http.referer
11
+ # status -> http.status_code (already in Rails payload)
12
+ # method -> http.method (already in Rails payload)
13
+ # path -> http.url (already in Rails payload)
14
+ module DefaultPayload
15
+ extend ActiveSupport::Concern
16
+
17
+ # @see https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/instrumentation.rb
18
+ def append_info_to_payload(payload)
19
+ super
20
+ # Use :full_path because rails_semantic_logger strips query string from :path
21
+ payload[:full_path] = request.fullpath
22
+ payload[:host] = request.host
23
+ payload[:user_agent] = request.user_agent
24
+ payload[:referer] = request.referer if request.referer.present?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ require 'anyway_config'
2
+
3
+ module RailsSemanticLogging
4
+ class Configuration < Anyway::Config
5
+ config_name :rails_semantic_logging
6
+
7
+ attr_config(
8
+ application_name: nil,
9
+ environment_name: nil,
10
+ custom_log_tags: {},
11
+ quiet_assets: true,
12
+ sync_in_test: true,
13
+ stdout_sync: true,
14
+ default_payload: true,
15
+ production_formatter: nil,
16
+ development_formatter: :color
17
+ )
18
+
19
+ DEFAULT_LOG_TAGS = { request_id: :request_id, client_ip: :remote_ip }.freeze
20
+
21
+ VALID_LOG_LEVELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
22
+
23
+ # Merges built-in default tags with app-provided custom tags
24
+ def effective_log_tags
25
+ DEFAULT_LOG_TAGS.merge(custom_log_tags)
26
+ end
27
+
28
+ # Returns the appropriate formatter for the given environment
29
+ def formatter_for(env)
30
+ case env.to_s
31
+ when 'production'
32
+ resolve_formatter(production_formatter) || RailsSemanticLogging::Formatters::Datadog.new
33
+ else
34
+ resolve_formatter(development_formatter) || :color
35
+ end
36
+ end
37
+
38
+ # Returns the appropriate log level for the given environment, respecting LOG_LEVEL env var
39
+ def log_level_for(env)
40
+ default_level = case env.to_s
41
+ when 'development' then 'DEBUG'
42
+ when 'test' then 'FATAL'
43
+ else 'INFO'
44
+ end
45
+
46
+ requested = ENV.fetch('LOG_LEVEL', default_level).to_s.upcase
47
+ VALID_LOG_LEVELS.include?(requested) ? requested : default_level
48
+ end
49
+
50
+ private
51
+
52
+ def resolve_formatter(value)
53
+ value = value.to_sym if value.is_a?(String)
54
+ return value unless value.is_a?(Symbol)
55
+ return RailsSemanticLogging::Formatters::Datadog.new if value == :datadog
56
+
57
+ value
58
+ end
59
+ end
60
+
61
+ class << self
62
+ def config
63
+ @config ||= Configuration.new
64
+ end
65
+
66
+ def configure
67
+ yield(config)
68
+ end
69
+
70
+ def reset_config!
71
+ @config = Configuration.new
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,36 @@
1
+ # Monkey patch for Datadog's ActiveJob LogInjection to use hash-style
2
+ # correlation tags instead of string-style. This is necessary because
3
+ # we patch ActiveJob::Logging to use named tags (job_class, job_id, queue),
4
+ # so Datadog must also use hash-style tags for compatibility with SemanticLogger.
5
+
6
+ module RailsSemanticLogging
7
+ module Datadog
8
+ module LogInjection
9
+ def self.apply!
10
+ return unless defined?(::Datadog::Tracing::Contrib::ActiveJob::LogInjection)
11
+
12
+ # Ensure the SemanticLogger ActiveJob extension is loaded first
13
+ require 'rails_semantic_logger/extensions/active_job/logging'
14
+
15
+ # Replace the original module with our patched version
16
+ ::Datadog::Tracing::Contrib::ActiveJob.send(:remove_const, :LogInjection)
17
+ ::Datadog::Tracing::Contrib::ActiveJob.const_set(:LogInjection, PatchedLogInjection)
18
+ end
19
+
20
+ # Replacement module that uses correlation.to_h instead of log_correlation
21
+ module PatchedLogInjection
22
+ def self.included(base)
23
+ base.class_eval do
24
+ around_perform do |_, block|
25
+ if ::Datadog.configuration.tracing.log_injection && logger.respond_to?(:tagged)
26
+ logger.tagged(::Datadog::Tracing.correlation.to_h, &block)
27
+ else
28
+ block.call
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,189 @@
1
+ require 'semantic_logger'
2
+
3
+ module RailsSemanticLogging
4
+ module Formatters
5
+ # Datadog-optimized JSON formatter that maps log fields to
6
+ # Datadog Standard Attributes (https://docs.datadoghq.com/standard-attributes/).
7
+ #
8
+ # Key mappings:
9
+ # name -> logger.name
10
+ # level -> status
11
+ # duration -> duration (nanoseconds) + duration_human (Rails format)
12
+ # exception -> error: { kind, message, stack }
13
+ # payload -> http: { status_code, method, url, ... } (controller requests)
14
+ # named_tags.dd -> dd (top-level, for trace linking)
15
+ # named_tags.user_* -> usr.{id, email, name, role}
16
+ class Datadog < ::SemanticLogger::Formatters::Raw
17
+ NANOSECONDS_PER_MILLISECOND = 1_000_000
18
+
19
+ # Mapping of Rails payload keys to Datadog http standard attribute names
20
+ HTTP_PAYLOAD_MAP = {
21
+ status: :status_code,
22
+ method: :method,
23
+ host: :host,
24
+ user_agent: :useragent,
25
+ referer: :referer
26
+ }.freeze
27
+
28
+ # Mapping of user-related named_tags to usr.* standard attributes
29
+ USER_NAMED_TAGS_MAP = {
30
+ user_id: :id,
31
+ user_email: :email,
32
+ user_name: :name,
33
+ user_role: :role
34
+ }.freeze
35
+
36
+ def initialize(time_format: :iso_8601, time_key: :timestamp, **args) # rubocop:disable Naming/VariableNumber
37
+ super(time_format:, time_key:, log_application: false, log_host: true, log_environment: false, **args)
38
+ end
39
+
40
+ def call(log, logger)
41
+ super
42
+ remap_named_tags
43
+ remap_http_payload
44
+ parse_url_details
45
+ apache_message
46
+ deep_compact_blank!(hash)
47
+ hash.to_json
48
+ end
49
+
50
+ def thread_name
51
+ # Exclude thread_name from output
52
+ end
53
+
54
+ def name
55
+ hash[:'logger.name'] = log.name if log.name
56
+ end
57
+
58
+ def level
59
+ hash[:status] = log.level
60
+ end
61
+
62
+ def duration
63
+ # Propagate duration from payload if not set on log
64
+ log.duration = log.payload[:duration] if log.duration.nil? && log.payload&.dig(:duration)
65
+ return unless log.duration
66
+
67
+ # Datadog standard: duration in nanoseconds
68
+ hash[:duration] = (log.duration * NANOSECONDS_PER_MILLISECOND).to_i
69
+ # Human-readable duration for readability (Rails format)
70
+ hash[:duration_human] = "#{log.duration.round(2)}ms"
71
+ end
72
+
73
+ def exception
74
+ return unless log.exception
75
+
76
+ hash[:error] = {
77
+ kind: log.exception.class.name,
78
+ message: log.exception.message,
79
+ stack: log.exception.backtrace&.join("\n")
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ # Parses http.url into url_details with host, path and queryString
86
+ def parse_url_details
87
+ return unless hash.dig(:http, :url)
88
+
89
+ url = hash[:http][:url]
90
+ path, query = url.split('?', 2)
91
+ details = { path: path }
92
+ details[:queryString] = Rack::Utils.parse_query(query) if query.present?
93
+ # Datadog standard: host belongs under http.url_details
94
+ details[:host] = hash[:http].delete(:host) if hash[:http][:host]
95
+
96
+ hash[:http][:url_details] = details
97
+ end
98
+
99
+ # For completed ActionController requests, replace the message with an
100
+ # Apache-like format: "GET /path JSON 200 1.17ms" for better readability on mobile
101
+ def apache_message
102
+ return unless hash[:http].is_a?(Hash) && hash[:http][:status_code] && log.duration
103
+
104
+ method = hash[:http][:method] || '-'
105
+ url = hash[:http][:url] || '-'
106
+ status = hash[:http][:status_code]
107
+ format = hash[:payload].is_a?(Hash) ? hash[:payload][:format] : nil
108
+
109
+ parts = [method, url]
110
+ parts << format if format.present?
111
+ parts << status
112
+ parts << "#{log.duration.round(2)}ms"
113
+ hash[:message] = parts.join(' ')
114
+ end
115
+
116
+ # Remaps known named_tags to Datadog standard attributes.
117
+ # Handles: client_ip, request_id, dd correlation, user_* tags.
118
+ def remap_named_tags
119
+ return unless hash[:named_tags].is_a?(Hash)
120
+
121
+ remap_network_and_request
122
+ remap_dd_correlation
123
+ remap_user_tags
124
+ end
125
+
126
+ def remap_network_and_request
127
+ if (ip = hash[:named_tags].delete(:client_ip))
128
+ hash[:network] = { client: { ip: ip } }
129
+ end
130
+
131
+ return unless (rid = hash[:named_tags].delete(:request_id))
132
+
133
+ hash[:http] ||= {}
134
+ hash[:http][:request_id] = rid
135
+ end
136
+
137
+ # Lifts Datadog correlation from named_tags (added by dd-trace-rb SemanticLogger
138
+ # instrumentation via Tracing.correlation.to_h) to top-level for trace linking.
139
+ # Skips the dd block entirely when there's no active trace (trace_id is "0").
140
+ def remap_dd_correlation
141
+ dd = hash[:named_tags].delete(:dd)
142
+ ddsource = hash[:named_tags].delete(:ddsource)
143
+
144
+ return unless dd.is_a?(Hash) && dd[:trace_id].to_s != '0'
145
+
146
+ hash[:dd] = dd
147
+ hash[:ddsource] = ddsource if ddsource
148
+ end
149
+
150
+ # Maps user_* named_tags to usr.* Datadog standard attributes
151
+ def remap_user_tags
152
+ usr = USER_NAMED_TAGS_MAP.each_with_object({}) do |(source, target), h|
153
+ value = hash[:named_tags].delete(source)
154
+ h[target] = value if value.present?
155
+ end
156
+
157
+ hash[:usr] = usr if usr.present?
158
+ end
159
+
160
+ # Remaps known HTTP payload fields to nested Datadog http standard attributes
161
+ def remap_http_payload
162
+ return unless hash[:payload].is_a?(Hash)
163
+
164
+ http = HTTP_PAYLOAD_MAP.each_with_object({}) do |(source, target), h|
165
+ h[target] = hash[:payload].delete(source) if hash[:payload].key?(source)
166
+ end
167
+
168
+ # Prefer full_path (with query string) over path (stripped by rails_semantic_logger).
169
+ # Delete both keys unconditionally to avoid duplication in payload.
170
+ full_path = hash[:payload].delete(:full_path)
171
+ path = hash[:payload].delete(:path)
172
+ url = full_path || path
173
+ http[:url] = url if url.present?
174
+ return if http.blank?
175
+
176
+ hash[:http].is_a?(Hash) ? hash[:http].merge!(http) : hash[:http] = http
177
+ end
178
+
179
+ # Recursively removes blank values from a hash
180
+ def deep_compact_blank!(h)
181
+ h.each do |key, value|
182
+ deep_compact_blank!(value) if value.is_a?(Hash)
183
+ h.delete(key) if value.blank?
184
+ end
185
+ h
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,20 @@
1
+ require 'active_support/concern'
2
+
3
+ module RailsSemanticLogging
4
+ module JobLogging
5
+ # ActiveJob patch to provide named tags instead of array tags.
6
+ # Converts the default tag_logger(class, id) call into named tags
7
+ # (job_class, job_id, queue) for structured logging.
8
+ module ActiveJobPatch
9
+ extend ActiveSupport::Concern
10
+
11
+ def tag_logger(job_class = nil, job_id = nil, &)
12
+ if job_class && job_id
13
+ super(job_class: job_class, job_id: job_id, queue: queue_name, &)
14
+ else
15
+ super(&)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ module RailsSemanticLogging
2
+ module JobLogging
3
+ # Sidekiq patch to provide job context in every processed job log line.
4
+ # SemanticLogger uses its own thread for formatting, so Sidekiq::Context
5
+ # (stored in Thread.current) is lost. This patch wraps each job with
6
+ # SemanticLogger named tags for consistent structured output.
7
+ module SidekiqPatch
8
+ # @param [Hash] item Sidekiq job hash
9
+ # @param [String] queue Queue name
10
+ def call(item, queue)
11
+ Sidekiq.logger.tagged(job_class: item['class'], job_id: item['jid'], queue: queue) do
12
+ super(item, queue)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,76 @@
1
+ require 'rails/railtie'
2
+ require 'rails_semantic_logger'
3
+
4
+ module RailsSemanticLogging
5
+ class Railtie < Rails::Railtie
6
+ config.before_configuration do
7
+ $stdout.sync = true if RailsSemanticLogging.config.stdout_sync
8
+ end
9
+
10
+ # Runs BEFORE the upstream rails_semantic_logger Engine's :initialize_logger initializer.
11
+ # This ensures our configuration is applied before the logger is set up.
12
+ initializer 'rails_semantic_logging.configure', before: :initialize_logger do |app|
13
+ cfg = RailsSemanticLogging.config
14
+
15
+ # Configure rails_semantic_logger options
16
+ app.config.rails_semantic_logger.quiet_assets = cfg.quiet_assets
17
+ app.config.rails_semantic_logger.console_logger = false
18
+ app.config.rails_semantic_logger.add_file_appender = false
19
+
20
+ # Set formatter based on environment
21
+ app.config.rails_semantic_logger.format = cfg.formatter_for(Rails.env)
22
+
23
+ # Merge default tags (request_id, client_ip) with app-specific custom tags
24
+ app.config.log_tags = cfg.effective_log_tags
25
+
26
+ # Set log level based on environment, respecting LOG_LEVEL env var
27
+ app.config.log_level = cfg.log_level_for(Rails.env)
28
+
29
+ # Add stdout appender with the configured formatter.
30
+ # IMPORTANT: Do NOT pass level: parameter. Subscriber#level defaults to :trace
31
+ # when unset, which is required by the host app's spec/support/output.rb check.
32
+ app.config.semantic_logger.add_appender(io: $stdout, formatter: app.config.rails_semantic_logger.format)
33
+ end
34
+
35
+ config.to_prepare do
36
+ cfg = RailsSemanticLogging.config
37
+ SemanticLogger.application = cfg.application_name || Rails.application.class.module_parent_name
38
+ SemanticLogger.environment = cfg.environment_name || Rails.env
39
+ SemanticLogger.sync! if Rails.env.test? && cfg.sync_in_test
40
+ end
41
+
42
+ config.after_initialize do
43
+ # Include DefaultPayload in ActionController to enrich request logs
44
+ # with host, user_agent, and referer (mapped to http.* by Datadog formatter)
45
+ if RailsSemanticLogging.config.default_payload
46
+ require 'rails_semantic_logging/action_controller/default_payload'
47
+
48
+ ActiveSupport.on_load(:action_controller_base) do
49
+ include RailsSemanticLogging::ActionController::DefaultPayload
50
+ end
51
+ ActiveSupport.on_load(:action_controller_api) do
52
+ include RailsSemanticLogging::ActionController::DefaultPayload
53
+ end
54
+ end
55
+
56
+ # Apply ActiveJob logging patch for named tags
57
+ ActiveSupport.on_load(:active_job) do
58
+ require 'rails_semantic_logging/job_logging/active_job_patch'
59
+ prepend RailsSemanticLogging::JobLogging::ActiveJobPatch
60
+ end
61
+
62
+ # Apply Sidekiq logging patch if Sidekiq is loaded
63
+ if defined?(::Sidekiq::JobLogger)
64
+ require 'sidekiq/job_logger'
65
+ require 'rails_semantic_logging/job_logging/sidekiq_patch'
66
+ ::Sidekiq::JobLogger.prepend(RailsSemanticLogging::JobLogging::SidekiqPatch)
67
+ end
68
+
69
+ # Apply Datadog log injection patch if Datadog tracing is loaded
70
+ if defined?(::Datadog::Tracing::Contrib::ActiveJob::LogInjection)
71
+ require 'rails_semantic_logging/datadog/log_injection'
72
+ RailsSemanticLogging::Datadog::LogInjection.apply!
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,84 @@
1
+ module RailsSemanticLogging
2
+ module RSpec
3
+ # Test helpers for applications using RailsSemanticLogging.
4
+ #
5
+ # Provides standalone helpers that work with or without TestProf:
6
+ # - LoggingHelpers: with_logging / with_ar_logging (uses SemanticLogger.silence)
7
+ # - SilenceOutput: capture stdout in tests
8
+ # - Appender validation (ensures single appender at trace level)
9
+ # - LOG env var support (LOG=all or LOG=ar to enable logging in tests)
10
+ #
11
+ # Usage:
12
+ # require 'rails_semantic_logging/rspec/helpers'
13
+ # RailsSemanticLogging::RSpec::Helpers.install!
14
+ #
15
+ module Helpers
16
+ # Standalone logging helpers using SemanticLogger.silence.
17
+ # Works without TestProf. If TestProf is present, also patches
18
+ # TestProf::Rails::LoggingHelpers for compatibility.
19
+ module LoggingHelpers
20
+ def with_logging(level = :trace, &)
21
+ SemanticLogger.silence(level, &)
22
+ end
23
+
24
+ def with_ar_logging(level = :trace, &)
25
+ SemanticLogger.appenders.first.filter = ->(log) { log.name == 'ActiveRecord' }
26
+ SemanticLogger.silence(level, &)
27
+ ensure
28
+ SemanticLogger.appenders.first.filter = nil
29
+ end
30
+ end
31
+
32
+ # Helper to silence stdout output in tests
33
+ module SilenceOutput
34
+ def silence_stdout
35
+ original_stdout = $stdout
36
+ $stdout = StringIO.new
37
+ yield
38
+ ensure
39
+ $stdout = original_stdout
40
+ end
41
+ end
42
+
43
+ class << self
44
+ # Installs all test helpers into RSpec configuration.
45
+ def install!
46
+ configure_rspec!
47
+ patch_test_prof!
48
+ end
49
+
50
+ private
51
+
52
+ def configure_rspec! # rubocop:disable Metrics/MethodLength
53
+ ::RSpec.configure do |config|
54
+ # Make logging helpers available in all specs
55
+ config.include LoggingHelpers
56
+
57
+ # Validate appender configuration
58
+ config.before(:suite) do
59
+ if SemanticLogger.appenders.size != 1 || SemanticLogger.appenders.first.level != :trace
60
+ raise 'Expected only one appender with trace level, ' \
61
+ "got #{SemanticLogger.appenders.size} with #{SemanticLogger.appenders.map(&:level)}"
62
+ end
63
+ end
64
+
65
+ # Enable logging via LOG env var (LOG=all for all logs, LOG=ar for ActiveRecord only)
66
+ config.around do |ex|
67
+ next ex.call if ENV['LOG'].blank?
68
+
69
+ level = ENV.fetch('LOG_LEVEL', 'trace').to_sym
70
+ ENV['LOG'].casecmp('ar').zero? ? with_ar_logging(level, &ex) : with_logging(level, &ex)
71
+ end
72
+ end
73
+ end
74
+
75
+ # If TestProf is loaded, also patch its LoggingHelpers for compatibility
76
+ def patch_test_prof!
77
+ return unless defined?(TestProf::Rails::LoggingHelpers)
78
+
79
+ TestProf::Rails::LoggingHelpers.prepend(LoggingHelpers)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,120 @@
1
+ require 'semantic_logger'
2
+
3
+ module RailsSemanticLogging
4
+ module RSpec
5
+ # In-memory appender that collects log entries for assertion
6
+ class InMemoryAppender < SemanticLogger::Subscriber
7
+ attr_reader :logs
8
+
9
+ def initialize
10
+ super(level: :trace)
11
+ @logs = []
12
+ end
13
+
14
+ def log(log_entry)
15
+ @logs << log_entry
16
+ end
17
+
18
+ def flush
19
+ # No-op for in-memory
20
+ end
21
+ end
22
+
23
+ # RSpec matcher for asserting log output from a block.
24
+ #
25
+ # Usage:
26
+ # expect { logger.info("hello") }.to log_semantic(level: :info, message: /hello/)
27
+ # expect { logger.warn("oops", payload: { key: "val" }) }.to log_semantic(payload: { key: "val" })
28
+ class LogSemanticMatcher
29
+ def initialize(expected)
30
+ @expected_level = expected[:level]
31
+ @expected_message = expected[:message]
32
+ @expected_named_tags = expected[:named_tags]
33
+ @expected_payload = expected[:payload]
34
+ @captured_logs = []
35
+ end
36
+
37
+ def matches?(block)
38
+ appender = InMemoryAppender.new
39
+ SemanticLogger.add_appender(appender: appender)
40
+ # Temporarily lower log level to capture all messages
41
+ previous_level = SemanticLogger.default_level
42
+ SemanticLogger.default_level = :trace
43
+ block.call
44
+ SemanticLogger.flush
45
+ SemanticLogger.default_level = previous_level
46
+ SemanticLogger.remove_appender(appender)
47
+
48
+ @captured_logs = appender.logs
49
+ @captured_logs.any? { |log| matches_log?(log) }
50
+ end
51
+
52
+ def supports_block_expectations?
53
+ true
54
+ end
55
+
56
+ def failure_message
57
+ "expected block to log a message matching #{expected_description}, but captured logs were:\n#{format_logs}"
58
+ end
59
+
60
+ def failure_message_when_negated
61
+ "expected block not to log a message matching #{expected_description}, but it did"
62
+ end
63
+
64
+ private
65
+
66
+ def matches_log?(log)
67
+ matches_level?(log) && matches_message?(log) && matches_named_tags?(log) && matches_payload?(log)
68
+ end
69
+
70
+ def matches_level?(log)
71
+ return true unless @expected_level
72
+
73
+ log.level == @expected_level
74
+ end
75
+
76
+ def matches_message?(log)
77
+ return true unless @expected_message
78
+
79
+ case @expected_message
80
+ when Regexp then @expected_message.match?(log.message.to_s)
81
+ else log.message.to_s == @expected_message.to_s
82
+ end
83
+ end
84
+
85
+ def matches_named_tags?(log)
86
+ return true unless @expected_named_tags
87
+
88
+ @expected_named_tags.all? { |key, value| log.named_tags[key] == value }
89
+ end
90
+
91
+ def matches_payload?(log)
92
+ return true unless @expected_payload
93
+ return false unless log.payload
94
+
95
+ @expected_payload.all? { |key, value| log.payload[key] == value }
96
+ end
97
+
98
+ def expected_description
99
+ parts = []
100
+ parts << "level: #{@expected_level.inspect}" if @expected_level
101
+ parts << "message: #{@expected_message.inspect}" if @expected_message
102
+ parts << "named_tags: #{@expected_named_tags.inspect}" if @expected_named_tags
103
+ parts << "payload: #{@expected_payload.inspect}" if @expected_payload
104
+ "{#{parts.join(', ')}}"
105
+ end
106
+
107
+ def format_logs
108
+ return ' (none)' if @captured_logs.empty?
109
+
110
+ @captured_logs.map { |log| " [#{log.level}] #{log.message} tags=#{log.named_tags} payload=#{log.payload}" }.join("\n")
111
+ end
112
+ end
113
+
114
+ module Matchers
115
+ def log_semantic(expected = {})
116
+ LogSemanticMatcher.new(expected)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,3 @@
1
+ module RailsSemanticLogging
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'rails_semantic_logging/version'
2
+ require_relative 'rails_semantic_logging/configuration'
3
+ require_relative 'rails_semantic_logging/formatters/datadog'
4
+
5
+ module RailsSemanticLogging
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative 'rails_semantic_logging/railtie'
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_semantic_logging
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Fabio Napoleoni
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: anyway_config
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rails_semantic_logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '4.0'
54
+ description: Provides a consistent, opinionated setup for structured JSON logging
55
+ in Rails, with specific hooks for Sidekiq, ActiveJob, and Puma, as well as Datadog-friendly
56
+ formatters.
57
+ email:
58
+ - f.napoleoni@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE.txt
64
+ - README.md
65
+ - lib/rails_semantic_logging.rb
66
+ - lib/rails_semantic_logging/action_controller/default_payload.rb
67
+ - lib/rails_semantic_logging/configuration.rb
68
+ - lib/rails_semantic_logging/datadog/log_injection.rb
69
+ - lib/rails_semantic_logging/formatters/datadog.rb
70
+ - lib/rails_semantic_logging/job_logging/active_job_patch.rb
71
+ - lib/rails_semantic_logging/job_logging/sidekiq_patch.rb
72
+ - lib/rails_semantic_logging/railtie.rb
73
+ - lib/rails_semantic_logging/rspec/helpers.rb
74
+ - lib/rails_semantic_logging/rspec/matchers.rb
75
+ - lib/rails_semantic_logging/version.rb
76
+ homepage: https://github.com/fabn/rails_semantic_logging
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/fabn/rails_semantic_logging
81
+ source_code_uri: https://github.com/fabn/rails_semantic_logging
82
+ changelog_uri: https://github.com/fabn/rails_semantic_logging/releases
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.2'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.6.9
99
+ specification_version: 4
100
+ summary: Opinionated Rails semantic logger configuration with Datadog support
101
+ test_files: []