chronos-ruby 0.3.0.pre.1 → 0.5.0.pre.1

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +55 -17
  4. data/contracts/event-v1.schema.json +25 -11
  5. data/contracts/rack-context-v1.schema.json +43 -0
  6. data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
  7. data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
  8. data/docs/architecture.md +17 -3
  9. data/docs/compatibility.md +6 -6
  10. data/docs/configuration.md +13 -2
  11. data/docs/data-collected.md +15 -2
  12. data/docs/modules/rack-context.md +59 -0
  13. data/docs/modules/rails-legacy.md +60 -0
  14. data/docs/modules/telemetry-events.md +9 -0
  15. data/docs/performance.md +28 -3
  16. data/docs/privacy-lgpd.md +13 -3
  17. data/docs/troubleshooting.md +18 -2
  18. data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
  19. data/lib/chronos/agent.rb +97 -6
  20. data/lib/chronos/application/capture_exception.rb +2 -2
  21. data/lib/chronos/application/capture_telemetry.rb +37 -0
  22. data/lib/chronos/application/remote_configuration.rb +1 -1
  23. data/lib/chronos/configuration/snapshot.rb +53 -0
  24. data/lib/chronos/configuration/validation.rb +122 -0
  25. data/lib/chronos/configuration.rb +22 -134
  26. data/lib/chronos/core/breadcrumb.rb +126 -0
  27. data/lib/chronos/core/telemetry_event.rb +106 -0
  28. data/lib/chronos/integrations/rack/middleware.rb +156 -0
  29. data/lib/chronos/integrations/rack.rb +12 -0
  30. data/lib/chronos/integrations.rb +10 -0
  31. data/lib/chronos/ports/context_store.rb +22 -0
  32. data/lib/chronos/rails/installer.rb +70 -0
  33. data/lib/chronos/rails/notifications_subscriber.rb +203 -0
  34. data/lib/chronos/rails/railtie.rb +21 -0
  35. data/lib/chronos/rails.rb +16 -0
  36. data/lib/chronos/version.rb +1 -1
  37. data/lib/chronos.rb +46 -1
  38. data/lib/generators/chronos/install/install_generator.rb +25 -0
  39. data/lib/generators/chronos/install/templates/chronos.rb +20 -0
  40. metadata +23 -1
@@ -1,4 +1,5 @@
1
1
  require "uri"
2
+ require "chronos/configuration/validation"
2
3
 
3
4
  module Chronos
4
5
  # Mutable configuration used only while the Chronos agent is being set up.
@@ -19,11 +20,13 @@ module Chronos
19
20
  # config.host = 'https://chronos.example.com'
20
21
  # snapshot = config.snapshot
21
22
  class Configuration
23
+ include Internal::ConfigurationValidation
22
24
  DEFAULT_BLOCKLIST_KEYS = %w(
23
25
  password password_confirmation passwd secret api_key apikey authorization
24
26
  token access_token refresh_token private_key client_secret cookie set-cookie
25
27
  session credit_card card_number cvv cpf cnpj
26
28
  ).freeze
29
+ CONTEXT_STORE_METHODS = [:get, :set, :clear, :with_context].freeze
27
30
 
28
31
  ATTRIBUTES = [
29
32
  :project_id, :project_key, :host, :environment, :app_version,
@@ -35,7 +38,9 @@ module Chronos
35
38
  :retry_base_interval, :retry_max_interval, :retry_jitter,
36
39
  :backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
37
40
  :remote_configuration, :remote_config_max_bytes, :sampling_rate,
38
- :enabled_event_types, :max_remote_send_interval
41
+ :enabled_event_types, :max_remote_send_interval, :context_store,
42
+ :breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
43
+ :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
39
44
  ].freeze
40
45
 
41
46
  attr_accessor(*ATTRIBUTES)
@@ -44,6 +49,7 @@ module Chronos
44
49
  initialize_core_defaults
45
50
  initialize_privacy_defaults
46
51
  initialize_resilience_defaults
