Skip to content

Commit 89bc373

Browse files
committed
Fix lograge custom request fields
1 parent e871d89 commit 89bc373

File tree

13 files changed

+206
-27
lines changed

13 files changed

+206
-27
lines changed

.rubocop.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ AllCops:
1313
SuggestExtensions:
1414
rubocop-rails: false
1515
Exclude:
16-
- node_modules/**/*
16+
- '**/node_modules/**/*'
1717
- public/**/*
1818
- vendor/**/*
1919
- tmp/**/*
20+
- coverage/**/*
21+
- coverage_rails/**/*
22+
- docs/public/**/*
2023
- docspring/**/*
24+
- convox_racks_terraform/**/*
25+
- terraform-aws-logstruct/**/*
26+
- terraform-example/**/*
27+
- terraform-provider-logstruct/**/*
2128
- sorbet/rbi/dsl/**/*
2229
- sorbet/rbi/gems/**/*
2330
- sorbet/rbi/annotations/**/*

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Once initialized (and enabled), the gem automatically includes its modules into
5252
- `ActiveSupport::TaggedLogging` is patched to support both Hashes and Strings (only when LogStruct is enabled)
5353
- `ActionMailer::Base` includes error handling and event logging modules
5454
- We configure `Lograge` for request logging
55+
- Lograge request logs include request metadata (request_id, source_ip, user_agent, referer, host, content_type, accept) and custom fields from `lograge_custom_options`
5556
- A Rack middleware is inserted to catch and log errors, including security violations (IP spoofing, CSRF, blocked hosts, etc.)
5657
- Structured logging is set up for ActiveJob, Sidekiq, Shrine, etc.
5758
- Rails `config.filter_parameters` are merged into LogStruct's filters and then cleared (to avoid double filtering). Configure sensitive keys via `LogStruct.config.filters`.

