catpm 0.1.0

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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +222 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/catpm/application.css +15 -0
  6. data/app/controllers/catpm/application_controller.rb +6 -0
  7. data/app/controllers/catpm/endpoints_controller.rb +63 -0
  8. data/app/controllers/catpm/errors_controller.rb +63 -0
  9. data/app/controllers/catpm/events_controller.rb +89 -0
  10. data/app/controllers/catpm/samples_controller.rb +13 -0
  11. data/app/controllers/catpm/status_controller.rb +79 -0
  12. data/app/controllers/catpm/system_controller.rb +17 -0
  13. data/app/helpers/catpm/application_helper.rb +264 -0
  14. data/app/jobs/catpm/application_job.rb +6 -0
  15. data/app/mailers/catpm/application_mailer.rb +8 -0
  16. data/app/models/catpm/application_record.rb +7 -0
  17. data/app/models/catpm/bucket.rb +45 -0
  18. data/app/models/catpm/error_record.rb +37 -0
  19. data/app/models/catpm/event_bucket.rb +12 -0
  20. data/app/models/catpm/event_sample.rb +22 -0
  21. data/app/models/catpm/sample.rb +26 -0
  22. data/app/views/catpm/endpoints/_sample_table.html.erb +36 -0
  23. data/app/views/catpm/endpoints/show.html.erb +124 -0
  24. data/app/views/catpm/errors/index.html.erb +66 -0
  25. data/app/views/catpm/errors/show.html.erb +107 -0
  26. data/app/views/catpm/events/index.html.erb +73 -0
  27. data/app/views/catpm/events/show.html.erb +86 -0
  28. data/app/views/catpm/samples/show.html.erb +113 -0
  29. data/app/views/catpm/shared/_page_nav.html.erb +6 -0
  30. data/app/views/catpm/shared/_segments_waterfall.html.erb +147 -0
  31. data/app/views/catpm/status/index.html.erb +124 -0
  32. data/app/views/catpm/system/index.html.erb +454 -0
  33. data/app/views/layouts/catpm/application.html.erb +381 -0
  34. data/config/routes.rb +19 -0
  35. data/db/migrate/20250601000001_create_catpm_tables.rb +104 -0
  36. data/lib/catpm/adapter/base.rb +85 -0
  37. data/lib/catpm/adapter/postgresql.rb +186 -0
  38. data/lib/catpm/adapter/sqlite.rb +159 -0
  39. data/lib/catpm/adapter.rb +28 -0
  40. data/lib/catpm/auto_instrument.rb +145 -0
  41. data/lib/catpm/buffer.rb +59 -0
  42. data/lib/catpm/circuit_breaker.rb +60 -0
  43. data/lib/catpm/collector.rb +320 -0
  44. data/lib/catpm/configuration.rb +103 -0
  45. data/lib/catpm/custom_event.rb +37 -0
  46. data/lib/catpm/engine.rb +39 -0
  47. data/lib/catpm/errors.rb +6 -0
  48. data/lib/catpm/event.rb +75 -0
  49. data/lib/catpm/fingerprint.rb +52 -0
  50. data/lib/catpm/flusher.rb +462 -0
  51. data/lib/catpm/lifecycle.rb +76 -0
  52. data/lib/catpm/middleware.rb +75 -0
  53. data/lib/catpm/middleware_probe.rb +28 -0
  54. data/lib/catpm/patches/httpclient.rb +44 -0
  55. data/lib/catpm/patches/net_http.rb +39 -0
  56. data/lib/catpm/request_segments.rb +101 -0
  57. data/lib/catpm/segment_subscribers.rb +242 -0
  58. data/lib/catpm/span_helpers.rb +51 -0
  59. data/lib/catpm/stack_sampler.rb +226 -0
  60. data/lib/catpm/subscribers.rb +47 -0
  61. data/lib/catpm/tdigest.rb +174 -0
  62. data/lib/catpm/trace.rb +165 -0
  63. data/lib/catpm/version.rb +5 -0
  64. data/lib/catpm.rb +66 -0
  65. data/lib/generators/catpm/install_generator.rb +36 -0
  66. data/lib/generators/catpm/templates/initializer.rb.tt +77 -0
  67. data/lib/tasks/catpm_seed.rake +79 -0
  68. data/lib/tasks/catpm_tasks.rake +6 -0
  69. metadata +123 -0
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Catpm
6
+ module Adapter
7
+ module PostgreSQL
8
+ extend Base
9
+
10
+ class << self
11
+ def persist_buckets(aggregated_buckets)
12
+ return if aggregated_buckets.empty?
13
+
14
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
15
+ aggregated_buckets.each_slice(Catpm.config.persistence_batch_size) do |batch|
16
+ batch.each do |bucket_data|
17
+ lock_id = advisory_lock_key(
18
+ "bucket:#{bucket_data[:kind]}:#{bucket_data[:target]}:" \
19
+ "#{bucket_data[:operation]}:#{bucket_data[:bucket_start]}"
20
+ )
21
+
22
+ ActiveRecord::Base.transaction do
23
+ conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
24
+
25
+ existing = Catpm::Bucket.find_by(
26
+ kind: bucket_data[:kind],
27
+ target: bucket_data[:target],
28
+ operation: bucket_data[:operation],
29
+ bucket_start: bucket_data[:bucket_start]
30
+ )
31
+
32
+ if existing
33
+ merged_metadata = merge_metadata_sum(
34
+ existing.metadata_sum, bucket_data[:metadata_sum]
35
+ )
36
+ merged_digest = merge_digest(
37
+ existing.p95_digest, bucket_data[:p95_digest]
38
+ )
39
+
40
+ existing.update!(
41
+ count: existing.count + bucket_data[:count],
42
+ success_count: existing.success_count + bucket_data[:success_count],
43
+ failure_count: existing.failure_count + bucket_data[:failure_count],
44
+ duration_sum: existing.duration_sum + bucket_data[:duration_sum],
45
+ duration_max: [existing.duration_max, bucket_data[:duration_max]].max,
46
+ duration_min: [existing.duration_min, bucket_data[:duration_min]].min,
47
+ metadata_sum: merged_metadata,
48
+ p95_digest: merged_digest
49
+ )
50
+ else
51
+ Catpm::Bucket.create!(
52
+ kind: bucket_data[:kind],
53
+ target: bucket_data[:target],
54
+ operation: bucket_data[:operation],
55
+ bucket_start: bucket_data[:bucket_start],
56
+ count: bucket_data[:count],
57
+ success_count: bucket_data[:success_count],
58
+ failure_count: bucket_data[:failure_count],
59
+ duration_sum: bucket_data[:duration_sum],
60
+ duration_max: bucket_data[:duration_max],
61
+ duration_min: bucket_data[:duration_min],
62
+ metadata_sum: bucket_data[:metadata_sum],
63
+ p95_digest: bucket_data[:p95_digest]
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def persist_event_buckets(event_buckets)
73
+ return if event_buckets.empty?
74
+
75
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
76
+ event_buckets.each_slice(Catpm.config.persistence_batch_size) do |batch|
77
+ batch.each do |bucket_data|
78
+ lock_id = advisory_lock_key("event_bucket:#{bucket_data[:name]}:#{bucket_data[:bucket_start]}")
79
+
80
+ ActiveRecord::Base.transaction do
81
+ conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
82
+
83
+ existing = Catpm::EventBucket.find_by(
84
+ name: bucket_data[:name],
85
+ bucket_start: bucket_data[:bucket_start]
86
+ )
87
+
88
+ if existing
89
+ existing.update!(count: existing.count + bucket_data[:count])
90
+ else
91
+ Catpm::EventBucket.create!(
92
+ name: bucket_data[:name],
93
+ bucket_start: bucket_data[:bucket_start],
94
+ count: bucket_data[:count]
95
+ )
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ def persist_event_samples(event_samples)
104
+ return if event_samples.empty?
105
+
106
+ ActiveRecord::Base.connection_pool.with_connection do
107
+ max = Catpm.config.events_max_samples_per_name
108
+
109
+ event_samples.each_slice(Catpm.config.persistence_batch_size) do |batch|
110
+ records = batch.map do |sample_data|
111
+ { name: sample_data[:name], payload: sample_data[:payload], recorded_at: sample_data[:recorded_at] }
112
+ end
113
+ Catpm::EventSample.insert_all(records) if records.any?
114
+ end
115
+
116
+ # Rotate: delete oldest samples beyond max per name
117
+ event_samples.map { |s| s[:name] }.uniq.each do |name|
118
+ count = Catpm::EventSample.where(name: name).count
119
+ if count > max
120
+ excess_ids = Catpm::EventSample.where(name: name)
121
+ .order(recorded_at: :asc)
122
+ .limit(count - max)
123
+ .pluck(:id)
124
+ Catpm::EventSample.where(id: excess_ids).delete_all
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def persist_errors(error_records)
131
+ return if error_records.empty?
132
+
133
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
134
+ error_records.each_slice(Catpm.config.persistence_batch_size) do |batch|
135
+ batch.each do |error_data|
136
+ lock_id = advisory_lock_key("error:#{error_data[:fingerprint]}")
137
+
138
+ ActiveRecord::Base.transaction do
139
+ conn.execute("SELECT pg_advisory_xact_lock(#{lock_id})")
140
+
141
+ existing = Catpm::ErrorRecord.find_by(fingerprint: error_data[:fingerprint])
142
+
143
+ if existing
144
+ merged_contexts = merge_contexts(
145
+ existing.parsed_contexts, error_data[:new_contexts]
146
+ )
147
+
148
+ attrs = {
149
+ occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
150
+ last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
151
+ contexts: merged_contexts
152
+ }
153
+ attrs[:resolved_at] = nil if existing.resolved?
154
+
155
+ existing.update!(attrs)
156
+ else
157
+ Catpm::ErrorRecord.create!(
158
+ fingerprint: error_data[:fingerprint],
159
+ kind: error_data[:kind],
160
+ error_class: error_data[:error_class],
161
+ message: error_data[:message],
162
+ occurrences_count: error_data[:occurrences_count],
163
+ first_occurred_at: error_data[:first_occurred_at],
164
+ last_occurred_at: error_data[:last_occurred_at],
165
+ contexts: error_data[:new_contexts]
166
+ )
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ def modulo_bucket_sql(interval)
175
+ "EXTRACT(EPOCH FROM bucket_start)::integer % #{interval.to_i} = 0"
176
+ end
177
+
178
+ private
179
+
180
+ def advisory_lock_key(identifier)
181
+ Zlib.crc32(identifier.to_s) & 0x7FFFFFFF
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ module Adapter
5
+ module SQLite
6
+ extend Base
7
+
8
+ class << self
9
+ def persist_buckets(aggregated_buckets)
10
+ return if aggregated_buckets.empty?
11
+
12
+ with_write_lock do
13
+ aggregated_buckets.each do |bucket_data|
14
+ existing = Catpm::Bucket.find_by(
15
+ kind: bucket_data[:kind],
16
+ target: bucket_data[:target],
17
+ operation: bucket_data[:operation],
18
+ bucket_start: bucket_data[:bucket_start]
19
+ )
20
+
21
+ if existing
22
+ merged_metadata = merge_metadata_sum(
23
+ existing.metadata_sum, bucket_data[:metadata_sum]
24
+ )
25
+ merged_digest = merge_digest(
26
+ existing.p95_digest, bucket_data[:p95_digest]
27
+ )
28
+
29
+ existing.update!(
30
+ count: existing.count + bucket_data[:count],
31
+ success_count: existing.success_count + bucket_data[:success_count],
32
+ failure_count: existing.failure_count + bucket_data[:failure_count],
33
+ duration_sum: existing.duration_sum + bucket_data[:duration_sum],
34
+ duration_max: [existing.duration_max, bucket_data[:duration_max]].max,
35
+ duration_min: [existing.duration_min, bucket_data[:duration_min]].min,
36
+ metadata_sum: merged_metadata.to_json,
37
+ p95_digest: merged_digest
38
+ )
39
+ else
40
+ Catpm::Bucket.create!(
41
+ kind: bucket_data[:kind],
42
+ target: bucket_data[:target],
43
+ operation: bucket_data[:operation],
44
+ bucket_start: bucket_data[:bucket_start],
45
+ count: bucket_data[:count],
46
+ success_count: bucket_data[:success_count],
47
+ failure_count: bucket_data[:failure_count],
48
+ duration_sum: bucket_data[:duration_sum],
49
+ duration_max: bucket_data[:duration_max],
50
+ duration_min: bucket_data[:duration_min],
51
+ metadata_sum: bucket_data[:metadata_sum]&.to_json,
52
+ p95_digest: bucket_data[:p95_digest]
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def persist_event_buckets(event_buckets)
60
+ return if event_buckets.empty?
61
+
62
+ with_write_lock do
63
+ event_buckets.each do |bucket_data|
64
+ existing = Catpm::EventBucket.find_by(
65
+ name: bucket_data[:name],
66
+ bucket_start: bucket_data[:bucket_start]
67
+ )
68
+
69
+ if existing
70
+ existing.update!(count: existing.count + bucket_data[:count])
71
+ else
72
+ Catpm::EventBucket.create!(
73
+ name: bucket_data[:name],
74
+ bucket_start: bucket_data[:bucket_start],
75
+ count: bucket_data[:count]
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def persist_event_samples(event_samples)
83
+ return if event_samples.empty?
84
+
85
+ max = Catpm.config.events_max_samples_per_name
86
+
87
+ with_write_lock do
88
+ event_samples.each_slice(Catpm.config.persistence_batch_size) do |batch|
89
+ records = batch.map do |sample_data|
90
+ { name: sample_data[:name], payload: sample_data[:payload]&.to_json, recorded_at: sample_data[:recorded_at] }
91
+ end
92
+ Catpm::EventSample.insert_all(records) if records.any?
93
+ end
94
+
95
+ event_samples.map { |s| s[:name] }.uniq.each do |name|
96
+ count = Catpm::EventSample.where(name: name).count
97
+ if count > max
98
+ excess_ids = Catpm::EventSample.where(name: name)
99
+ .order(recorded_at: :asc)
100
+ .limit(count - max)
101
+ .pluck(:id)
102
+ Catpm::EventSample.where(id: excess_ids).delete_all
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def persist_errors(error_records)
109
+ return if error_records.empty?
110
+
111
+ with_write_lock do
112
+ error_records.each do |error_data|
113
+ existing = Catpm::ErrorRecord.find_by(fingerprint: error_data[:fingerprint])
114
+
115
+ if existing
116
+ merged_contexts = merge_contexts(
117
+ existing.parsed_contexts, error_data[:new_contexts]
118
+ )
119
+
120
+ attrs = {
121
+ occurrences_count: existing.occurrences_count + error_data[:occurrences_count],
122
+ last_occurred_at: [existing.last_occurred_at, error_data[:last_occurred_at]].max,
123
+ contexts: merged_contexts.to_json
124
+ }
125
+ attrs[:resolved_at] = nil if existing.resolved?
126
+
127
+ existing.update!(attrs)
128
+ else
129
+ Catpm::ErrorRecord.create!(
130
+ fingerprint: error_data[:fingerprint],
131
+ kind: error_data[:kind],
132
+ error_class: error_data[:error_class],
133
+ message: error_data[:message],
134
+ occurrences_count: error_data[:occurrences_count],
135
+ first_occurred_at: error_data[:first_occurred_at],
136
+ last_occurred_at: error_data[:last_occurred_at],
137
+ contexts: error_data[:new_contexts].to_json
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def modulo_bucket_sql(interval)
145
+ "CAST(strftime('%s', bucket_start) AS INTEGER) % #{interval.to_i} = 0"
146
+ end
147
+
148
+ private
149
+
150
+ def with_write_lock(&block)
151
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
152
+ conn.raw_connection.busy_timeout = Catpm.config.sqlite_busy_timeout
153
+ ActiveRecord::Base.transaction(&block)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'catpm/adapter/base'
4
+ require 'catpm/adapter/sqlite'
5
+ require 'catpm/adapter/postgresql'
6
+
7
+ module Catpm
8
+ module Adapter
9
+ def self.current
10
+ @current ||= resolve
11
+ end
12
+
13
+ def self.reset!
14
+ @current = nil
15
+ end
16
+
17
+ def self.resolve
18
+ adapter_name = ActiveRecord::Base.connection.adapter_name
19
+ case adapter_name
20
+ when /SQLite/i then Catpm::Adapter::SQLite
21
+ when /PostgreSQL/i then Catpm::Adapter::PostgreSQL
22
+ else
23
+ raise Catpm::UnsupportedAdapter,
24
+ "catpm does not support #{adapter_name}. Supported: PostgreSQL, SQLite."
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ # Zero-config service auto-instrumentation.
5
+ #
6
+ # Detects common service base classes (ApplicationService, BaseService)
7
+ # and prepends span tracking on their .call class method. Since subclasses
8
+ # inherit .call from the base, ALL service objects get instrumented
9
+ # automatically — no code changes, no configuration lists.
10
+ #
11
+ # The typical Rails service pattern:
12
+ # class ApplicationService
13
+ # def self.call(...) = new(...).call
14
+ # end
15
+ #
16
+ # class Sync::Processor < ApplicationService
17
+ # def call = ...
18
+ # end
19
+ #
20
+ # After auto-instrumentation, Sync::Processor.call creates a span
21
+ # named "Sync::Processor#call" that wraps the entire service execution.
22
+ #
23
+ # Custom base classes:
24
+ # Catpm.configure { |c| c.service_base_classes = ["MyBase"] }
25
+ #
26
+ # Explicit method list for edge cases:
27
+ # Catpm.configure do |c|
28
+ # c.auto_instrument_methods = ["Worker#process", "Gateway.charge"]
29
+ # end
30
+ #
31
+ module AutoInstrument
32
+ DEFAULT_SERVICE_BASES = %w[
33
+ ApplicationService
34
+ BaseService
35
+ ].freeze
36
+
37
+ class << self
38
+ def apply!
39
+ instrument_service_bases
40
+ instrument_explicit_methods
41
+ end
42
+
43
+ def reset!
44
+ @applied = Set.new
45
+ @bases_applied = Set.new
46
+ end
47
+
48
+ private
49
+
50
+ # ─── Auto-detect service base classes ───
51
+
52
+ def instrument_service_bases
53
+ @bases_applied ||= Set.new
54
+
55
+ bases = Catpm.config.service_base_classes
56
+ bases = DEFAULT_SERVICE_BASES if bases.nil?
57
+
58
+ bases.each do |base_name|
59
+ next if @bases_applied.include?(base_name)
60
+
61
+ begin
62
+ klass = Object.const_get(base_name)
63
+ rescue NameError
64
+ next
65
+ end
66
+
67
+ next unless klass.is_a?(Class)
68
+
69
+ # Prepend on the class-level .call so ALL subclasses get instrumented.
70
+ # Since subclasses inherit .call from the base and only override
71
+ # instance #call, this single prepend covers everything.
72
+ if klass.respond_to?(:call)
73
+ # Guard against double-prepend (e.g. code reloading in development)
74
+ already = klass.singleton_class.ancestors.any? do |a|
75
+ a.instance_variable_defined?(:@catpm_service_span)
76
+ end
77
+ next if already
78
+
79
+ mod = Module.new do
80
+ @catpm_service_span = true
81
+
82
+ define_method(:call) do |*args, **kwargs, &block|
83
+ Catpm.span("#{name}#call", type: :code) { super(*args, **kwargs, &block) }
84
+ end
85
+ end
86
+ klass.singleton_class.prepend(mod)
87
+ end
88
+
89
+ @bases_applied << base_name
90
+ end
91
+ end
92
+
93
+ # ─── Explicit method list ───
94
+
95
+ def instrument_explicit_methods
96
+ methods = Catpm.config.auto_instrument_methods
97
+ return if methods.nil? || methods.empty?
98
+
99
+ @applied ||= Set.new
100
+
101
+ methods.each do |method_spec|
102
+ next if @applied.include?(method_spec)
103
+
104
+ if method_spec.include?('#')
105
+ class_name, method_name = method_spec.split('#', 2)
106
+ instrument_instance_method(class_name, method_name, method_spec)
107
+ elsif method_spec.include?('.')
108
+ class_name, method_name = method_spec.split('.', 2)
109
+ instrument_class_method(class_name, method_name, method_spec)
110
+ end
111
+ end
112
+ end
113
+
114
+ def instrument_instance_method(class_name, method_name, spec)
115
+ klass = Object.const_get(class_name)
116
+ span_name = spec
117
+
118
+ mod = Module.new do
119
+ define_method(method_name.to_sym) do |*args, **kwargs, &block|
120
+ Catpm.span(span_name) { super(*args, **kwargs, &block) }
121
+ end
122
+ end
123
+ klass.prepend(mod)
124
+ @applied << spec
125
+ rescue NameError
126
+ nil
127
+ end
128
+
129
+ def instrument_class_method(class_name, method_name, spec)
130
+ klass = Object.const_get(class_name)
131
+ span_name = spec
132
+
133
+ mod = Module.new do
134
+ define_method(method_name.to_sym) do |*args, **kwargs, &block|
135
+ Catpm.span(span_name) { super(*args, **kwargs, &block) }
136
+ end
137
+ end
138
+ klass.singleton_class.prepend(mod)
139
+ @applied << spec
140
+ rescue NameError
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class Buffer
5
+ attr_reader :current_bytes, :dropped_count
6
+
7
+ def initialize(max_bytes:)
8
+ @monitor = Monitor.new
9
+ @events = []
10
+ @current_bytes = 0
11
+ @max_bytes = max_bytes
12
+ @dropped_count = 0
13
+ end
14
+
15
+ # Called from request threads. Returns :accepted or :dropped.
16
+ # Never blocks — monitoring must not slow down the application.
17
+ def push(event)
18
+ @monitor.synchronize do
19
+ bytes = event.estimated_bytes
20
+ if @current_bytes + bytes > @max_bytes
21
+ @dropped_count += 1
22
+ Catpm.stats[:dropped_events] += 1
23
+ return :dropped
24
+ end
25
+
26
+ @events << event
27
+ @current_bytes += bytes
28
+ :accepted
29
+ end
30
+ end
31
+
32
+ # Called from flusher thread. Atomically swaps out the entire buffer.
33
+ # Returns the array of events and resets internal state.
34
+ def drain
35
+ @monitor.synchronize do
36
+ events = @events
37
+ @events = []
38
+ @current_bytes = 0
39
+ events
40
+ end
41
+ end
42
+
43
+ def size
44
+ @monitor.synchronize { @events.size }
45
+ end
46
+
47
+ def empty?
48
+ @monitor.synchronize { @events.empty? }
49
+ end
50
+
51
+ def reset!
52
+ @monitor.synchronize do
53
+ @events = []
54
+ @current_bytes = 0
55
+ @dropped_count = 0
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Catpm
4
+ class CircuitBreaker
5
+ attr_reader :state
6
+
7
+ def initialize(failure_threshold: Catpm.config.circuit_breaker_failure_threshold, recovery_timeout: Catpm.config.circuit_breaker_recovery_timeout)
8
+ @failure_threshold = failure_threshold
9
+ @recovery_timeout = recovery_timeout
10
+ @failures = 0
11
+ @state = :closed
12
+ @opened_at = nil
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def open?
17
+ @mutex.synchronize do
18
+ case @state
19
+ when :closed
20
+ false
21
+ when :open
22
+ if Time.now - @opened_at >= @recovery_timeout
23
+ @state = :half_open
24
+ false # Allow one probe attempt
25
+ else
26
+ true
27
+ end
28
+ when :half_open
29
+ false
30
+ end
31
+ end
32
+ end
33
+
34
+ def record_success
35
+ @mutex.synchronize do
36
+ @failures = 0
37
+ @state = :closed
38
+ end
39
+ end
40
+
41
+ def record_failure
42
+ @mutex.synchronize do
43
+ @failures += 1
44
+ if @failures >= @failure_threshold
45
+ @state = :open
46
+ @opened_at = Time.now
47
+ Catpm.stats[:circuit_opens] += 1
48
+ end
49
+ end
50
+ end
51
+
52
+ def reset!
53
+ @mutex.synchronize do
54
+ @failures = 0
55
+ @state = :closed
56
+ @opened_at = nil
57
+ end
58
+ end
59
+ end
60
+ end