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
@@ -0,0 +1,9 @@
1
+ # Framework telemetry events
2
+
3
+ `Chronos::Core::TelemetryEvent` and `Chronos::Core::TelemetrySerializer` extend the v1 envelope to `request`, `query`, `job`, and `cache` events. These values exist so framework adapters do not model operational timings as fake exceptions.
4
+
5
+ Telemetry has the same `schema_version`, event ID, timestamps, project, environment, service, runtime, context, sanitization, payload-size limit, idempotency header, asynchronous queue, retry, circuit breaker, and memory backlog used by exception events. Unsupported types are rejected locally.
6
+
7
+ `Chronos.record_event` is the narrow integration entry point. Application code should prefer documented higher-level APIs; its payload is allowlisted by each bundled integration and then sanitized. Remote configuration can reduce or disable any locally enabled telemetry type but cannot enable a type excluded by local configuration.
8
+
9
+ Version 0.5 intentionally sends individual framework events. Aggregation, percentiles, slow-query analysis, and transaction breakdowns remain part of version 0.7.
data/docs/performance.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Performance
2
2
 
3
- Performance is a functional requirement, but version 0.3 makes no unverified speed claim.
3
+ Performance is a functional requirement, but version 0.5 makes no unverified speed claim.
4
4
 
5
5
  Current controls:
6
6
 
@@ -15,9 +15,24 @@ Current controls:
15
15
  - retry count, delay, jitter, and `Retry-After` are capped;
16
16
  - the circuit breaker suppresses requests during sustained failure;
17
17
  - retry backlog capacity is fixed and may be disabled;
18
- - shutdown and flush have caller-controlled timeouts.
18
+ - shutdown and flush have caller-controlled timeouts;
19
+ - request context and breadcrumbs have fixed structural and byte limits;
20
+ - Rack middleware never consumes request or response bodies.
21
+ - Rails subscribers copy only small allowlisted field sets and never copy raw SQL or job arguments.
19
22
 
20
- Run the scripts under `benchmarks/` and record Ruby version, operating system, CPU, warmup, iteration count, median, and dispersion before publishing results. `benchmarks/filtering.rb` measures privacy filtering, while `benchmarks/retry_backlog.rb` measures fixed-memory behavior with an unavailable in-memory transport. Request overhead is not measured because automatic request integration is outside version 0.3.
23
+ Run the scripts under `benchmarks/` and record Ruby version, operating system, CPU, warmup, iteration count, median, and dispersion before publishing results. `benchmarks/filtering.rb` measures privacy filtering, `benchmarks/retry_backlog.rb` measures fixed-memory outage behavior, `benchmarks/request_overhead.rb` compares Rack-protocol calls, and `benchmarks/rails_notifications.rb` isolates subscriber normalization overhead.
24
+
25
+ ## Version 0.5 Rails subscriber benchmark
26
+
27
+ Run:
28
+
29
+ ```bash
30
+ ITERATIONS=100000 bundle _1.17.3_ exec ruby benchmarks/rails_notifications.rb
31
+ ```
32
+
33
+ This synthetic benchmark invokes the SQL notification normalizer with a no-op notifier. It verifies that raw SQL and binds are not copied and measures local allowlist normalization only; it does not include Rails dispatch, serialization, queueing, or network delivery.
34
+
35
+ A local diagnostic run on 2026-07-20 used Ruby 2.2.10 on macOS arm64 and 10,000 iterations. It completed in 0.041232 seconds, or approximately 4.123 microseconds per normalization. This single run has no warmup, median, or dispersion and is not a production performance claim.
21
36
 
22
37
  ## Version 0.2 development measurement
23
38
 
@@ -51,3 +66,13 @@ A local diagnostic run on 2026-07-20 used Ruby 2.2.10 on macOS arm64 with 1,000
51
66
  | Open-circuit backlog handling | 88,922 operations/second |
52
67
 
53
68
  The outage run retained exactly 100 events and rejected 9,900 additional events without growing the backlog. These are single development runs without median or dispersion and are not comparative performance claims.
69
+
70
+ ## Version 0.4 Rack middleware benchmark
71
+
72
+ Run:
73
+
74
+ ```bash
75
+ ITERATIONS=100000 bundle _1.17.3_ exec ruby benchmarks/request_overhead.rb
76
+ ```
77
+
78
+ A local diagnostic run on 2026-07-20 used Ruby 2.2.10 on macOS arm64 and 10,000 successful Rack-protocol calls. The direct application calls took 0.013272 seconds and middleware calls took 0.539758 seconds, for an estimated 52.649 microseconds of middleware work per request. The fixture uses no network, error capture, or response-body enumeration. This is a single development measurement, not a production latency claim; controlled warmup, median, and dispersion remain required before publishing a performance claim.
data/docs/privacy-lgpd.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Privacy and LGPD
2
2
 