docs/app/docs/configuration/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@ end`}
233233
</p>
234234

235235
<RubyCodeExample name="lograge_custom_options" />
236+
<p className="text-neutral-600 dark:text-neutral-300 mt-4">
237+
Custom options are merged into the request log output at the top level. LogStruct also
238+
captures request metadata like <code>request_id</code>, <code>source_ip</code>,
239+
<code>user_agent</code>, <code>referer</code>, <code>host</code>, <code>content_type</code>,
240+
and <code>accept</code> automatically when present.
241+
</p>
236242

237243
<HeadingWithAnchor id="custom-string-scrubbing">Custom String Scrubbing</HeadingWithAnchor>
238244
<p className="text-neutral-600 dark:text-neutral-300 mb-4">

docs/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Config } from 'jest';
2-
import nextJest from 'next/jest';
2+
import nextJest from 'next/jest.js';
33

44
const createJestConfig = nextJest({
55
// Provide the path to your Next.js app

docs/lib/log-generation/sample-data.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export const SampleByLogField: Readonly<Record<LogField, (gen: RandomGen) => unk
193193
[LogField.UserAgent]: (_gen: RandomGen) => 'Mozilla/5.0',
194194
[LogField.Referer]: (_gen: RandomGen) => 'https://example.com',
195195
[LogField.RequestId]: SampleHelpers.hex8,
196+
[LogField.Host]: (_gen: RandomGen) => 'example.test',
197+
[LogField.ContentType]: (_gen: RandomGen) => 'application/json',
198+
[LogField.Accept]: (_gen: RandomGen) => 'application/json',
196199
[LogField.Controller]: (_gen: RandomGen) => 'HomeController',
197200
[LogField.Action]: (_gen: RandomGen) => 'index',
198201
[LogField.View]: (gen: RandomGen) => gen.randomFloat(0, 200),

lib/log_struct/enums/log_field.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class LogField < T::Enum
3333
UserAgent = new(:user_agent)
3434
Referer = new(:referer)
3535
RequestId = new(:request_id)
36+
Host = new(:host)
37+
ContentType = new(:content_type)
38+
Accept = new(:accept)
3639

3740
# HTTP-specific fields
3841
Format = new(:format)

lib/log_struct/integrations/lograge.rb

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ module Integrations
1313
module Lograge
1414
extend IntegrationInterface
1515

16+
LOGRAGE_KNOWN_KEYS = T.let(
17+
[
18+
:method,
19+
:path,
20+
:format,
21+
:controller,
22+
:action,
23+
:status,
24+
:duration,
25+
:view,
26+
:db,
27+
:params,
28+
:request_id,
29+
:source_ip,
30+
:user_agent,
31+
:referer,
32+
:host,
33+
:content_type,
34+
:accept
35+
].freeze,
36+
T::Array[Symbol]
37+
)
38+
1639
class << self
1740
extend T::Sig
1841

@@ -38,30 +61,9 @@ def configure_lograge(logstruct_config)
3861
# The struct is converted to JSON by our Formatter (after filtering, etc.)
3962
config.lograge.formatter = T.let(
4063
lambda do |data|
41-
# Coerce common fields to expected types
42-
status = ((s = data[:status]) && s.respond_to?(:to_i)) ? s.to_i : s
43-
duration_ms = ((d = data[:duration]) && d.respond_to?(:to_f)) ? d.to_f : d
44-
view = ((v = data[:view]) && v.respond_to?(:to_f)) ? v.to_f : v
45-
db = ((b = data[:db]) && b.respond_to?(:to_f)) ? b.to_f : b
46-
47-
params = data[:params]
48-
params = params.deep_symbolize_keys if params&.respond_to?(:deep_symbolize_keys)
49-
50-
Log::Request.new(
51-
http_method: data[:method]&.to_s,
52-
path: data[:path]&.to_s,
53-
format: data[:format]&.to_sym,
54-
controller: data[:controller]&.to_s,
55-
action: data[:action]&.to_s,
56-
status: status,
57-
duration_ms: duration_ms,
58-
view: view,
59-
database: db,
60-
params: params,
61-
timestamp: Time.now
62-
)
64+
LogStruct::Integrations::Lograge.build_request_log(data)
6365
end,
64-
T.proc.params(hash: T::Hash[Symbol, T.untyped]).returns(Log::Request)
66+
T.proc.params(hash: T::Hash[T.any(Symbol, String), T.untyped]).returns(Log::Request)
6567
)
6668

6769
# Add custom options to lograge
@@ -100,6 +102,7 @@ def process_headers(event, options)
100102
return if headers.blank?
101103

102104
options[:user_agent] = headers["HTTP_USER_AGENT"]
105+
options[:referer] = headers["HTTP_REFERER"]
103106
options[:content_type] = headers["CONTENT_TYPE"]
104107
options[:accept] = headers["HTTP_ACCEPT"]
105108
end
@@ -114,6 +117,66 @@ def apply_custom_options(event, options)
114117
# The proc can modify the options hash directly
115118
custom_options_proc.call(event, options)
116119
end
120+
121+
sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(Log::Request) }
122+
def build_request_log(data)
123+
normalized_data = normalize_lograge_data(data)
124+
125+
# Coerce common fields to expected types
126+
status = ((s = normalized_data[:status]) && s.respond_to?(:to_i)) ? s.to_i : s
127+
duration_ms = ((d = normalized_data[:duration]) && d.respond_to?(:to_f)) ? d.to_f : d
128+
view = ((v = normalized_data[:view]) && v.respond_to?(:to_f)) ? v.to_f : v
129+
db = ((b = normalized_data[:db]) && b.respond_to?(:to_f)) ? b.to_f : b
130+
131+
params = normalized_data[:params]
132+
params = params.deep_symbolize_keys if params&.respond_to?(:deep_symbolize_keys)
133+
134+
additional_data = extract_additional_data(normalized_data)
135+
136+
Log::Request.new(
137+
http_method: normalized_data[:method]&.to_s,
138+
path: normalized_data[:path]&.to_s,
139+
format: normalized_data[:format]&.to_sym,
140+
controller: normalized_data[:controller]&.to_s,
141+
action: normalized_data[:action]&.to_s,
142+
status: status,
143+
duration_ms: duration_ms,
144+
view: view,
145+
database: db,
146+
params: params,
147+
request_id: normalized_data[:request_id]&.to_s,
148+
source_ip: normalized_data[:source_ip]&.to_s,
149+
user_agent: normalized_data[:user_agent]&.to_s,
150+
referer: normalized_data[:referer]&.to_s,
151+
host: normalized_data[:host]&.to_s,
152+
content_type: normalized_data[:content_type]&.to_s,
153+
accept: normalized_data[:accept]&.to_s,
154+
additional_data: additional_data,
155+
timestamp: Time.now
156+
)
157+
end
158+
159+
sig { params(data: T::Hash[T.any(Symbol, String), T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
160+
def normalize_lograge_data(data)
161+
data.each_with_object({}) do |(key, value), normalized|
162+
normalized[key.to_s.to_sym] = value
163+
end
164+
end
165+
166+
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
167+
def extract_additional_data(data)
168+
extras = T.let({}, T::Hash[Symbol, T.untyped])
169+
data.each do |key, value|
170+
next if LOGRAGE_KNOWN_KEYS.include?(key)
171+
next if value.nil?
172+
173+
extras[key] = value
174+
end
175+
176+
return nil if extras.empty?
177+
178+
extras
179+
end
117180
end
118181
end
119182
end

lib/log_struct/log/request.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ class Request < T::Struct
4444
const :view, T.nilable(Float), default: nil
4545
const :database, T.nilable(Float), default: nil
4646
const :params, T.nilable(T::Hash[Symbol, T.untyped]), default: nil
47+
const :host, T.nilable(String), default: nil
48+
const :content_type, T.nilable(String), default: nil
49+
const :accept, T.nilable(String), default: nil
50+
51+
# Additional data
52+
include LogStruct::Log::Interfaces::AdditionalDataField
53+
const :additional_data, T.nilable(T::Hash[T.any(String, Symbol), T.untyped]), default: nil
54+
include LogStruct::Log::Shared::MergeAdditionalDataFields
4755

4856
# Request fields (optional)
4957
include LogStruct::Log::Interfaces::RequestFields
@@ -70,6 +78,9 @@ def to_h
7078
h[LogField::View] = view unless view.nil?
7179
h[LogField::Database] = database unless database.nil?
7280
h[LogField::Params] = params unless params.nil?
81+
h[LogField::Host] = host unless host.nil?
82+
h[LogField::ContentType] = content_type unless content_type.nil?
83+
h[LogField::Accept] = accept unless accept.nil?
7384
h
7485
end
7586
end

lib/log_struct/semantic_logger/formatter.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,17 @@ def call(log, logger)
8686
private
8787

8888
# Extract a LogStruct from the various places it might be stored in a SemanticLogger::Log
89-
sig { params(log: ::SemanticLogger::Log).returns(T.nilable(LogStruct::Log::Interfaces::CommonFields)) }
89+
sig do
90+
params(log: ::SemanticLogger::Log).returns(
91+
T.nilable(
92+
T.any(
93+
LogStruct::Log::Interfaces::CommonFields,
94+
LogStruct::Log::Interfaces::PublicCommonFields,
95+
T::Struct
96+
)
97+
)
98+
)
99+
end
90100
def extract_logstruct(log)
91101
# Check payload first (most common path for structured logging)
92102
if log.payload.is_a?(Hash) && log.payload[:payload].is_a?(LogStruct::Log::Interfaces::CommonFields)
@@ -97,6 +107,10 @@ def extract_logstruct(log)
97107
return log.payload
98108
end
99109

110+
if log.payload.is_a?(LogStruct::Log::Interfaces::PublicCommonFields)
111+
return log.payload
112+
end
113+
100114
# Check message - this is where structs end up when passed directly to logger.info(struct)
101115
if log.message.is_a?(LogStruct::Log::Interfaces::CommonFields)
102116
return T.cast(log.message, LogStruct::Log::Interfaces::CommonFields)
@@ -106,7 +120,7 @@ def extract_logstruct(log)
106120
if log.payload.is_a?(Hash) && log.payload[:payload].is_a?(T::Struct)
107121
struct = log.payload[:payload]
108122
if struct.respond_to?(:source) && struct.respond_to?(:event)
109-
return T.unsafe(struct)
123+
return struct
110124
end
111125
end
112126

schemas/log_sources/request.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ consts:
44
source: Source::Rails
55

66
add_request_fields: true
7+
additional_data: true
78

89
events:
910
Request:
@@ -17,3 +18,6 @@ events:
1718
View: Float
1819
Database: Float
1920
Params: 'T::Hash[Symbol, T.untyped]'
21+
Host: String
22+
ContentType: String
23+
Accept: String

0 commit comments

Comments
 (0)