52
+ initialize_rails_defaults
47
53
  end
48
54
 
49
55
  def snapshot
@@ -57,7 +63,7 @@ module Chronos
57
63
  validation_errors.empty?
58
64
  end
59
65
 
60
- def validation_errors
66
+ def validation_errors # rubocop:disable Metrics/AbcSize
61
67
  errors = []
62
68
  if enabled
63
69
  errors << "project_id is required" if blank?(project_id)
@@ -71,6 +77,7 @@ module Chronos
71
77
  errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
72
78
  errors.concat(resilience_errors)
73
79
  errors.concat(privacy_errors)
80
+ errors.concat(context_errors)
74
81
  errors
75
82
  end
76
83
 
@@ -97,6 +104,16 @@ module Chronos
97
104
  @user_agent = "chronos-ruby/#{Chronos::VERSION}"
98
105
  @max_payload_size = 1_048_576
99
106
  @gzip = false
107
+ @context_store = :thread_local
108
+ @breadcrumb_capacity = 20
109
+ @breadcrumb_max_bytes = 2048
110
+ end
111
+
112
+ def initialize_rails_defaults
113
+ @rails_enabled = true
114
+ @rails_capture_in_console = false
115
+ @rails_capture_in_test = false
116
+ @rails_capture_user_agent = false
100
117
  end
101
118
 
102
119
  def initialize_privacy_defaults
@@ -118,55 +135,10 @@ module Chronos
118
135
  @remote_configuration = true
119
136
  @remote_config_max_bytes = 4096
120
137
  @sampling_rate = 1.0
121
- @enabled_event_types = ["exception"]
138
+ @enabled_event_types = ["exception", "request", "query", "job", "cache"]
122
139
  @max_remote_send_interval = 60.0
123
140
  end
124
141
 
125
- def resilience_errors
126
- errors = []
127
- errors.concat(retry_errors)
128
- errors.concat(backlog_and_circuit_errors)
129
- errors.concat(remote_policy_errors)
130
- errors
131
- end
132
-
133
- def retry_errors
134
- errors = []
135
- errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
136
- errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
137
- errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
138
- if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
139
- retry_max_interval < retry_base_interval
140
- errors << "retry_max_interval must be greater than or equal to retry_base_interval"
141
- end
142
- errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
143
- errors
144
- end
145
-
146
- def backlog_and_circuit_errors
147
- errors = []
148
- errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
149
- unless positive_integer?(circuit_failure_threshold)
150
- errors << "circuit_failure_threshold must be a positive integer"
151
- end
152
- errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
153
- errors
154
- end
155
-
156
- def remote_policy_errors
157
- errors = []
158
- unless [true, false].include?(remote_configuration)
159
- errors << "remote_configuration must be true or false"
160
- end
161
- errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
162
- errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
163
- unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
164
- errors << "enabled_event_types must contain only String values"
165
- end
166
- errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
167
- errors
168
- end
169
-
170
142
  def to_hash
171
143
  ATTRIBUTES.each_with_object({}) do |attribute, values|
172
144
  value = public_send(attribute)
@@ -174,39 +146,6 @@ module Chronos
174
146
  end
175
147
  end
176
148
 
177
- def privacy_errors
178
- errors = []
179
- errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
180
- errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
181
- errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
182
- errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
183
- errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
184
- errors.concat(matcher_errors("hash_keys", hash_keys))
185
- errors.concat(filter_errors)
186
- errors.concat(anonymization_errors)
187
- errors
188
- end
189
-
190
- def filter_errors
191
- return ["filters must be an array"] unless filters.is_a?(Array)
192
- return [] if filters.all? { |filter| filter.respond_to?(:call) }
193
-
194
- ["filters must contain only callable objects"]
195
- end
196
-
197
- def anonymization_errors
198
- return [] if anonymize_ip == true || anonymize_ip == false
199
-
200
- ["anonymize_ip must be true or false"]
201
- end
202
-
203
- def matcher_errors(name, values)
204
- return [] unless values.is_a?(Array)
205
- return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
206
-
207
- ["#{name} must contain only String, Symbol, or Regexp values"]
208
- end
209
-
210
149
  def deep_copy(value)