3
- Version 0.3 sanitizes every exception event before JSON serialization, queueing, retry backlog, or transport. This reduces accidental exposure, but the host application remains responsible for lawful purpose, minimization, access control, retention, and responses to data-subject requests.
3
+ Version 0.5 sanitizes exception and framework telemetry before JSON serialization, queueing, retry backlog, or transport. This reduces accidental exposure, but the host application remains responsible for lawful purpose, minimization, access control, retention, and responses to data-subject requests.
4
4
 
5
5
  ## Default policy
6
6
 
@@ -13,9 +13,19 @@ Version 0.3 sanitizes every exception event before JSON serialization, queueing,
13
13
  | Payment-card candidates that pass the Luhn check | Replaced with `[FILTERED_CARD]` |
14
14
  | IPv4 addresses | Last octet replaced with `0` |
15
15
  | Unknown Ruby objects | Represented by class name without calling application serialization |
16
- | Request/response bodies, cookies, HTTP headers, SQL binds, environment variables | Never collected automatically in version 0.3 |
16
+ | Request/response bodies, raw query strings, cookies, authorization headers, raw SQL/binds, cache values, mail bodies, job arguments, environment variables | Never collected automatically in version 0.5 |
17
17
 
18
- The retry backlog exists only in process memory, accepts only `SerializedEvent`, has a fixed capacity, and disappears on process exit. Version 0.3 does not persist telemetry to disk.
18
+ The retry backlog exists only in process memory, accepts only `SerializedEvent`, has a fixed capacity, and disappears on process exit. Version 0.5 does not persist telemetry to disk.
19
+
20
+ ## Rails telemetry
21
+
22
+ Rails subscribers use per-notification allowlists. SQL events retain only the operation name, cached flag, and duration; cache events omit key and value; mailer events omit addresses and content; Active Job events omit job ID and arguments; view identifiers are reduced to basenames. Controller parameters are sanitized by the normal payload pipeline before queueing.
23
+
24
+ ## Rack context and breadcrumbs
25
+
26
+ The Rack middleware copies only bounded operational fields and parameter hashes that another component already parsed. It does not read `rack.input` or copy `QUERY_STRING`. User context is opt-in through `chronos.user`, and user agent collection is disabled unless the middleware option enables it.
27
+
28
+ Breadcrumbs contain category, bounded message, bounded metadata, and timestamp. No raw request, response, SQL, cache, job, log, or external HTTP payload is added automatically. Rack context and breadcrumbs pass through the same sanitizer as manual context before queueing.
19
29
 
20
30
  Blocklist matching accepts `String`, `Symbol`, and `Regexp`. String and Symbol matching is case-insensitive after punctuation normalization and also protects namespaced keys such as `user_password`.
21
31
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Configuration raises an error
4
4
 
5
- Verify `project_id`, `project_key`, and `host`. HTTPS is required while `ssl_verify` is true. Queue size, worker count, payload size, and timeouts must be positive.
5
+ Verify `project_id`, `project_key`, and `host`. HTTPS is required while `ssl_verify` is true. Queue size, worker count, payload size, breadcrumb capacity, and timeouts must be positive. `breadcrumb_max_bytes` must be at least 128.
6
6
 
7
7
  ## `Chronos.notify` returns false
8
8
 
@@ -10,7 +10,23 @@ The agent may be unconfigured, disabled, ignored in the current environment, una
10
10
 
11
11
  ## `Chronos.notify_sync` returns false
12
12
 
