chronos-ruby 0.1.0.pre.2

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +23 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +251 -0
  7. data/SECURITY.md +9 -0
  8. data/contracts/event-v1.schema.json +51 -0
  9. data/docs/adr/ADR-001-core-and-integrations.md +25 -0
  10. data/docs/adr/ADR-002-version-lines.md +25 -0
  11. data/docs/adr/ADR-003-net-http-legacy-transport.md +25 -0
  12. data/docs/adr/ADR-004-bounded-queue.md +25 -0
  13. data/docs/adr/ADR-005-sanitize-before-backlog.md +25 -0
  14. data/docs/adr/ADR-006-versioned-event-contract.md +25 -0
  15. data/docs/architecture.md +31 -0
  16. data/docs/compatibility.md +21 -0
  17. data/docs/configuration.md +40 -0
  18. data/docs/data-collected.md +20 -0
  19. data/docs/examples/plain-ruby.md +12 -0
  20. data/docs/modules/async-queue.md +17 -0
  21. data/docs/modules/configuration.md +9 -0
  22. data/docs/modules/notice-pipeline.md +20 -0
  23. data/docs/modules/transport.md +9 -0
  24. data/docs/performance.md +16 -0
  25. data/docs/privacy-lgpd.md +16 -0
  26. data/docs/troubleshooting.md +25 -0
  27. data/lib/chronos/adapters/net_http_transport.rb +132 -0
  28. data/lib/chronos/adapters.rb +10 -0
  29. data/lib/chronos/agent.rb +57 -0
  30. data/lib/chronos/application/capture_exception.rb +61 -0
  31. data/lib/chronos/application.rb +10 -0
  32. data/lib/chronos/configuration.rb +139 -0
  33. data/lib/chronos/core/backtrace_parser.rb +74 -0
  34. data/lib/chronos/core/exception_cause_collector.rb +63 -0
  35. data/lib/chronos/core/notice.rb +53 -0
  36. data/lib/chronos/core/notice_builder.rb +83 -0
  37. data/lib/chronos/core/payload_serializer.rb +165 -0
  38. data/lib/chronos/core/runtime_info.rb +42 -0
  39. data/lib/chronos/core.rb +10 -0
  40. data/lib/chronos/errors.rb +24 -0
  41. data/lib/chronos/internal/bounded_queue.rb +79 -0
  42. data/lib/chronos/internal/safe_logger.rb +55 -0
  43. data/lib/chronos/internal/worker_pool.rb +155 -0
  44. data/lib/chronos/internal.rb +10 -0
  45. data/lib/chronos/ports/transport.rb +45 -0
  46. data/lib/chronos/ports.rb +10 -0
  47. data/lib/chronos/ruby/version.rb +1 -0
  48. data/lib/chronos/ruby.rb +1 -0
  49. data/lib/chronos/version.rb +4 -0
  50. data/lib/chronos.rb +98 -0
  51. metadata +156 -0