211
150
  case value
212
151
  when Hash
@@ -220,18 +159,6 @@ module Chronos
220
159
  end
221
160
  end
222
161
 
223
- def host_errors
224
- return ["host is required"] if blank?(host)
225
-
226
- uri = URI.parse(host.to_s)
227
- return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
228
- return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
229
-
230
- []
231
- rescue URI::InvalidURIError
232
- ["host must be a valid URL"]
233
- end
234
-
235
162
  def blank?(value)
236
163
  value.nil? || value.to_s.strip.empty?
237
164
  end
@@ -251,46 +178,7 @@ module Chronos
251
178
  def rate?(value)
252
179
  value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
253
180
  end
254
-
255
- # Immutable configuration shared by all runtime components.
256
- #
257
- # @responsibility Expose validated settings without mutable containers.
258
- # @motivation Keep capture behavior stable while multiple threads run.
259
- # @limits It cannot be edited after creation.
260
- # @thread_safety Safe to share between threads after construction.
261
- # @compatibility Ruby 2.2.10 through Ruby 2.6.
262
- class Snapshot
263
- attr_reader(*ATTRIBUTES)
264
-
265
- def initialize(values)
266
- ATTRIBUTES.each do |attribute|
267
- value = values[attribute]
268
- deep_freeze(value)
269
- instance_variable_set("@#{attribute}", value)
270
- end
271
- freeze
272
- end
273
-
274
- def enabled_for_environment?
275
- enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
276
- end
277
-
278
- private
279
-
280
- def deep_freeze(value)
281
- return value if value.respond_to?(:call)
282
-
283
- case value
284
- when Hash
285
- value.each do |key, child|
286
- deep_freeze(key)
287
- deep_freeze(child)
288
- end
289
- when Array
290
- value.each { |child| deep_freeze(child) }
291
- end
292
- value.freeze
293
- end
294
- end
295
181
  end
296
182
  end