13
- Check DNS, TLS certificates, credentials, HTTP status, proxy configuration, and timeout values. Version 0.3 retries only network errors, HTTP `408`, `429`, and `5xx` responses. Inspect `agent.diagnostics` when constructing an agent directly to see retry state, backlog usage, and the circuit state.
13
+ Check DNS, TLS certificates, credentials, HTTP status, proxy configuration, and timeout values. The resilience layer retries only network errors, HTTP `408`, `429`, and `5xx` responses. Inspect `agent.diagnostics` when constructing an agent directly to see retry state, backlog usage, and the circuit state.
14
+
15
+ ## Rack exception is not captured
16
+
17
+ Confirm that `Chronos.configure` runs before the middleware handles requests and that the middleware wraps the application component that raises. Version 0.5 captures exceptions raised by the initial downstream Rack call; an exception raised later while a server enumerates a streaming response body is outside this release. The original exception is always re-raised, so the server log should still show it.
18
+
19
+ ## Rails middleware or telemetry is missing
20
+
21
+ Require `chronos/rails` from the generated initializer and confirm it runs before requests. Automatic integration is disabled by default in Rails test and console; set `rails_capture_in_test` or `rails_capture_in_console` explicitly when validating those environments. `rails_enabled = false` disables all automatic Rails hooks. Active Job is subscribed only when its constant is available during installation.
22
+
23
+ ## Rails telemetry contains no SQL or cache key
24
+
25
+ This is intentional. Version 0.5 records SQL operation name/duration and cache operation/store/hit status without raw statements, binds, keys, or values. These omissions are privacy and cardinality boundaries, not capture failures.
26
+
27
+ ## Context appears missing
28
+
29
+ The legacy context store is thread-local. A new application-created thread does not inherit context. Establish a new `Chronos.with_context` scope inside that thread, and issue manual notification before the scope exits. For Rack capture, supply user and explicit parameters through the documented environment keys.
14
30
 
15
31
  ## Events disappear during shutdown
16
32
 
@@ -0,0 +1,52 @@
1
+ module Chronos
2
+ module Adapters
3
+ # Stores execution context in the current Ruby thread.
4
+ #
5
+ # @responsibility Isolate request context and restore nested scopes reliably.
6
+ # @motivation Legacy Ruby does not provide a portable fiber-local storage API.
7
+ # @limits Context does not propagate to new threads or across processes.
8
+ # @collaborators ContextStore port and Rack middleware.
9
+ # @thread_safety Each thread owns an independent value; one instance may be shared.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # store.with_context(:request_id => "r1") { store.get }
13
+ # @errors The previous value is restored in ensure even when the block raises.
14
+ # @performance Reads and writes are constant-time thread-local operations.
15
+ class ThreadLocalContextStore
16
+ def initialize
17
+ @key = "chronos_context_#{object_id}".freeze
18
+ end
19
+
20
+ def get
21
+ Thread.current[@key] || {}
22
+ end
23
+
24
+ def set(context)
25
+ raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
26
+
27
+ Thread.current[@key] = context
28
+ end
29
+
30
+ def clear
31
+ Thread.current[@key] = nil
32
+ nil
33
+ end
34
+
35
+ def with_context(context)
36
+ previous = Thread.current[@key]
37
+ set(merge_context(previous || {}, context))
38
+ yield
39
+ ensure
40
+ previous ? Thread.current[@key] = previous : clear
41
+ end
42
+
43
+ private
44
+
45
+ def merge_context(current, additional)
46
+ raise ArgumentError, "context must be a Hash" unless additional.is_a?(Hash)
47
+
48
+ current.merge(additional)
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/chronos/agent.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  module Chronos
2
2
  # Runtime composition root for the framework-independent Chronos agent.
3
3
  #
4
- # @responsibility Own transport, resilient delivery pipeline, and the capture use case.
4
+ # @responsibility Own delivery, capture, execution context, and breadcrumb collaborators.
5
5
  # @motivation Keep construction details outside the public module facade.
6
- # @limits It does not integrate with Rails, Rack, or job systems.
7
- # @collaborators Configuration snapshot, CaptureException, DeliveryPipeline, and NetHttpTransport.
6
+ # @limits It does not install Rack, Rails, or job integrations automatically.
7
+ # @collaborators CaptureException, DeliveryPipeline, ContextStore, and BreadcrumbBuffer.
8
8
  # @thread_safety Runtime collaborators synchronize mutable state.
9
9
  # @compatibility Ruby 2.2.10 through Ruby 2.6.
10
10
  # @example
@@ -19,6 +19,10 @@ module Chronos
19
19
  def initialize(config, options = {})
20
20
  @config = config
21
21
  @logger = options[:logger] || Internal::SafeLogger.new(config.logger)
22
+ @context_store = options[:context_store] || build_context_store(config.context_store)
23
+ unless Ports::ContextStore.compatible?(@context_store)
24
+ raise ArgumentError, "context store does not implement the Chronos context-store port"
25
+ end
22
26
  @transport = options[:transport] || Adapters::NetHttpTransport.new(config, @logger)
23
27
  unless Ports::Transport.compatible?(@transport)
24
28
  raise ArgumentError, "transport does not implement the Chronos transport port"
