chronos-ruby 0.4.0.pre.1 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +33 -9
- data/contracts/event-v1.schema.json +25 -11
- data/contracts/sidekiq-job-v1.schema.json +21 -0
- data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
- data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
- data/docs/architecture.md +12 -1
- data/docs/compatibility.md +12 -6
- data/docs/configuration.md +8 -2
- data/docs/data-collected.md +14 -2
- data/docs/modules/rails-legacy.md +60 -0
- data/docs/modules/sidekiq-legacy.md +23 -0
- data/docs/modules/telemetry-events.md +9 -0
- data/docs/performance.md +27 -3
- data/docs/privacy-lgpd.md +12 -3
- data/docs/troubleshooting.md +17 -1
- data/lib/chronos/agent.rb +58 -1
- data/lib/chronos/application/capture_telemetry.rb +37 -0
- data/lib/chronos/application/remote_configuration.rb +1 -1
- data/lib/chronos/configuration/snapshot.rb +53 -0
- data/lib/chronos/configuration/validation.rb +122 -0
- data/lib/chronos/configuration.rb +15 -153
- data/lib/chronos/core/telemetry_event.rb +106 -0
- data/lib/chronos/integrations/job_payload.rb +72 -0
- data/lib/chronos/integrations/rack/middleware.rb +5 -1
- data/lib/chronos/integrations/sidekiq.rb +258 -0
- data/lib/chronos/integrations.rb +1 -1
- data/lib/chronos/internal/worker_pool.rb +19 -2
- data/lib/chronos/rails/installer.rb +70 -0
- data/lib/chronos/rails/notifications_subscriber.rb +203 -0
- data/lib/chronos/rails/railtie.rb +21 -0
- data/lib/chronos/rails.rb +16 -0
- data/lib/chronos/sidekiq.rb +12 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +34 -1
- data/lib/generators/chronos/install/install_generator.rb +25 -0
- data/lib/generators/chronos/install/templates/chronos.rb +20 -0
- metadata +21 -2
data/lib/chronos/agent.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Chronos
|
|
|
36
36
|
@logger,
|
|
37
37
|
pipeline_options
|
|
38
38
|
)
|
|
39
|
-
|
|
39
|
+
initialize_capture(options)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def notify(exception, context = {})
|
|
@@ -63,6 +63,46 @@ module Chronos
|
|
|
63
63
|
false
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
def record_event(event_type, payload = {}, context = {})
|
|
67
|
+
@telemetry.call(event_type, payload, telemetry_context(context))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the correlation subset safe for an integration-owned process boundary.
|
|
71
|
+
def propagation_context
|
|
72
|
+
current = context_hash(@context_store.get)
|
|
73
|
+
nested = context_hash(current[:context] || current["context"])
|
|
74
|
+
request = context_hash(nested["request"] || nested[:request])
|
|
75
|
+
values = {
|
|
76
|
+
"trace_id" => nested["trace_id"] || nested[:trace_id],
|
|
77
|
+
"request_id" => nested["request_id"] || nested[:request_id] ||
|
|
78
|
+
request["request_id"] || request[:request_id]
|
|
79
|
+
}
|
|
80
|
+
values.delete_if { |_key, value| value.to_s.empty? }
|
|
81
|
+
rescue StandardError
|
|
82
|
+
{}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def notify_once(exception, context = {})
|
|
86
|
+
execution = @context_store.get
|
|
87
|
+
captured = execution[:__chronos_captured_exceptions] || {}
|
|
88
|
+
keys = [[:object, exception.object_id], [:message, exception.message.to_s]]
|
|
89
|
+
return false if keys.any? { |key| captured[key] }
|
|
90
|
+
|
|
91
|
+
keys.each { |key| captured[key] = true }
|
|
92
|
+
@context_store.set(execution.merge(:__chronos_captured_exceptions => captured))
|
|
93
|
+
notify(exception, context)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rails_integration_options(environment = nil, console = false)
|
|
99
|
+
current_environment = (environment || @config.environment).to_s
|
|
100
|
+
enabled = @config.rails_enabled
|
|
101
|
+
enabled = false if console && !@config.rails_capture_in_console
|
|
102
|
+
enabled = false if current_environment == "test" && !@config.rails_capture_in_test
|
|
103
|
+
{:enabled => enabled, :include_user_agent => @config.rails_capture_user_agent}
|
|
104
|
+
end
|
|
105
|
+
|
|
66
106
|
def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
67
107
|
@delivery_pipeline.flush(timeout)
|
|
68
108
|
rescue StandardError => error
|
|
@@ -84,6 +124,11 @@ module Chronos
|
|
|
84
124
|
|
|
85
125
|
private
|
|
86
126
|
|
|
127
|
+
def initialize_capture(options)
|
|
128
|
+
@capture = options[:capture] || Application::CaptureException.new(@config, @delivery_pipeline, @logger)
|
|
129
|
+
@telemetry = options[:telemetry] || Application::CaptureTelemetry.new(@config, @delivery_pipeline, @logger)
|
|
130
|
+
end
|
|
131
|
+
|
|
87
132
|
def build_context_store(strategy)
|
|
88
133
|
return Adapters::ThreadLocalContextStore.new if strategy == :thread_local
|
|
89
134
|
|
|
@@ -92,6 +137,8 @@ module Chronos
|
|
|
92
137
|
|
|
93
138
|
def context_for_capture(additional)
|
|
94
139
|
merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
|
|
140
|
+
merged.delete(:__chronos_captured_exceptions)
|
|
141
|
+
merged.delete("__chronos_captured_exceptions")
|
|
95
142
|
buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
|
|
96
143
|
if buffer.respond_to?(:to_a)
|
|
97
144
|
merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
|
|
@@ -101,6 +148,16 @@ module Chronos
|
|
|
101
148
|
context_hash(additional)
|
|
102
149
|
end
|
|
103
150
|
|
|
151
|
+
def telemetry_context(additional)
|
|
152
|
+
merged = context_for_capture(additional)
|
|
153
|
+
context = context_hash(merged.delete(:context) || merged.delete("context"))
|
|
154
|
+
parameters = merged.delete(:parameters) || merged.delete("parameters")
|
|
155
|
+
user = merged.delete(:user) || merged.delete("user")
|
|
156
|
+
context["parameters"] = parameters if parameters.is_a?(Hash) && !parameters.empty?
|
|
157
|
+
context["user"] = user if user.is_a?(Hash) && !user.empty?
|
|
158
|
+
context.merge(merged)
|
|
159
|
+
end
|
|
160
|
+
|
|
104
161
|
def deep_merge(left, right)
|
|
105
162
|
left.merge(right) do |_key, old_value, new_value|
|
|
106
163
|
old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
|
|
@@ -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
|
|
@@ -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,6 +20,7 @@ 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
|
|
@@ -37,7 +39,8 @@ module Chronos
|
|
|
37
39
|
:backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
|
|
38
40
|
:remote_configuration, :remote_config_max_bytes, :sampling_rate,
|
|
39
41
|
:enabled_event_types, :max_remote_send_interval, :context_store,
|
|
40
|
-
:breadcrumb_capacity, :breadcrumb_max_bytes
|
|
42
|
+
:breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
|
|
43
|
+
:rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
|
|
41
44
|
].freeze
|
|
42
45
|
|
|
43
46
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -46,6 +49,7 @@ module Chronos
|
|
|
46
49
|
initialize_core_defaults
|
|
47
50
|
initialize_privacy_defaults
|
|
48
51
|
initialize_resilience_defaults
|
|
52
|
+
initialize_rails_defaults
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def snapshot
|
|
@@ -105,6 +109,13 @@ module Chronos
|
|
|
105
109
|
@breadcrumb_max_bytes = 2048
|
|
106
110
|
end
|
|
107
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
|
|
117
|
+
end
|
|
118
|
+
|
|
108
119
|
def initialize_privacy_defaults
|
|
109
120
|
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
110
121
|
@allowlist_keys = []
|
|
@@ -124,55 +135,10 @@ module Chronos
|
|
|
124
135
|
@remote_configuration = true
|
|
125
136
|
@remote_config_max_bytes = 4096
|
|
126
137
|
@sampling_rate = 1.0
|
|
127
|
-
@enabled_event_types = ["exception"]
|
|
138
|
+
@enabled_event_types = ["exception", "request", "query", "job", "cache"]
|
|
128
139
|
@max_remote_send_interval = 60.0
|
|
129
140
|
end
|
|
130
141
|
|
|
131
|
-
def resilience_errors
|
|
132
|
-
errors = []
|
|
133
|
-
errors.concat(retry_errors)
|
|
134
|
-
errors.concat(backlog_and_circuit_errors)
|
|
135
|
-
errors.concat(remote_policy_errors)
|
|
136
|
-
errors
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def retry_errors
|
|
140
|
-
errors = []
|
|
141
|
-
errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
|
|
142
|
-
errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
|
|
143
|
-
errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
|
|
144
|
-
if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
|
|
145
|
-
retry_max_interval < retry_base_interval
|
|
146
|
-
errors << "retry_max_interval must be greater than or equal to retry_base_interval"
|
|
147
|
-
end
|
|
148
|
-
errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
|
|
149
|
-
errors
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def backlog_and_circuit_errors
|
|
153
|
-
errors = []
|
|
154
|
-
errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
|
|
155
|
-
unless positive_integer?(circuit_failure_threshold)
|
|
156
|
-
errors << "circuit_failure_threshold must be a positive integer"
|
|
157
|
-
end
|
|
158
|
-
errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
|
|
159
|
-
errors
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def remote_policy_errors
|
|
163
|
-
errors = []
|
|
164
|
-
unless [true, false].include?(remote_configuration)
|
|
165
|
-
errors << "remote_configuration must be true or false"
|
|
166
|
-
end
|
|
167
|
-
errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
|
|
168
|
-
errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
|
|
169
|
-
unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
|
|
170
|
-
errors << "enabled_event_types must contain only String values"
|
|
171
|
-
end
|
|
172
|
-
errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
|
|
173
|
-
errors
|
|
174
|
-
end
|
|
175
|
-
|
|
176
142
|
def to_hash
|
|
177
143
|
ATTRIBUTES.each_with_object({}) do |attribute, values|
|
|
178
144
|
value = public_send(attribute)
|
|
@@ -180,53 +146,6 @@ module Chronos
|
|
|
180
146
|
end
|
|
181
147
|
end
|
|
182
148
|
|
|
183
|
-
def privacy_errors
|
|
184
|
-
errors = []
|
|
185
|
-
errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
|
|
186
|
-
errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
|
|
187
|
-
errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
|
|
188
|
-
errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
|
|
189
|
-
errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
|
|
190
|
-
errors.concat(matcher_errors("hash_keys", hash_keys))
|
|
191
|
-
errors.concat(filter_errors)
|
|
192
|
-
errors.concat(anonymization_errors)
|
|
193
|
-
errors
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def context_errors
|
|
197
|
-
errors = []
|
|
198
|
-
unless context_store == :thread_local || CONTEXT_STORE_METHODS.all? do |method_name|
|
|
199
|
-
context_store.respond_to?(method_name)
|
|
200
|
-
end
|
|
201
|
-
errors << "context_store must be :thread_local or implement get, set, clear, and with_context"
|
|
202
|
-
end
|
|
203
|
-
errors << "breadcrumb_capacity must be a positive integer" unless positive_integer?(breadcrumb_capacity)
|
|
204
|
-
unless breadcrumb_max_bytes.is_a?(Integer) && breadcrumb_max_bytes >= 128
|
|
205
|
-
errors << "breadcrumb_max_bytes must be an integer greater than or equal to 128"
|
|
206
|
-
end
|
|
207
|
-
errors
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def filter_errors
|
|
211
|
-
return ["filters must be an array"] unless filters.is_a?(Array)
|
|
212
|
-
return [] if filters.all? { |filter| filter.respond_to?(:call) }
|
|
213
|
-
|
|
214
|
-
["filters must contain only callable objects"]
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def anonymization_errors
|
|
218
|
-
return [] if anonymize_ip == true || anonymize_ip == false
|
|
219
|
-
|
|
220
|
-
["anonymize_ip must be true or false"]
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def matcher_errors(name, values)
|
|
224
|
-
return [] unless values.is_a?(Array)
|
|
225
|
-
return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
|
|
226
|
-
|
|
227
|
-
["#{name} must contain only String, Symbol, or Regexp values"]
|
|
228
|
-
end
|
|
229
|
-
|
|
230
149
|
def deep_copy(value)
|
|
231
150
|
case value
|
|
232
151
|
when Hash
|
|
@@ -240,18 +159,6 @@ module Chronos
|
|
|
240
159
|
end
|
|
241
160
|
end
|
|
242
161
|
|
|
243
|
-
def host_errors
|
|
244
|
-
return ["host is required"] if blank?(host)
|
|
245
|
-
|
|
246
|
-
uri = URI.parse(host.to_s)
|
|
247
|
-
return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
|
|
248
|
-
return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
|
|
249
|
-
|
|
250
|
-
[]
|
|
251
|
-
rescue URI::InvalidURIError
|
|
252
|
-
["host must be a valid URL"]
|
|
253
|
-
end
|
|
254
|
-
|
|
255
162
|
def blank?(value)
|
|
256
163
|
value.nil? || value.to_s.strip.empty?
|
|
257
164
|
end
|
|
@@ -271,52 +178,7 @@ module Chronos
|
|
|
271
178
|
def rate?(value)
|
|
272
179
|
value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
273
180
|
end
|
|
274
|
-
|
|
275
|
-
# Immutable configuration shared by all runtime components.
|
|
276
|
-
#
|
|
277
|
-
# @responsibility Expose validated settings without mutable containers.
|
|
278
|
-
# @motivation Keep capture behavior stable while multiple threads run.
|
|
279
|
-
# @limits It cannot be edited after creation.
|
|
280
|
-
# @thread_safety Safe to share between threads after construction.
|
|
281
|
-
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
282
|
-
class Snapshot
|
|
283
|
-
attr_reader(*ATTRIBUTES)
|
|
284
|
-
|
|
285
|
-
def initialize(values)
|
|
286
|
-
ATTRIBUTES.each do |attribute|
|
|
287
|
-
value = values[attribute]
|
|
288
|
-
deep_freeze(value)
|
|
289
|
-
instance_variable_set("@#{attribute}", value)
|
|
290
|
-
end
|
|
291
|
-
freeze
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def enabled_for_environment?
|
|
295
|
-
enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
private
|
|
299
|
-
|
|
300
|
-
def deep_freeze(value)
|
|
301
|
-
return value if value.respond_to?(:call) || context_store?(value)
|
|
302
|
-
|
|
303
|
-
case value
|
|
304
|
-
when Hash
|
|
305
|
-
value.each do |key, child|
|
|
306
|
-
deep_freeze(key)
|
|
307
|
-
deep_freeze(child)
|
|
308
|
-
end
|
|
309
|
-
when Array
|
|
310
|
-
value.each { |child| deep_freeze(child) }
|
|
311
|
-
end
|
|
312
|
-
value.freeze
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def context_store?(value)
|
|
316
|
-
CONTEXT_STORE_METHODS.all? { |method_name| value.respond_to?(method_name) }
|
|
317
|
-
rescue StandardError
|
|
318
|
-
false
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
181
|
end
|
|
322
182
|
end
|
|
183
|
+
|
|
184
|
+
require "chronos/configuration/snapshot"
|
|
@@ -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
|