@@ -0,0 +1,74 @@
1
+ module Chronos
2
+ module Core
3
+ # Parses CRuby and common JRuby backtrace lines without rejecting unknown input.
4
+ #
5
+ # @responsibility Convert backtrace strings into bounded structured frames.
6
+ # @motivation Give the SaaS stable fields for display and grouping.
7
+ # @limits It does not read source files or collect code hunks.
8
+ # @collaborators NoticeBuilder.
9
+ # @thread_safety Stateless and safe to reuse between threads.
10
+ # @compatibility CRuby 2.2.10 through 2.6 and common JRuby line formats.
11
+ # @example
12
+ # parser.call(["app/job.rb:12:in `call'"])
13
+ # @performance Linear in the number of frames, capped by max_frames.
14
+ class BacktraceParser
15
+ DEFAULT_MAX_FRAMES = 200
16
+
17
+ def initialize(root_directory, max_frames = DEFAULT_MAX_FRAMES)
18
+ @root_directory = expand_root(root_directory)
19
+ @max_frames = max_frames
20
+ end
21
+
22
+ def call(lines)
23
+ Array(lines).first(@max_frames).map { |line| parse(line) }
24
+ end
25
+
26
+ private
27
+
28
+ def parse(line)
29
+ text = safe_string(line)
30
+ match = text.match(/\A(.+?):(\d+)(?::in [`'](.*)['`])?\z/)
31
+ return unknown_frame(text) unless match
32
+
33
+ file = normalize_file(match[1])
34
+ {
35
+ "file" => file,
36
+ "line" => match[2].to_i,
37
+ "function" => match[3],
38
+ "in_app" => in_app?(file)
39
+ }
40
+ rescue StandardError
41
+ unknown_frame(safe_string(line))
42
+ end
43
+
44
+ def unknown_frame(line)
45
+ {"file" => line, "line" => nil, "function" => nil, "in_app" => false}
46
+ end
47
+
48
+ def normalize_file(file)
49
+ return file unless @root_directory && file.start_with?(@root_directory + File::SEPARATOR)
50
+
51
+ file[(@root_directory.length + 1)..-1]
52
+ end
53
+
54
+ def in_app?(file)
55
+ return false unless @root_directory
56
+ return true unless file.start_with?(File::SEPARATOR)
57
+
58
+ file.start_with?(@root_directory + File::SEPARATOR)
59
+ end
60
+
61
+ def expand_root(root)
62
+ root && File.expand_path(root.to_s)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ def safe_string(value)
68
+ value.to_s
69
+ rescue StandardError
70
+ "<unreadable backtrace>"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ module Chronos
2
+ module Core
3
+ # Collects a bounded exception cause chain with cycle protection.
4
+ #
5
+ # @responsibility Normalize nested Exception#cause values.
6
+ # @motivation Preserve root-cause information without unbounded traversal.
7
+ # @limits It records class and message only; each cause backtrace is handled elsewhere.
8
+ # @collaborators NoticeBuilder.
9
+ # @thread_safety Uses only call-local state.
10
+ # @compatibility Feature-detects Exception#cause for legacy runtimes.
11
+ # @example
12
+ # collector.call(exception)
13
+ # @performance Constant memory bounded by max_depth.
14
+ class ExceptionCauseCollector
15
+ DEFAULT_MAX_DEPTH = 10
16
+
17
+ def initialize(max_depth = DEFAULT_MAX_DEPTH)
18
+ @max_depth = max_depth
19
+ end
20
+
21
+ def call(exception)
22
+ causes = []
23
+ seen = {}
24
+ current = exception
25
+
26
+ @max_depth.times do
27
+ break unless current.respond_to?(:cause)
28
+ current = safe_cause(current)
29
+ break unless current
30
+ break if seen[current.object_id]
31
+
32
+ seen[current.object_id] = true
33
+ causes << {
34
+ "class" => safe_class_name(current),
35
+ "message" => safe_message(current)
36
+ }
37
+ end
38
+
39
+ causes
40
+ end
41
+
42
+ private
43
+
44
+ def safe_cause(exception)
45
+ exception.cause
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def safe_class_name(exception)
51
+ exception.class.name.to_s
52
+ rescue StandardError
53
+ "Exception"
54
+ end
55
+
56
+ def safe_message(exception)
57
+ exception.message.to_s
58
+ rescue StandardError
59
+ "<unreadable exception message>"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,53 @@
1
+ module Chronos
2
+ module Core
3
+ # Immutable value object containing a normalized Ruby exception.
4
+ #
5
+ # @responsibility Carry exception and diagnostic context through the pipeline.
6
+ # @motivation Keep transport details separate from exception normalization.
7
+ # @limits It does not sanitize, serialize, enqueue, or send itself.
8
+ # @collaborators NoticeBuilder and PayloadSerializer.
9
+ # @thread_safety Immutable after construction and safe to share.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
11
+ # @example
12
+ # notice.exception_class #=> "RuntimeError"
13
+ # @performance Construction is linear in backtrace and context size.
14
+ class Notice
15
+ ATTRIBUTES = [
16
+ :event_id, :exception_class, :message, :backtrace, :causes,
17
+ :severity, :timestamp, :context, :parameters, :session, :user,
18
+ :environment, :runtime, :versions, :host, :process, :thread,
19
+ :tags, :fingerprint
20
+ ].freeze
21
+
22
+ attr_reader(*ATTRIBUTES)
23
+
24
+ def initialize(attributes)
25
+ ATTRIBUTES.each do |attribute|
26
+ instance_variable_set("@#{attribute}", deep_freeze(attributes[attribute]))
27
+ end
28
+ freeze
29
+ end
30
+
31
+ def to_h
32
+ ATTRIBUTES.each_with_object({}) do |attribute, result|
33
+ result[attribute] = public_send(attribute)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def deep_freeze(value)
40
+ case value
41
+ when Hash
42
+ value.each do |key, child|
43
+ deep_freeze(key)
44
+ deep_freeze(child)
45
+ end
46
+ when Array
47
+ value.each { |child| deep_freeze(child) }
48
+ end
49
+ value.freeze
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,83 @@
1
+ require "securerandom"
2
+ require "time"
3
+
4
+ module Chronos
5
+ module Core
6
+ # Normalizes an Exception and caller context into a Notice.
7
+ #
8
+ # @responsibility Coordinate backtrace, causes, runtime, and caller-provided fields.
9
+ # @motivation Isolate Ruby runtime behavior from delivery code.
10
+ # @limits It does not serialize, enqueue, or perform HTTP.
11
+ # @collaborators BacktraceParser, ExceptionCauseCollector, RuntimeInfo, and Notice.
12
+ # @thread_safety Collaborators are stateless and the builder keeps no call state.
13
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
14
+ # @example
15
+ # notice = builder.call(RuntimeError.new("failed"), :tags => ["billing"])
16
+ # @performance Linear in backtrace and supplied context size.
17
+ class NoticeBuilder
18
+ def initialize(config, clock = nil)
19
+ @config = config
20
+ @clock = clock || proc { Time.now }
21
+ @backtrace_parser = BacktraceParser.new(config.root_directory)
22
+ @cause_collector = ExceptionCauseCollector.new
23
+ @runtime_info = RuntimeInfo.new
24
+ end
25
+
26
+ def call(exception, context = {})
27
+ raise ArgumentError, "exception must be an Exception" unless exception.is_a?(Exception)
28
+
29
+ context = {} unless context.is_a?(Hash)
30
+ runtime = @runtime_info.call
31
+ Notice.new(
32
+ :event_id => SecureRandom.uuid,
33
+ :exception_class => safe_class_name(exception),
34
+ :message => safe_message(exception),
35
+ :backtrace => @backtrace_parser.call(exception.backtrace),
36
+ :causes => @cause_collector.call(exception),
37
+ :severity => value(context, :severity) || "error",
38
+ :timestamp => @clock.call.utc.iso8601(6),
39
+ :context => value(context, :context) || context_without_reserved_keys(context),
40
+ :parameters => value(context, :parameters) || {},
41
+ :session => value(context, :session) || {},
42
+ :user => value(context, :user) || {},
43
+ :environment => @config.environment,
44
+ :runtime => runtime[:runtime],
45
+ :versions => {"agent" => Chronos::VERSION, "application" => @config.app_version},
46
+ :host => runtime[:host],
47
+ :process => runtime[:process],
48
+ :thread => runtime[:thread],
49
+ :tags => Array(value(context, :tags)),
50
+ :fingerprint => value(context, :fingerprint)
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ RESERVED_KEYS = [:severity, :context, :parameters, :session, :user, :tags, :fingerprint].freeze
57
+
58
+ def value(hash, key)
59
+ hash.key?(key) ? hash[key] : hash[key.to_s]
60
+ end
61
+
62
+ def context_without_reserved_keys(context)
63
+ context.each_with_object({}) do |(key, child), result|
64
+ result[key] = child unless RESERVED_KEYS.include?(key.to_sym)
65
+ end
66
+ rescue StandardError
67
+ {}
68
+ end
69
+
70
+ def safe_class_name(exception)
71
+ exception.class.name.to_s
72
+ rescue StandardError
73
+ "Exception"
74
+ end
75
+
76
+ def safe_message(exception)
77
+ exception.message.to_s
78
+ rescue StandardError
79
+ "<unreadable exception message>"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,165 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Chronos
5
+ module Core
6
+ # Serialized event ready for transport.
7
+ #
8
+ # @responsibility Keep an event ID next to its JSON body.
9
+ # @motivation Allow transports to set idempotency headers without reparsing JSON.
10
+ # @limits It contains no retry or HTTP behavior.
11
+ # @thread_safety Immutable after construction.
12
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
13
+ class SerializedEvent
14
+ attr_reader :event_id, :body
15
+
16
+ def initialize(event_id, body)
17
+ @event_id = event_id.to_s.freeze
18
+ @body = body.to_s.freeze
19
+ freeze
20
+ end
21
+
22
+ def bytesize
23
+ body.bytesize
24
+ end
25
+ end
26
+
27
+ # Converts a Notice into the versioned Chronos JSON envelope.
28
+ #
29
+ # @responsibility Normalize values to JSON primitives and enforce size limits.
30
+ # @motivation Prevent arbitrary application objects or invalid encodings from breaking capture.
31
+ # @limits Version 0.1 applies structural limits, not the advanced privacy rules from 0.2.
32
+ # @collaborators Notice and SerializedEvent.
33
+ # @thread_safety Stateless apart from immutable configuration.
34
+ # @compatibility Uses the JSON standard library available on Ruby 2.2.10.
35
+ # @example
36
+ # event = serializer.call(notice)
37
+ # event.body #=> "{...}"
38
+ # @errors Serialization failures are handled by CaptureException.
39
+ # @performance Linear in payload size with bounded depth and collection sizes.
40
+ class PayloadSerializer
41
+ MAX_DEPTH = 10
42
+ MAX_KEYS = 100
43
+ MAX_ITEMS = 100
44
+ MAX_STRING_BYTES = 8192
45
+
46
+ def initialize(config, clock = nil)
47
+ @config = config
48
+ @clock = clock || proc { Time.now }
49
+ end
50
+
51
+ def call(notice)
52
+ envelope = build_envelope(notice)
53
+ body = JSON.generate(envelope)
54
+ body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @config.max_payload_size
55
+ raise Error, "event exceeds max_payload_size" if body.bytesize > @config.max_payload_size
56
+
57
+ SerializedEvent.new(notice.event_id, body)
58
+ end
59
+
60
+ private
61
+
62
+ def build_envelope(notice)
63
+ {
64
+ "schema_version" => "1.0",
65
+ "event_id" => notice.event_id,
66
+ "event_type" => "exception",
67
+ "occurred_at" => notice.timestamp,
68
+ "sent_at" => @clock.call.utc.iso8601(6),
69
+ "project_key" => safe_string(@config.project_id),
70
+ "environment" => safe_string(notice.environment),
71
+ "service" => {
72
+ "name" => safe_string(@config.service_name),
73
+ "version" => safe_string(@config.app_version),
74
+ "instance_id" => safe_string(notice.host)
75
+ },
76
+ "runtime" => normalize(notice.runtime, 0),
77
+ "context" => normalize(notice.context, 0),
78
+ "payload" => payload(notice)
79
+ }
80
+ end
81
+
82
+ def payload(notice)
83
+ normalize({
84
+ "exception" => {
85
+ "class" => notice.exception_class,
86
+ "message" => notice.message,
87
+ "backtrace" => notice.backtrace,
88
+ "causes" => notice.causes
89
+ },
90
+ "severity" => notice.severity,
91
+ "parameters" => notice.parameters,
92
+ "session" => notice.session,
93
+ "user" => notice.user,
94
+ "versions" => notice.versions,
95
+ "host" => notice.host,
96
+ "process" => notice.process,
97
+ "thread" => notice.thread,
98
+ "tags" => notice.tags,
99
+ "fingerprint" => notice.fingerprint
100
+ }, 0)
101
+ end
102
+
103
+ def compact_envelope(envelope)
104
+ payload = envelope["payload"]
105
+ exception = payload["exception"]
106
+ exception["message"] = truncate_string(exception["message"], 1024)
107
+ exception["backtrace"] = Array(exception["backtrace"]).first(20)
108
+ payload["parameters"] = {"_truncated" => true}
109
+ payload["session"] = {"_truncated" => true}
110
+ payload["user"] = {"_truncated" => true}
111
+ envelope["context"] = {"_truncated" => true}
112
+ envelope
113
+ end
114
+
115
+ def normalize(value, depth)
116
+ return "<maximum depth reached>" if depth >= MAX_DEPTH
117
+
118
+ case value
119
+ when nil, true, false, Integer
120
+ value
121
+ when Float
122
+ value.finite? ? value : value.to_s
123
+ when String, Symbol
124
+ truncate_string(safe_string(value), MAX_STRING_BYTES)
125
+ when Array
126
+ value.first(MAX_ITEMS).map { |child| normalize(child, depth + 1) }
127
+ when Hash
128
+ normalize_hash(value, depth)
129
+ else
130
+ "<#{safe_class_name(value)}>"
131
+ end
132
+ rescue StandardError
133
+ "<unserializable value>"
134
+ end
135
+
136
+ def normalize_hash(value, depth)
137
+ result = {}
138
+ value.to_a.first(MAX_KEYS).each do |key, child|
139
+ result[truncate_string(safe_string(key), 256)] = normalize(child, depth + 1)
140
+ end
141
+ result
142
+ end
143
+
144
+ def safe_string(value)
145
+ return nil if value.nil?
146
+
147
+ value.to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
148
+ rescue StandardError
149
+ "<unreadable value>"
150
+ end
151
+
152
+ def truncate_string(value, limit)
153
+ return value if value.nil? || value.bytesize <= limit
154
+
155
+ value.byteslice(0, limit).to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�") + "…"
156
+ end
157
+
158
+ def safe_class_name(value)
159
+ value.class.name.to_s
160
+ rescue StandardError
161
+ "Object"
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,42 @@
1
+ require "socket"
2
+
3
+ module Chronos
4
+ module Core
5
+ # Collects low-cost runtime identifiers for an event.
6
+ #
7
+ # @responsibility Report Ruby, platform, process, thread, and optional host data.
8
+ # @motivation Provide enough environment context for diagnosis and grouping.
9
+ # @limits It does not enumerate environment variables, gems, or machine secrets.
10
+ # @thread_safety Stateless; all returned hashes are new objects.
11
+ # @compatibility Ruby 2.2.10 through Ruby 2.6 and feature-detected engines.
12
+ # @example
13
+ # Chronos::Core::RuntimeInfo.new.call
14
+ # @performance Uses constant-time runtime lookups and one hostname lookup.
15
+ class RuntimeInfo
16
+ def call
17
+ {
18
+ :runtime => {
19
+ "ruby_version" => RUBY_VERSION,
20
+ "ruby_engine" => ruby_engine,
21
+ "platform" => RUBY_PLATFORM
22
+ },
23
+ :host => safe_hostname,
24
+ :process => {"pid" => Process.pid},
25
+ :thread => {"id" => Thread.current.object_id.to_s}
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def ruby_engine
32
+ defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
33
+ end
34
+
35
+ def safe_hostname
36
+ Socket.gethostname.to_s
37
+ rescue StandardError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,10 @@
1
+ module Chronos
2
+ # Framework-independent domain values and normalization services.
3
+ #
4
+ # @responsibility Model and normalize Chronos events.
5
+ # @motivation Keep runtime data independent from delivery mechanisms.
6
+ # @limits Does not depend on Rails, Rack, Sidekiq, or Net::HTTP.
7
+ # @thread_safety Components are immutable or stateless unless documented otherwise.
8
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
9
+ module Core; end
10
+ end
@@ -0,0 +1,24 @@
1
+ module Chronos
2
+ # Base error raised by explicit Chronos setup operations.
3
+ #
4
+ # @responsibility Provide a common ancestor for errors callers may handle.
5
+ # @motivation Separate agent setup failures from application failures.
6
+ # @limits Runtime capture and delivery errors never escape through this class.
7
+ # @thread_safety Error instances are not shared by the agent.
8
+ # @compatibility Ruby 2.2.10 and newer legacy runtimes.
9
+ # @example
10
+ # rescue Chronos::Error => error
11
+ # warn error.message
12
+ class Error < StandardError; end
13
+
14
+ # Raised when a configuration cannot produce a valid immutable snapshot.
15
+ #
16
+ # @responsibility Report all invalid configuration fields before agent startup.
17
+ # @motivation Fail early during explicit setup instead of failing in requests.
18
+ # @limits It is not used for transport or event errors.
19
+ # @thread_safety Instances are immutable after construction by convention.
20
+ # @compatibility Ruby 2.2.10 and newer legacy runtimes.
21
+ # @example
22
+ # Chronos.configure { |config| config.project_key = nil }
23
+ class ConfigurationError < Error; end
24
+ end
@@ -0,0 +1,79 @@
1
+ module Chronos
2
+ module Internal
3
+ # Thread-safe queue with fixed capacity and non-blocking producer behavior.
4
+ #
5
+ # @responsibility Accept events up to a limit and count accepted and dropped items.
6
+ # @motivation Prevent telemetry bursts from growing application memory indefinitely.
7
+ # @limits Version 0.1 drops the newest item when full and does not persist to disk.
8
+ # @collaborators WorkerPool.
9
+ # @thread_safety Mutex and condition variable protect all mutable state.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6 and fork-aware callers.
11
+ # @example
12
+ # queue.push(event) #=> true or false
13
+ # @performance Push is constant-time and never waits for free capacity.
14
+ class BoundedQueue
15
+ attr_reader :capacity
16
+
17
+ def initialize(capacity)
18
+ raise ArgumentError, "capacity must be positive" unless capacity.is_a?(Integer) && capacity > 0
19
+
20
+ @capacity = capacity
21
+ @items = []
22
+ @accepted = 0
23
+ @dropped = 0
24
+ @closed = false
25
+ @mutex = Mutex.new
26
+ @condition = ConditionVariable.new
27
+ end
28
+
29
+ def push(item)
30
+ @mutex.synchronize do
31
+ return false if @closed
32
+ if @items.length >= @capacity
33
+ @dropped += 1
34
+ return false
35
+ end
36
+
37
+ @items << item
38
+ @accepted += 1
39
+ @condition.signal
40
+ true
41
+ end
42
+ end
43
+
44
+ def pop(timeout = nil)
45
+ @mutex.synchronize do
46
+ @condition.wait(@mutex, timeout) while @items.empty? && !@closed && timeout.nil?
47
+ @condition.wait(@mutex, timeout) if @items.empty? && !@closed && timeout
48
+ @items.shift
49
+ end
50
+ end
51
+
52
+ def close
53
+ @mutex.synchronize do
54
+ @closed = true
55
+ @condition.broadcast
56
+ end
57
+ true
58
+ end
59
+
60
+ def closed?
61
+ @mutex.synchronize { @closed }
62
+ end
63
+
64
+ def empty?
65
+ @mutex.synchronize { @items.empty? }
66
+ end
67
+
68
+ def size
69
+ @mutex.synchronize { @items.size }
70
+ end
71
+
72
+ def stats
73
+ @mutex.synchronize do
74
+ {:size => @items.size, :capacity => capacity, :accepted => @accepted, :dropped => @dropped}
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,55 @@
1
+ module Chronos
2
+ module Internal
3
+ # Protects the host application from logger failures and recursive diagnostics.
4
+ #
5
+ # @responsibility Emit bounded internal messages through a configured logger.
6
+ # @motivation Diagnostics must never become a new application failure.
7
+ # @limits It does not store logs or include credentials and payloads.
8
+ # @thread_safety Uses a mutex for recursion protection across threads.
9
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
10
+ # @example
11
+ # logger.warn("transport failed")
12
+ class SafeLogger
13
+ MAX_MESSAGE_BYTES = 1024
14
+
15
+ def initialize(logger)
16
+ @logger = logger
17
+ @mutex = Mutex.new
18
+ @logging = false
19
+ end
20
+
21
+ def debug(message)
22
+ write(:debug, message)
23
+ end
24
+
25
+ def warn(message)
26
+ write(:warn, message)
27
+ end
28
+
29
+ private
30
+
31
+ def write(level, message)
32
+ return unless @logger && @logger.respond_to?(level)
33
+
34
+ allowed = @mutex.synchronize do
35
+ next false if @logging
36
+ @logging = true
37
+ end
38
+ return unless allowed
39
+
40
+ @logger.public_send(level, bounded(message))
41
+ rescue StandardError
42
+ nil
43
+ ensure
44
+ @mutex.synchronize { @logging = false } if allowed
45
+ end
46
+
47
+ def bounded(message)
48
+ text = message.to_s
49
+ text.bytesize > MAX_MESSAGE_BYTES ? text.byteslice(0, MAX_MESSAGE_BYTES) : text
50
+ rescue StandardError
51
+ "Chronos internal diagnostic unavailable"
52
+ end
53
+ end
54
+ end
55
+ end