@@ -32,15 +36,56 @@ module Chronos
32
36
  @logger,
33
37
  pipeline_options
34
38
  )
35
- @capture = options[:capture] || Application::CaptureException.new(config, @delivery_pipeline, @logger)
39
+ initialize_capture(options)
36
40
  end
37
41
 
38
42
  def notify(exception, context = {})
39
- @capture.call(exception, context)
43
+ @capture.call(exception, context_for_capture(context))
40
44
  end
41
45
 
42
46
  def notify_sync(exception, context = {})
43
- @capture.call_sync(exception, context)
47
+ @capture.call_sync(exception, context_for_capture(context))
48
+ end
49
+
50
+ def with_context(context = {}, &block)
51
+ @context_store.with_context(context, &block)
52
+ end
53
+
54
+ def add_breadcrumb(attributes = {})
55
+ context = @context_store.get
56
+ buffer = context[:__chronos_breadcrumbs]
57
+ buffer ||= Core::BreadcrumbBuffer.new(@config.breadcrumb_capacity, @config.breadcrumb_max_bytes)
58
+ buffer.add(attributes)
59
+ @context_store.set(context.merge(:__chronos_breadcrumbs => buffer))
60
+ true
61
+ rescue StandardError => error
62
+ @logger.warn("Chronos breadcrumb failed: #{error.class}")
63
+ false
64
+ end
65
+
66
+ def record_event(event_type, payload = {}, context = {})
67
+ @telemetry.call(event_type, payload, telemetry_context(context))
68
+ end
69
+
70
+ def notify_once(exception, context = {})
71
+ execution = @context_store.get
72
+ captured = execution[:__chronos_captured_exceptions] || {}
73
+ keys = [[:object, exception.object_id], [:message, exception.message.to_s]]
74
+ return false if keys.any? { |key| captured[key] }
75
+
76
+ keys.each { |key| captured[key] = true }
77
+ @context_store.set(execution.merge(:__chronos_captured_exceptions => captured))
78
+ notify(exception, context)
79
+ rescue StandardError
80
+ false
81
+ end
82
+
83
+ def rails_integration_options(environment = nil, console = false)
84
+ current_environment = (environment || @config.environment).to_s
85
+ enabled = @config.rails_enabled
86
+ enabled = false if console && !@config.rails_capture_in_console
87
+ enabled = false if current_environment == "test" && !@config.rails_capture_in_test
88
+ {:enabled => enabled, :include_user_agent => @config.rails_capture_user_agent}
44
89
  end
45
90
 
46
91
  def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
@@ -61,5 +106,51 @@ module Chronos
61
106
  details = @delivery_pipeline.diagnostics
62
107
  details[:queue].merge(details)
63
108
  end
109
+
110
+ private
111
+
112
+ def initialize_capture(options)
113
+ @capture = options[:capture] || Application::CaptureException.new(@config, @delivery_pipeline, @logger)
114
+ @telemetry = options[:telemetry] || Application::CaptureTelemetry.new(@config, @delivery_pipeline, @logger)
115
+ end
116
+
117
+ def build_context_store(strategy)
118
+ return Adapters::ThreadLocalContextStore.new if strategy == :thread_local
119
+
120
+ strategy
121
+ end
122
+
123
+ def context_for_capture(additional)
124
+ merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
125
+ merged.delete(:__chronos_captured_exceptions)
126
+ merged.delete("__chronos_captured_exceptions")
127
+ buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
128
+ if buffer.respond_to?(:to_a)
129
+ merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
130
+ end
131
+ merged
132
+ rescue StandardError
133
+ context_hash(additional)
134
+ end
135
+
136
+ def telemetry_context(additional)
137
+ merged = context_for_capture(additional)
138
+ context = context_hash(merged.delete(:context) || merged.delete("context"))
139
+ parameters = merged.delete(:parameters) || merged.delete("parameters")
140
+ user = merged.delete(:user) || merged.delete("user")
141
+ context["parameters"] = parameters if parameters.is_a?(Hash) && !parameters.empty?
142
+ context["user"] = user if user.is_a?(Hash) && !user.empty?
143
+ context.merge(merged)
144
+ end
145
+
146
+ def deep_merge(left, right)
147
+ left.merge(right) do |_key, old_value, new_value|
148
+ old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
149
+ end
150
+ end
151
+
152
+ def context_hash(value)
153
+ value.is_a?(Hash) ? value : {}
154
+ end
64
155
  end
65
156
  end