183
+
184
+ require "chronos/configuration/snapshot"
@@ -0,0 +1,126 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Chronos
5
+ module Core
6
+ # Immutable, bounded diagnostic marker attached to an execution.
7
+ #
8
+ # @responsibility Normalize one breadcrumb without retaining raw application objects.
9
+ # @motivation Preserve useful events leading to an exception with predictable memory use.
10
+ # @limits It does not capture logs, SQL, bodies, or HTTP calls automatically.
11
+ # @collaborators SafeSerializer and BreadcrumbBuffer.
12
+ # @thread_safety Immutable after construction.
13
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
14
+ # @example
15
+ # breadcrumb.to_h #=> {"category"=>"custom", ...}
16
+ # @errors Unserializable metadata is replaced by SafeSerializer placeholders.
17
+ # @performance Metadata traversal has strict depth, node, item, and byte limits.
18
+ class Breadcrumb
19
+ CATEGORIES = %w(custom log request query external_http cache job).freeze
20
+
21
+ def initialize(attributes, clock = nil, max_bytes = 2048)
22
+ attributes = {} unless attributes.is_a?(Hash)
23
+ clock ||= proc { Time.now }
24
+ serializer = SafeSerializer.new(
25
+ :max_depth => 5, :max_keys => 20, :max_items => 20,
26
+ :max_string_bytes => 512, :max_nodes => 100
27
+ )
28
+ @data = serializer.call(build_data(attributes, clock))
29
+ @data = compact_data(@data, max_bytes) if JSON.generate(@data).bytesize > max_bytes
30
+ deep_freeze(@data)
31
+ freeze
32
+ end
33
+
34
+ def to_h
35
+ @data
36
+ end
37
+
38
+ private
39
+
40
+ def build_data(attributes, clock)
41
+ category = value(attributes, :category).to_s
42
+ category = "custom" unless CATEGORIES.include?(category)
43
+ {
44
+ "category" => category,
45
+ "message" => value(attributes, :message).to_s,
46
+ "metadata" => value(attributes, :metadata) || {},
47
+ "timestamp" => clock.call.utc.iso8601(6)
48
+ }
49
+ end
50
+
51
+ def compact_data(data, max_bytes)
52
+ message_limit = [max_bytes / 4, 32].max
53
+ compacted = {
54
+ "category" => data["category"],
55
+ "message" => SafeSerializer.new.call(data["message"], :max_string_bytes => message_limit),
56
+ "metadata" => {"_truncated" => true},
57
+ "timestamp" => data["timestamp"]
58
+ }
59
+ trim_compacted_message(compacted, max_bytes)
60
+ end
61
+
62
+ def trim_compacted_message(compacted, max_bytes)
63
+ while JSON.generate(compacted).bytesize > max_bytes && !compacted["message"].empty?
64
+ length = [compacted["message"].bytesize - 16, 0].max
65
+ compacted["message"] = SafeSerializer.new.call(
66
+ compacted["message"].byteslice(0, length).to_s,
67
+ :max_string_bytes => length
68
+ )
69
+ end
70
+ compacted
71
+ end
72
+
73
+ def value(attributes, key)
74
+ attributes.key?(key) ? attributes[key] : attributes[key.to_s]
75
+ end
76
+
77
+ def deep_freeze(value)
78
+ if value.is_a?(Hash)
79
+ value.each do |key, child|
80
+ deep_freeze(key)
81
+ deep_freeze(child)
82
+ end
83
+ elsif value.is_a?(Array)
84
+ value.each { |child| deep_freeze(child) }
85
+ end
86
+ value.freeze
87
+ end
88
+ end
89
+
90
+ # Fixed-size circular collection of execution breadcrumbs.
91
+ #
92
+ # @responsibility Retain only the newest bounded breadcrumbs for one execution.
93
+ # @motivation Prevent long requests or noisy instrumentation from growing memory indefinitely.
94
+ # @limits It is process memory only and does not collect events by itself.
95
+ # @collaborators Breadcrumb and Agent.
96
+ # @thread_safety Intended for one execution thread; snapshots are immutable values.
97
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
98
+ # @example
99
+ # buffer.add(:category => "custom", :message => "started")
100
+ # @errors Invalid attributes are normalized into a safe breadcrumb.
101
+ # @performance Add is constant time and storage never exceeds capacity.
102
+ class BreadcrumbBuffer
103
+ def initialize(capacity, max_bytes = 2048)
104
+ raise ArgumentError, "capacity must be a positive integer" unless capacity.is_a?(Integer) && capacity > 0
105
+
106
+ @capacity = capacity
107
+ @max_bytes = max_bytes
108
+ @items = []
109
+ end
110
+
111
+ def add(attributes)
112
+ @items.shift if @items.length >= @capacity
113
+ @items << Breadcrumb.new(attributes, nil, @max_bytes)
114
+ true
115
+ end
116
+
117
+ def to_a
118
+ @items.map(&:to_h)
119
+ end
120
+
121
+ def size
122
+ @items.size
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,106 @@
1
+ require "securerandom"
2
+ require "time"
3
+
4
+ module Chronos
5
+ module Core
6
+ # Immutable framework telemetry event normalized to the Chronos v1 envelope.
7
+ #
8
+ # @responsibility Carry a bounded event type, timestamp, context, and payload.
9
+ # @motivation Let integrations report metrics without depending on exception notices.
10
+ # @limits It does not sanitize, serialize, enqueue, or deliver itself.
11
+ # @collaborators TelemetrySerializer and framework integrations.
12
+ # @thread_safety Immutable after construction and safe to share.
13
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
14
+ # @example
15
+ # event = Chronos::Core::TelemetryEvent.new("request", {"duration_ms" => 12.0})
16
+ # @errors Unsupported types raise ArgumentError during construction.
17
+ # @performance Construction is linear in supplied context and payload size.
18
+ class TelemetryEvent
19
+ TYPES = %w(request query job cache).freeze
20
+
21
+ attr_reader :event_id, :event_type, :timestamp, :context, :payload
22
+
23
+ def initialize(event_type, payload = {}, context = {}, options = {})
24
+ type = event_type.to_s
25
+ raise ArgumentError, "unsupported telemetry event type" unless TYPES.include?(type)
26
+
27
+ clock = options[:clock] || proc { Time.now }
28
+ @event_id = (options[:event_id] || SecureRandom.uuid).to_s.freeze
29
+ @event_type = type.freeze
30
+ @timestamp = clock.call.utc.iso8601(6).freeze
31
+ @context = deep_freeze(context.is_a?(Hash) ? context : {})
32
+ @payload = deep_freeze(payload.is_a?(Hash) ? payload : {})
33
+ freeze
34
+ end
35
+
36
+ private
37
+
38
+ def deep_freeze(value)
39
+ if value.is_a?(Hash)
40
+ value.each do |key, child|
41
+ deep_freeze(key)
42
+ deep_freeze(child)
43
+ end
44
+ elsif value.is_a?(Array)
45
+ value.each { |child| deep_freeze(child) }
46
+ end
47
+ value.freeze
48
+ end
49
+ end
50
+
51
+ # Serializes framework telemetry into the common Chronos event envelope.
52
+ #
53
+ # @responsibility Sanitize telemetry, normalize JSON primitives, and enforce payload size.
54
+ # @motivation Give Rails subscribers the same privacy and delivery boundary as exceptions.
55
+ # @limits It accepts only TelemetryEvent values and does not perform delivery.
56
+ # @collaborators TelemetryEvent, Sanitizer, SafeSerializer, RuntimeInfo, and SerializedEvent.
57
+ # @thread_safety Stateless apart from immutable configuration.
58
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
59
+ # @example
60
+ # serialized = serializer.call(event)
61
+ # @errors Oversized events raise Chronos::Error and are contained by CaptureTelemetry.
62
+ # @performance Traversal and output bytes are bounded by existing serializer budgets.
63
+ class TelemetrySerializer
64
+ def initialize(config, clock = nil, options = {})
65
+ @config = config
66
+ @clock = clock || proc { Time.now }
67
+ @sanitizer = options[:sanitizer] || Sanitizer.new(config)
68
+ @safe_serializer = options[:safe_serializer] || SafeSerializer.new
69
+ @max_payload_size = options[:max_payload_size] || proc { @config.max_payload_size }
70
+ @runtime_info = RuntimeInfo.new
71
+ end
72
+
73
+ def call(event)
74
+ raise ArgumentError, "event must be a TelemetryEvent" unless event.is_a?(TelemetryEvent)
75
+
76
+ envelope = @safe_serializer.call(@sanitizer.call(build_envelope(event)))
77
+ body = JSON.generate(envelope)
78
+ body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @max_payload_size.call
79
+ raise Error, "event exceeds max_payload_size" if body.bytesize > @max_payload_size.call
80
+
81
+ SerializedEvent.new(event.event_id, body)
82
+ end
83
+
84
+ private
85
+
86
+ def build_envelope(event)
87
+ runtime = @runtime_info.call
88
+ {
89
+ "schema_version" => "1.0", "event_id" => event.event_id,
90
+ "event_type" => event.event_type, "occurred_at" => event.timestamp,
91
+ "sent_at" => @clock.call.utc.iso8601(6), "project_key" => @config.project_id,
92
+ "environment" => @config.environment,
93
+ "service" => {"name" => @config.service_name, "version" => @config.app_version,
94
+ "instance_id" => runtime[:host]},
95
+ "runtime" => runtime[:runtime], "context" => event.context, "payload" => event.payload
96
+ }
97
+ end
98
+
99
+ def compact_envelope(envelope)
100
+ envelope["context"] = {"_truncated" => true}
101
+ envelope["payload"] = {"_truncated" => true}
102
+ envelope
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,156 @@
1
+ require "securerandom"
2
+
3
+ module Chronos
4
+ module Integrations
5
+ module Rack
6
+ # Captures unhandled Rack exceptions while preserving Rack semantics.
7
+ #
8
+ # @responsibility Build bounded request context, notify Chronos, and re-raise the original exception.
9
+ # @motivation Provide automatic error capture without coupling the core to Rack.
10
+ # @limits It never reads rack.input, enumerates response bodies, or infers Rails routes.
11
+ # @collaborators Rack application and the Chronos facade or Agent.
12
+ # @thread_safety Shared instances keep no per-request mutable state.
13
+ # @compatibility Rack 1.x/2.x protocol; Ruby 2.2.10 through Ruby 2.6.
14
+ # @example
15
+ # use Chronos::Integrations::Rack::Middleware
16
+ # @errors Notification failures are contained; the application exception is always re-raised.
17
+ # @performance Adds bounded hash construction and monotonic-clock reads per request.
18
+ class Middleware
19
+ def initialize(app, options = {})
20
+ @app = app
21
+ @notifier = options[:notifier] || Chronos
22
+ @include_user_agent = options[:include_user_agent] || false
23
+ @clock = options[:clock] || proc { monotonic_time }
24
+ end
25
+
26
+ def call(env)
27
+ started_at = @clock.call
28
+ base = request_capture_context(env)
29
+ @notifier.with_context(base) do
30
+ add_request_breadcrumb("request started", base)
31
+ call_application(env, base, started_at)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def call_application(env, base, started_at)
38
+ response = @app.call(env)
39
+ status = response[0]
40
+ headers = response[1]
41
+ add_request_breadcrumb("request completed", dynamic_request_context(base, status, headers, started_at))
42
+ response
43
+ rescue Exception => error # rubocop:disable Lint/RescueException
44
+ context = dynamic_request_context(base, 500, nil, started_at)
45
+ notify_safely(error, context)
46
+ raise
47
+ end
48
+
49
+ def notify_safely(error, context)
50
+ if @notifier.respond_to?(:notify_once)
51
+ @notifier.notify_once(error, context)
52
+ else
53
+ @notifier.notify(error, context)
54
+ end
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ def request_capture_context(env)
60
+ request = request_values(env)
61
+ {
62
+ :context => {"request" => request, "trace_id" => trace_id(env)},
63
+ :parameters => parameters(env),
64
+ :user => hash_value(env["chronos.user"])
65
+ }
66
+ end
67
+
68
+ def request_values(env)
69
+ values = {
70
+ "method" => env["REQUEST_METHOD"].to_s,
71
+ "route" => normalized_route(env),
72
+ "request_id" => request_id(env),
73
+ "host" => (env["HTTP_HOST"] || env["SERVER_NAME"]).to_s,
74
+ "path" => env["PATH_INFO"].to_s,
75
+ "controller" => controller_action(env, "controller"),
76
+ "action" => controller_action(env, "action")
77
+ }
78
+ values["user_agent"] = env["HTTP_USER_AGENT"].to_s if @include_user_agent
79
+ values
80
+ end
81
+
82
+ def dynamic_request_context(base, status, headers, started_at)
83
+ request = base[:context]["request"].merge(
84
+ "status" => status.to_i,
85
+ "duration_ms" => ((@clock.call - started_at) * 1000.0).round(3),
86
+ "response_size" => response_size(headers)
87
+ )
88
+ {:context => base[:context].merge("request" => request)}
89
+ end
90
+
91
+ def parameters(env)
92
+ result = {}
93
+ [env["rack.request.query_hash"], env["action_dispatch.request.query_parameters"],
94
+ env["action_dispatch.request.path_parameters"], env["chronos.parameters"]].each do |candidate|
95
+ result.merge!(candidate) if candidate.is_a?(Hash)
96
+ end
97
+ result
98
+ rescue StandardError
99
+ {}
100
+ end
101
+
102
+ def normalized_route(env)
103
+ explicit = env["chronos.route"] || env["action_dispatch.route_uri_pattern"]
104
+ return explicit.to_s unless explicit.to_s.empty?
105
+
106
+ env["PATH_INFO"].to_s.split("/").map { |part| dynamic_segment?(part) ? ":id" : part }.join("/")
107
+ end
108
+
109
+ def dynamic_segment?(segment)
110
+ segment =~ /\A\d+\z/ || segment =~ /\A[0-9a-f]{8}-[0-9a-f-]{27,}\z/i
111
+ end
112
+
113
+ def controller_action(env, key)
114
+ explicit = env["chronos.#{key}"]
115
+ paths = env["action_dispatch.request.path_parameters"]
116
+ explicit || (paths[key] if paths.is_a?(Hash)) || (paths[key.to_sym] if paths.is_a?(Hash))
117
+ end
118
+
119
+ def request_id(env)
120
+ env["chronos.request_id"] || env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
121
+ end
122
+
123
+ def trace_id(env)
124
+ env["chronos.trace_id"] || SecureRandom.uuid
125
+ end
126
+
127
+ def response_size(headers)
128
+ return nil unless headers.respond_to?(:each)
129
+
130
+ pair = headers.find { |key, _value| key.to_s.casecmp("content-length").zero? }
131
+ pair ? pair[1].to_i : nil
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def add_request_breadcrumb(message, context)
137
+ request = context[:context]["request"]
138
+ @notifier.add_breadcrumb(
139
+ :category => "request", :message => message,
140
+ :metadata => {"method" => request["method"], "route" => request["route"], "status" => request["status"]}
141
+ )
142
+ end
143
+
144
+ def hash_value(value)
145
+ value.is_a?(Hash) ? value : {}
146
+ end
147
+
148
+ def monotonic_time
149
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
+ rescue StandardError
151
+ Time.now.to_f
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,12 @@
1
+ module Chronos
2
+ module Integrations
3
+ # Rack protocol integrations that do not require Rack at gem load time.
4
+ #
5
+ # @responsibility Namespace Rack-compatible middleware.
6
+ # @motivation Permit optional use without adding a runtime Rack dependency.
7
+ # @limits It does not provide Rails-specific route discovery.
8
+ # @thread_safety Middleware instances may be shared by concurrent Rack threads.
9
+ # @compatibility Rack 1.x and 2.x protocol shapes on Ruby 2.2.10 through Ruby 2.6.
10
+ module Rack; end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module Chronos
2
+ # Optional framework entry points kept outside the framework-independent core.
3
+ #
4
+ # @responsibility Namespace integration adapters such as Rack middleware.
5
+ # @motivation Keep framework loading optional for plain Ruby applications.
6
+ # @limits Integrations may depend only on documented public agent behavior.
7
+ # @thread_safety Each integration documents its own guarantees.
8
+ # @compatibility Version 0.5 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
9
+ module Integrations; end
10
+ end
@@ -0,0 +1,22 @@
1
+ module Chronos
2
+ module Ports
3
+ # Conceptual storage port for execution-scoped context.
4
+ #
5
+ # @responsibility Define get, set, clear, and scoped context behavior.
6
+ # @motivation Let integrations isolate request state without depending on a thread implementation.
7
+ # @limits The port does not prescribe thread, fiber, or distributed propagation semantics.
8
+ # @collaborators Agent and context-store adapters.
9
+ # @thread_safety Implementations must isolate concurrent executions.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # Chronos::Ports::ContextStore.compatible?(store) #=> true
13
+ # @errors Compatibility checks never invoke application methods.
14
+ module ContextStore
15
+ REQUIRED_METHODS = [:get, :set, :clear, :with_context].freeze
16
+
17
+ def self.compatible?(object)
18
+ REQUIRED_METHODS.all? { |method_name| object.respond_to?(method_name) }
19
+ end
20
+ end
21
+ end
22
+ end