@@ -2,9 +2,9 @@ module Chronos
2
2
  module Application
3
3
  # Orchestrates exception normalization, serialization, queueing, and delivery.
4
4
  #
5
- # @responsibility Execute the version 0.3 exception capture pipeline.
5
+ # @responsibility Execute the exception capture pipeline for manual and integration callers.
6
6
  # @motivation Keep the public facade and transport adapters free of use-case logic.
7
- # @limits It does not implement framework hooks or automatic capture.
7
+ # @limits Framework hooks remain separate integration adapters.
8
8
  # @collaborators NoticeBuilder, PayloadSerializer, and DeliveryPipeline.
9
9
  # @thread_safety Collaborators are immutable or synchronized; calls may run concurrently.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
@@ -0,0 +1,37 @@
1
+ module Chronos
2
+ module Application
3
+ # Coordinates bounded non-exception telemetry capture.
4
+ #
5
+ # @responsibility Build, sanitize, serialize, and enqueue integration events.
6
+ # @motivation Keep Rails notification policy outside transport and domain objects.
7
+ # @limits It handles request, query, job, and cache events only.
8
+ # @collaborators TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
9
+ # @thread_safety Calls own event state and may execute concurrently.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
11
+ # @example
12
+ # capture.call("query", {"duration_ms" => 3.2})
13
+ # @errors Internal StandardError failures are contained and logged.
14
+ # @performance Local normalization is bounded before asynchronous queue insertion.
15
+ class CaptureTelemetry
16
+ def initialize(config, delivery_pipeline, logger = nil, options = {})
17
+ @config = config
18
+ @delivery_pipeline = delivery_pipeline
19
+ @logger = logger || Internal::SafeLogger.new(config.logger)
20
+ @serializer = options[:serializer] || Core::TelemetrySerializer.new(
21
+ config, nil, :max_payload_size => proc { @delivery_pipeline.max_payload_size }
22
+ )
23
+ end
24
+
25
+ def call(event_type, payload = {}, context = {})
26
+ return false unless @config.enabled_for_environment?
27
+ return false unless @delivery_pipeline.capture_allowed?(event_type)
28
+
29
+ event = Core::TelemetryEvent.new(event_type, payload, context)
30
+ @delivery_pipeline.enqueue(@serializer.call(event))
31
+ rescue StandardError => error
32
+ @logger.warn("Chronos telemetry capture failed: #{error.class}")
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
@@ -13,7 +13,7 @@ module Chronos
13
13
  # @errors Invalid documents are ignored and return false.
14
14
  # @performance Validation is bounded by configured document and list limits.
15
15
  class RemoteConfiguration
16
- SUPPORTED_EVENT_TYPES = ["exception"].freeze
16
+ SUPPORTED_EVENT_TYPES = ["exception", "request", "query", "job", "cache"].freeze
17
17
  MAX_IGNORED_FINGERPRINTS = 100
18
18
  MAX_FINGERPRINT_BYTES = 256
19
19
  MIN_PAYLOAD_SIZE = 256
@@ -0,0 +1,53 @@
1
+ module Chronos
2
+ # Immutable configuration shared by all runtime components.
3
+ #
4
+ # @responsibility Expose validated settings without mutable containers.
5
+ # @motivation Keep capture behavior stable while multiple threads run.
6
+ # @limits It cannot be edited after creation.
7
+ # @collaborators Configuration and runtime services.
8
+ # @thread_safety Safe to share between threads after construction.
9
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
10
+ # @example
11
+ # snapshot.enabled_for_environment? #=> true
12
+ # @errors Construction occurs only after Configuration validation.
13
+ # @performance Deep freezing is paid once during configuration.
14
+ Configuration::Snapshot = Class.new do
15
+ attr_reader(*Configuration::ATTRIBUTES)
16
+
17
+ def initialize(values)
18
+ Configuration::ATTRIBUTES.each do |attribute|
19
+ value = values[attribute]
20
+ deep_freeze(value)
21
+ instance_variable_set("@#{attribute}", value)
22
+ end
23
+ freeze
24
+ end
25
+
26
+ def enabled_for_environment?
27
+ enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
28
+ end
29
+
30
+ private # rubocop:disable Layout/AccessModifierIndentation
31
+
32
+ def deep_freeze(value)
33
+ return value if value.respond_to?(:call) || context_store?(value)
34
+
35
+ case value
36
+ when Hash
37
+ value.each do |key, child|
38
+ deep_freeze(key)
39
+ deep_freeze(child)
40
+ end
41
+ when Array
42
+ value.each { |child| deep_freeze(child) }
43
+ end
44
+ value.freeze
45
+ end
46
+
47
+ def context_store?(value)
48
+ Configuration::CONTEXT_STORE_METHODS.all? { |method_name| value.respond_to?(method_name) }
49
+ rescue StandardError
50
+ false
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,122 @@
1
+ module Chronos
2
+ module Internal
3
+ # Shared validation predicates for mutable Chronos configuration.
4
+ #
5
+ # @responsibility Validate resilience, privacy, context, Rails, and endpoint settings.
6
+ # @motivation Keep Configuration focused on option storage and snapshot creation.
7
+ # @limits It is mixed into Configuration and is not a public extension API.
8
+ # @collaborators Chronos::Configuration values.
9
+ # @thread_safety Validation reads one configuration instance without shared state.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @errors Invalid values become messages; malformed URLs are contained.
12
+ module ConfigurationValidation
13
+ private
14
+
15
+ def resilience_errors
16
+ retry_errors + backlog_and_circuit_errors + remote_policy_errors
17
+ end
18
+
19
+ def retry_errors
20
+ errors = []
21
+ errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
22
+ errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
23
+ errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
24
+ if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
25
+ retry_max_interval < retry_base_interval
26
+ errors << "retry_max_interval must be greater than or equal to retry_base_interval"
27
+ end
28
+ errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
29
+ errors
30
+ end
31
+
32
+ def backlog_and_circuit_errors
33
+ errors = []
34
+ errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
35
+ unless positive_integer?(circuit_failure_threshold)
36
+ errors << "circuit_failure_threshold must be a positive integer"
37
+ end
38
+ errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
39
+ errors
40
+ end
41
+
42
+ def remote_policy_errors
43
+ errors = []
44
+ errors << "remote_configuration must be true or false" unless [true, false].include?(remote_configuration)
45
+ errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
46
+ errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
47
+ unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
48
+ errors << "enabled_event_types must contain only String values"
49
+ end
50
+ errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
51
+ errors
52
+ end
53
+
54
+ def privacy_errors
55
+ errors = []
56
+ errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
57
+ errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
58
+ errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
59
+ errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
60
+ errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
61
+ errors.concat(matcher_errors("hash_keys", hash_keys))
62
+ errors.concat(filter_errors)
63
+ errors.concat(anonymization_errors)
64
+ errors
65
+ end
66
+
67
+ def context_errors
68
+ errors = []
69
+ unless context_store == :thread_local || compatible_context_store?
70
+ errors << "context_store must be :thread_local or implement get, set, clear, and with_context"
71
+ end
72
+ errors << "breadcrumb_capacity must be a positive integer" unless positive_integer?(breadcrumb_capacity)
73
+ unless breadcrumb_max_bytes.is_a?(Integer) && breadcrumb_max_bytes >= 128
74
+ errors << "breadcrumb_max_bytes must be an integer greater than or equal to 128"
75
+ end
76
+ rails_boolean_errors.each { |error| errors << error }
77
+ errors
78
+ end
79
+
80
+ def compatible_context_store?
81
+ self.class::CONTEXT_STORE_METHODS.all? { |method_name| context_store.respond_to?(method_name) }
82
+ end
83
+
84
+ def rails_boolean_errors
85
+ names = [:rails_enabled, :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent]
86
+ names.reject { |name| [true, false].include?(public_send(name)) }.map do |name|
87
+ "#{name} must be true or false"
88
+ end
89
+ end
90
+
91
+ def filter_errors
92
+ return ["filters must be an array"] unless filters.is_a?(Array)
93
+ return [] if filters.all? { |filter| filter.respond_to?(:call) }
94
+
95
+ ["filters must contain only callable objects"]
96
+ end
97
+
98
+ def anonymization_errors
99
+ [true, false].include?(anonymize_ip) ? [] : ["anonymize_ip must be true or false"]
100
+ end
101
+
102
+ def matcher_errors(name, values)
103
+ return [] unless values.is_a?(Array)
104
+ return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
105
+
106
+ ["#{name} must contain only String, Symbol, or Regexp values"]
107
+ end
108
+
109
+ def host_errors
110
+ return ["host is required"] if blank?(host)
111
+
112
+ uri = URI.parse(host.to_s)
113
+ return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
114
+ return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
115
+
116
+ []
117
+ rescue URI::InvalidURIError
118
+ ["host must be a valid URL"]
119
+ end
120
+ end
121
+ end
122
+ end