nats_pubsub 1.0.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.
- checksums.yaml +7 -0
- data/exe/nats_pubsub +44 -0
- data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
- data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
- data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
- data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
- data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
- data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
- data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
- data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
- data/lib/nats_pubsub/active_record/publishable.rb +192 -0
- data/lib/nats_pubsub/cli.rb +105 -0
- data/lib/nats_pubsub/core/base_repository.rb +73 -0
- data/lib/nats_pubsub/core/config.rb +152 -0
- data/lib/nats_pubsub/core/config_presets.rb +139 -0
- data/lib/nats_pubsub/core/connection.rb +103 -0
- data/lib/nats_pubsub/core/constants.rb +190 -0
- data/lib/nats_pubsub/core/duration.rb +113 -0
- data/lib/nats_pubsub/core/error_action.rb +288 -0
- data/lib/nats_pubsub/core/event.rb +275 -0
- data/lib/nats_pubsub/core/health_check.rb +470 -0
- data/lib/nats_pubsub/core/logging.rb +72 -0
- data/lib/nats_pubsub/core/message_context.rb +193 -0
- data/lib/nats_pubsub/core/presets.rb +222 -0
- data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
- data/lib/nats_pubsub/core/structured_logger.rb +141 -0
- data/lib/nats_pubsub/core/subject.rb +185 -0
- data/lib/nats_pubsub/instrumentation.rb +327 -0
- data/lib/nats_pubsub/middleware/active_record.rb +18 -0
- data/lib/nats_pubsub/middleware/chain.rb +92 -0
- data/lib/nats_pubsub/middleware/logging.rb +48 -0
- data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
- data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
- data/lib/nats_pubsub/models/event_model.rb +73 -0
- data/lib/nats_pubsub/models/inbox_event.rb +109 -0
- data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
- data/lib/nats_pubsub/models/model_utils.rb +57 -0
- data/lib/nats_pubsub/models/outbox_event.rb +113 -0
- data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
- data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
- data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
- data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
- data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
- data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
- data/lib/nats_pubsub/publisher/publisher.rb +156 -0
- data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
- data/lib/nats_pubsub/railtie.rb +52 -0
- data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
- data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
- data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
- data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
- data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
- data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
- data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
- data/lib/nats_pubsub/subscribers/pool.rb +166 -0
- data/lib/nats_pubsub/subscribers/registry.rb +114 -0
- data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
- data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
- data/lib/nats_pubsub/subscribers/worker.rb +152 -0
- data/lib/nats_pubsub/tasks/install.rake +10 -0
- data/lib/nats_pubsub/testing/helpers.rb +199 -0
- data/lib/nats_pubsub/testing/matchers.rb +208 -0
- data/lib/nats_pubsub/testing/test_harness.rb +250 -0
- data/lib/nats_pubsub/testing.rb +157 -0
- data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
- data/lib/nats_pubsub/topology/stream.rb +102 -0
- data/lib/nats_pubsub/topology/stream_support.rb +170 -0
- data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
- data/lib/nats_pubsub/topology/topology.rb +24 -0
- data/lib/nats_pubsub/version.rb +8 -0
- data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
- data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
- data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
- data/lib/nats_pubsub/web/views/layout.erb +68 -0
- data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
- data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
- data/lib/nats_pubsub/web.rb +181 -0
- data/lib/nats_pubsub.rb +290 -0
- metadata +225 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Immutable value object representing a NATS subject.
|
|
5
|
+
# Provides validation, normalization, and pattern matching for NATS subjects.
|
|
6
|
+
#
|
|
7
|
+
# NATS subjects are dot-separated strings that support wildcards:
|
|
8
|
+
# - '*' matches exactly one token
|
|
9
|
+
# - '>' matches one or more tokens (only valid at end)
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a subject
|
|
12
|
+
# subject = Subject.new('production.myapp.users.created')
|
|
13
|
+
# subject.tokens # => ['production', 'myapp', 'users', 'created']
|
|
14
|
+
#
|
|
15
|
+
# @example Pattern matching
|
|
16
|
+
# pattern = Subject.new('production.*.users.*')
|
|
17
|
+
# pattern.matches?('production.myapp.users.created') # => true
|
|
18
|
+
#
|
|
19
|
+
# @example Wildcard subjects
|
|
20
|
+
# wildcard = Subject.new('production.myapp.>')
|
|
21
|
+
# wildcard.wildcard? # => true
|
|
22
|
+
# wildcard.matches?('production.myapp.users.created') # => true
|
|
23
|
+
class Subject
|
|
24
|
+
attr_reader :value, :tokens
|
|
25
|
+
|
|
26
|
+
# NATS subject constraints
|
|
27
|
+
MAX_LENGTH = 255
|
|
28
|
+
VALID_PATTERN = /\A[a-zA-Z0-9_.*>-]+(\.[a-zA-Z0-9_.*>-]+)*\z/
|
|
29
|
+
|
|
30
|
+
# Initialize a new Subject
|
|
31
|
+
#
|
|
32
|
+
# @param value [String, Subject] Subject string or another Subject
|
|
33
|
+
# @raise [ArgumentError] if subject is invalid
|
|
34
|
+
def initialize(value)
|
|
35
|
+
@value = value.is_a?(Subject) ? value.value : value.to_s
|
|
36
|
+
validate!
|
|
37
|
+
@tokens = @value.split('.')
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build subject from domain, resource, and action
|
|
42
|
+
#
|
|
43
|
+
# @param env [String] Environment name
|
|
44
|
+
# @param app_name [String] Application name
|
|
45
|
+
# @param domain [String] Domain name
|
|
46
|
+
# @param resource [String] Resource type
|
|
47
|
+
# @param action [String] Action performed
|
|
48
|
+
# @return [Subject] New subject instance
|
|
49
|
+
def self.from_event(env:, app_name:, domain:, resource:, action:)
|
|
50
|
+
new("#{env}.#{app_name}.#{domain}.#{resource}.#{action}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Build subject for topic
|
|
54
|
+
#
|
|
55
|
+
# @param env [String] Environment name
|
|
56
|
+
# @param app_name [String] Application name
|
|
57
|
+
# @param topic [String] Topic name
|
|
58
|
+
# @return [Subject] New subject instance
|
|
59
|
+
def self.from_topic(env:, app_name:, topic:)
|
|
60
|
+
normalized_topic = normalize_topic(topic)
|
|
61
|
+
new("#{env}.#{app_name}.#{normalized_topic}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Normalize topic name for use in subjects
|
|
65
|
+
# Preserves dots for hierarchical topics and NATS wildcards
|
|
66
|
+
#
|
|
67
|
+
# @param topic [String] Topic name
|
|
68
|
+
# @return [String] Normalized topic name
|
|
69
|
+
def self.normalize_topic(topic)
|
|
70
|
+
topic.to_s.downcase.gsub(/[^a-z0-9_.>*-]/, '_')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if subject contains wildcards
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] True if contains * or >
|
|
76
|
+
def wildcard?
|
|
77
|
+
@value.include?('*') || @value.include?('>')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if subject contains tail wildcard (>)
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] True if ends with >
|
|
83
|
+
def tail_wildcard?
|
|
84
|
+
@value.end_with?('.>')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if this subject/pattern matches another subject
|
|
88
|
+
# Note: Requires SubjectMatcher to be loaded
|
|
89
|
+
#
|
|
90
|
+
# @param other [String, Subject] Subject to match against
|
|
91
|
+
# @return [Boolean] True if matches
|
|
92
|
+
def matches?(other)
|
|
93
|
+
require_relative '../topology/subject_matcher' unless defined?(NatsPubsub::SubjectMatcher)
|
|
94
|
+
other_subject = other.is_a?(Subject) ? other : Subject.new(other)
|
|
95
|
+
SubjectMatcher.match?(@value, other_subject.value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if two subjects overlap (both could match the same message)
|
|
99
|
+
# Note: Requires SubjectMatcher to be loaded
|
|
100
|
+
#
|
|
101
|
+
# @param other [String, Subject] Subject to check overlap with
|
|
102
|
+
# @return [Boolean] True if subjects overlap
|
|
103
|
+
def overlaps?(other)
|
|
104
|
+
require_relative '../topology/subject_matcher' unless defined?(NatsPubsub::SubjectMatcher)
|
|
105
|
+
other_subject = other.is_a?(Subject) ? other : Subject.new(other)
|
|
106
|
+
SubjectMatcher.overlap?(@value, other_subject.value)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get the number of tokens in the subject
|
|
110
|
+
#
|
|
111
|
+
# @return [Integer] Token count
|
|
112
|
+
def token_count
|
|
113
|
+
@tokens.size
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if subject is empty
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean] True if empty
|
|
119
|
+
def empty?
|
|
120
|
+
@value.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# String representation
|
|
124
|
+
#
|
|
125
|
+
# @return [String] Subject value
|
|
126
|
+
def to_s
|
|
127
|
+
@value
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Equality comparison
|
|
131
|
+
#
|
|
132
|
+
# @param other [Object] Object to compare
|
|
133
|
+
# @return [Boolean] True if equal
|
|
134
|
+
def ==(other)
|
|
135
|
+
case other
|
|
136
|
+
when Subject
|
|
137
|
+
@value == other.value
|
|
138
|
+
when String
|
|
139
|
+
@value == other
|
|
140
|
+
else
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
alias eql? ==
|
|
146
|
+
|
|
147
|
+
# Hash code for use in hash tables
|
|
148
|
+
#
|
|
149
|
+
# @return [Integer] Hash code
|
|
150
|
+
def hash
|
|
151
|
+
@value.hash
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Inspect representation
|
|
155
|
+
#
|
|
156
|
+
# @return [String] Inspection string
|
|
157
|
+
def inspect
|
|
158
|
+
"#<Subject:#{@value}>"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Validate subject format
|
|
164
|
+
#
|
|
165
|
+
# @raise [ArgumentError] if invalid
|
|
166
|
+
def validate!
|
|
167
|
+
raise ArgumentError, 'Subject cannot be nil' if @value.nil?
|
|
168
|
+
raise ArgumentError, 'Subject cannot be empty' if @value.empty?
|
|
169
|
+
raise ArgumentError, "Subject too long (max #{MAX_LENGTH} chars)" if @value.length > MAX_LENGTH
|
|
170
|
+
raise ArgumentError, "Invalid subject format: #{@value}" unless @value =~ VALID_PATTERN
|
|
171
|
+
validate_wildcard_placement!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Validate wildcard placement rules
|
|
175
|
+
#
|
|
176
|
+
# @raise [ArgumentError] if wildcard placement is invalid
|
|
177
|
+
def validate_wildcard_placement!
|
|
178
|
+
# '>' can only appear at the end
|
|
179
|
+
if @value.include?('>')
|
|
180
|
+
raise ArgumentError, "Wildcard '>' must be at the end of subject" unless @value.end_with?('.>')
|
|
181
|
+
raise ArgumentError, "Subject cannot contain multiple '>' wildcards" if @value.count('>') > 1
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Instrumentation for NatsPubsub using ActiveSupport::Notifications
|
|
5
|
+
#
|
|
6
|
+
# Provides instrumentation hooks for monitoring and observability.
|
|
7
|
+
#
|
|
8
|
+
# @example Subscribe to all events
|
|
9
|
+
# ActiveSupport::Notifications.subscribe(/nats_pubsub/) do |name, start, finish, id, payload|
|
|
10
|
+
# duration = (finish - start) * 1000
|
|
11
|
+
# puts "#{name}: #{duration}ms"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Subscribe to specific events
|
|
15
|
+
# ActiveSupport::Notifications.subscribe('nats_pubsub.publish') do |*args|
|
|
16
|
+
# event = ActiveSupport::Notifications::Event.new(*args)
|
|
17
|
+
# puts "Published: #{event.payload[:subject]} in #{event.duration}ms"
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
module Instrumentation
|
|
21
|
+
# Available instrumentation events:
|
|
22
|
+
#
|
|
23
|
+
# - nats_pubsub.publish - Message publishing
|
|
24
|
+
# - nats_pubsub.receive - Message reception
|
|
25
|
+
# - nats_pubsub.process - Message processing
|
|
26
|
+
# - nats_pubsub.subscribe - Subscription creation
|
|
27
|
+
# - nats_pubsub.health_check - Health check execution
|
|
28
|
+
# - nats_pubsub.outbox.enqueue - Outbox event enqueued
|
|
29
|
+
# - nats_pubsub.outbox.publish - Outbox event published
|
|
30
|
+
# - nats_pubsub.inbox.store - Inbox event stored
|
|
31
|
+
# - nats_pubsub.inbox.process - Inbox event processed
|
|
32
|
+
# - nats_pubsub.connection.connect - Connection established
|
|
33
|
+
# - nats_pubsub.connection.disconnect - Connection closed
|
|
34
|
+
# - nats_pubsub.error - Error occurred
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Check if ActiveSupport::Notifications is available
|
|
38
|
+
#
|
|
39
|
+
# @return [Boolean] True if available
|
|
40
|
+
def available?
|
|
41
|
+
defined?(ActiveSupport::Notifications)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Instrument a block of code
|
|
45
|
+
#
|
|
46
|
+
# @param event [String] Event name (will be prefixed with 'nats_pubsub.')
|
|
47
|
+
# @param payload [Hash] Event payload
|
|
48
|
+
# @yield Block to instrument
|
|
49
|
+
# @return [Object] Block result
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# Instrumentation.instrument('publish', subject: 'users.created') do
|
|
53
|
+
# # Publishing code
|
|
54
|
+
# end
|
|
55
|
+
def instrument(event, payload = {}, &block)
|
|
56
|
+
return yield unless available?
|
|
57
|
+
|
|
58
|
+
ActiveSupport::Notifications.instrument("nats_pubsub.#{event}", payload, &block)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Publish an instrumentation event without a block
|
|
62
|
+
#
|
|
63
|
+
# @param event [String] Event name (will be prefixed with 'nats_pubsub.')
|
|
64
|
+
# @param payload [Hash] Event payload
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# Instrumentation.publish('error', error: e, context: context)
|
|
68
|
+
def publish(event, payload = {})
|
|
69
|
+
return unless available?
|
|
70
|
+
|
|
71
|
+
ActiveSupport::Notifications.publish("nats_pubsub.#{event}", payload)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Subscribe to instrumentation events
|
|
75
|
+
#
|
|
76
|
+
# @param pattern [String, Regexp] Event pattern to subscribe to
|
|
77
|
+
# @yield Block to execute for matching events
|
|
78
|
+
# @return [Object] Subscription handle
|
|
79
|
+
#
|
|
80
|
+
# @example Subscribe to all events
|
|
81
|
+
# Instrumentation.subscribe(/nats_pubsub/) do |event|
|
|
82
|
+
# puts "Event: #{event.name}, Duration: #{event.duration}ms"
|
|
83
|
+
# end
|
|
84
|
+
#
|
|
85
|
+
# @example Subscribe to specific event
|
|
86
|
+
# Instrumentation.subscribe('nats_pubsub.publish') do |event|
|
|
87
|
+
# puts "Published: #{event.payload[:subject]}"
|
|
88
|
+
# end
|
|
89
|
+
def subscribe(pattern = /nats_pubsub/, &block)
|
|
90
|
+
return unless available?
|
|
91
|
+
|
|
92
|
+
ActiveSupport::Notifications.subscribe(pattern) do |*args|
|
|
93
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
94
|
+
block.call(event)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Unsubscribe from instrumentation events
|
|
99
|
+
#
|
|
100
|
+
# @param subscriber [Object] Subscription handle
|
|
101
|
+
def unsubscribe(subscriber)
|
|
102
|
+
return unless available?
|
|
103
|
+
|
|
104
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Publishing instrumentation
|
|
109
|
+
module Publisher
|
|
110
|
+
# Instrument message publishing
|
|
111
|
+
#
|
|
112
|
+
# @param subject [String] NATS subject
|
|
113
|
+
# @param payload [Hash] Message payload
|
|
114
|
+
# @yield Block performing the publish
|
|
115
|
+
def self.instrument_publish(subject:, **payload, &block)
|
|
116
|
+
Instrumentation.instrument('publish', { subject: subject }.merge(payload), &block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Instrument outbox enqueuing
|
|
120
|
+
#
|
|
121
|
+
# @param event_id [String] Event ID
|
|
122
|
+
# @param subject [String] NATS subject
|
|
123
|
+
# @yield Block performing the enqueue
|
|
124
|
+
def self.instrument_outbox_enqueue(event_id:, subject:, &block)
|
|
125
|
+
Instrumentation.instrument('outbox.enqueue', event_id: event_id, subject: subject, &block)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Instrument outbox publishing
|
|
129
|
+
#
|
|
130
|
+
# @param event_id [String] Event ID
|
|
131
|
+
# @param subject [String] NATS subject
|
|
132
|
+
# @yield Block performing the publish
|
|
133
|
+
def self.instrument_outbox_publish(event_id:, subject:, &block)
|
|
134
|
+
Instrumentation.instrument('outbox.publish', event_id: event_id, subject: subject, &block)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Subscriber instrumentation
|
|
139
|
+
module Subscriber
|
|
140
|
+
# Instrument message reception
|
|
141
|
+
#
|
|
142
|
+
# @param subject [String] NATS subject
|
|
143
|
+
# @param event_id [String] Event ID
|
|
144
|
+
# @yield Block receiving the message
|
|
145
|
+
def self.instrument_receive(subject:, event_id:, &block)
|
|
146
|
+
Instrumentation.instrument('receive', subject: subject, event_id: event_id, &block)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Instrument message processing
|
|
150
|
+
#
|
|
151
|
+
# @param subject [String] NATS subject
|
|
152
|
+
# @param event_id [String] Event ID
|
|
153
|
+
# @param subscriber [String] Subscriber class name
|
|
154
|
+
# @yield Block processing the message
|
|
155
|
+
def self.instrument_process(subject:, event_id:, subscriber:, &block)
|
|
156
|
+
Instrumentation.instrument(
|
|
157
|
+
'process',
|
|
158
|
+
subject: subject,
|
|
159
|
+
event_id: event_id,
|
|
160
|
+
subscriber: subscriber,
|
|
161
|
+
&block
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Instrument inbox storage
|
|
166
|
+
#
|
|
167
|
+
# @param event_id [String] Event ID
|
|
168
|
+
# @param subject [String] NATS subject
|
|
169
|
+
# @yield Block storing the event
|
|
170
|
+
def self.instrument_inbox_store(event_id:, subject:, &block)
|
|
171
|
+
Instrumentation.instrument('inbox.store', event_id: event_id, subject: subject, &block)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Instrument inbox processing
|
|
175
|
+
#
|
|
176
|
+
# @param event_id [String] Event ID
|
|
177
|
+
# @param subject [String] NATS subject
|
|
178
|
+
# @yield Block processing the event
|
|
179
|
+
def self.instrument_inbox_process(event_id:, subject:, &block)
|
|
180
|
+
Instrumentation.instrument('inbox.process', event_id: event_id, subject: subject, &block)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Instrument subscription creation
|
|
184
|
+
#
|
|
185
|
+
# @param subject [String] NATS subject pattern
|
|
186
|
+
# @param subscriber [String] Subscriber class name
|
|
187
|
+
def self.instrument_subscribe(subject:, subscriber:)
|
|
188
|
+
Instrumentation.publish('subscribe', subject: subject, subscriber: subscriber)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Connection instrumentation
|
|
193
|
+
module Connection
|
|
194
|
+
# Instrument connection establishment
|
|
195
|
+
#
|
|
196
|
+
# @param urls [Array<String>] NATS server URLs
|
|
197
|
+
# @yield Block establishing connection
|
|
198
|
+
def self.instrument_connect(urls:, &block)
|
|
199
|
+
Instrumentation.instrument('connection.connect', urls: urls, &block)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Instrument connection close
|
|
203
|
+
#
|
|
204
|
+
# @param urls [Array<String>] NATS server URLs
|
|
205
|
+
def self.instrument_disconnect(urls:)
|
|
206
|
+
Instrumentation.publish('connection.disconnect', urls: urls)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Health check instrumentation
|
|
211
|
+
module HealthCheck
|
|
212
|
+
# Instrument health check execution
|
|
213
|
+
#
|
|
214
|
+
# @param type [Symbol] Health check type (:full, :quick)
|
|
215
|
+
# @yield Block performing health check
|
|
216
|
+
def self.instrument_check(type: :full, &block)
|
|
217
|
+
Instrumentation.instrument('health_check', type: type, &block)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Error instrumentation
|
|
222
|
+
module Error
|
|
223
|
+
# Instrument error occurrence
|
|
224
|
+
#
|
|
225
|
+
# @param error [Exception] Error that occurred
|
|
226
|
+
# @param context [Hash] Error context
|
|
227
|
+
def self.instrument_error(error:, **context)
|
|
228
|
+
Instrumentation.publish(
|
|
229
|
+
'error',
|
|
230
|
+
{
|
|
231
|
+
error_class: error.class.name,
|
|
232
|
+
error_message: error.message,
|
|
233
|
+
backtrace: error.backtrace&.first(5)
|
|
234
|
+
}.merge(context)
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Metrics collector for instrumentation events
|
|
240
|
+
#
|
|
241
|
+
# Collects metrics from ActiveSupport::Notifications events.
|
|
242
|
+
#
|
|
243
|
+
# @example Basic usage
|
|
244
|
+
# collector = NatsPubsub::Instrumentation::MetricsCollector.new
|
|
245
|
+
# collector.start
|
|
246
|
+
#
|
|
247
|
+
# # Later
|
|
248
|
+
# metrics = collector.metrics
|
|
249
|
+
# puts "Published: #{metrics[:publish][:count]}"
|
|
250
|
+
#
|
|
251
|
+
class MetricsCollector
|
|
252
|
+
attr_reader :metrics, :subscriber
|
|
253
|
+
|
|
254
|
+
def initialize
|
|
255
|
+
@metrics = Hash.new do |h, k|
|
|
256
|
+
h[k] = { count: 0, total_duration: 0.0, errors: 0 }
|
|
257
|
+
end
|
|
258
|
+
@subscriber = nil
|
|
259
|
+
@started_at = nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Start collecting metrics
|
|
263
|
+
def start
|
|
264
|
+
return unless Instrumentation.available?
|
|
265
|
+
return if @subscriber
|
|
266
|
+
|
|
267
|
+
@started_at = Time.now
|
|
268
|
+
@subscriber = Instrumentation.subscribe do |event|
|
|
269
|
+
record_event(event)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Stop collecting metrics
|
|
274
|
+
def stop
|
|
275
|
+
return unless @subscriber
|
|
276
|
+
|
|
277
|
+
Instrumentation.unsubscribe(@subscriber)
|
|
278
|
+
@subscriber = nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Reset metrics
|
|
282
|
+
def reset
|
|
283
|
+
@metrics.clear
|
|
284
|
+
@started_at = Time.now
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Get metrics summary
|
|
288
|
+
#
|
|
289
|
+
# @return [Hash] Metrics summary
|
|
290
|
+
def summary
|
|
291
|
+
{
|
|
292
|
+
uptime: uptime,
|
|
293
|
+
events: @metrics.transform_values do |stats|
|
|
294
|
+
{
|
|
295
|
+
count: stats[:count],
|
|
296
|
+
total_duration_ms: stats[:total_duration].round(2),
|
|
297
|
+
avg_duration_ms: avg_duration(stats),
|
|
298
|
+
errors: stats[:errors]
|
|
299
|
+
}
|
|
300
|
+
end
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
def record_event(event)
|
|
307
|
+
name = event.name.sub('nats_pubsub.', '')
|
|
308
|
+
|
|
309
|
+
@metrics[name][:count] += 1
|
|
310
|
+
@metrics[name][:total_duration] += event.duration if event.duration
|
|
311
|
+
@metrics[name][:errors] += 1 if name.include?('error')
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def avg_duration(stats)
|
|
315
|
+
return 0.0 if stats[:count].zero?
|
|
316
|
+
|
|
317
|
+
(stats[:total_duration] / stats[:count]).round(2)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def uptime
|
|
321
|
+
return 0.0 unless @started_at
|
|
322
|
+
|
|
323
|
+
Time.now - @started_at
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Middleware
|
|
5
|
+
# Middleware that ensures ActiveRecord connections are properly managed
|
|
6
|
+
class ActiveRecord
|
|
7
|
+
def call(_subscriber, _payload, _metadata, &)
|
|
8
|
+
if defined?(::ActiveRecord::Base)
|
|
9
|
+
::ActiveRecord::Base.connection_pool.with_connection(&)
|
|
10
|
+
else
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
ensure
|
|
14
|
+
::ActiveRecord::Base.clear_active_connections! if defined?(::ActiveRecord::Base)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
module Middleware
|
|
5
|
+
# Middleware chain for processing messages through multiple middleware layers.
|
|
6
|
+
# Similar to Rack middleware or Sidekiq middleware.
|
|
7
|
+
class Chain
|
|
8
|
+
def initialize
|
|
9
|
+
@entries = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Add middleware to the chain
|
|
13
|
+
#
|
|
14
|
+
# @param klass [Class] Middleware class
|
|
15
|
+
# @param args [Array] Arguments to pass to middleware constructor
|
|
16
|
+
# @param kwargs [Hash] Keyword arguments to pass to middleware constructor
|
|
17
|
+
def add(klass, *args, **kwargs)
|
|
18
|
+
@entries << [klass, args, kwargs]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Remove middleware from the chain
|
|
22
|
+
#
|
|
23
|
+
# @param klass [Class] Middleware class to remove
|
|
24
|
+
def remove(klass)
|
|
25
|
+
@entries.delete_if { |(k, _, _)| k == klass }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clear all middleware
|
|
29
|
+
def clear
|
|
30
|
+
@entries.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if chain is empty
|
|
34
|
+
#
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def empty?
|
|
37
|
+
@entries.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get count of middleware in chain
|
|
41
|
+
#
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def size
|
|
44
|
+
@entries.size
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Invoke the middleware chain
|
|
48
|
+
#
|
|
49
|
+
# @param subscriber [Object] Subscriber instance
|
|
50
|
+
# @param payload [Hash] Event payload
|
|
51
|
+
# @param metadata [Hash] Event metadata
|
|
52
|
+
# @yield Block to execute after all middleware
|
|
53
|
+
def invoke(subscriber, payload, metadata, &)
|
|
54
|
+
chain = build_chain
|
|
55
|
+
traverse(chain, subscriber, payload, metadata, &)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Build middleware instances from entries
|
|
61
|
+
#
|
|
62
|
+
# @return [Array] Array of middleware instances
|
|
63
|
+
def build_chain
|
|
64
|
+
@entries.map do |(klass, args, kwargs)|
|
|
65
|
+
if kwargs.empty?
|
|
66
|
+
klass.new(*args)
|
|
67
|
+
else
|
|
68
|
+
klass.new(*args, **kwargs)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Recursively traverse the middleware chain
|
|
74
|
+
#
|
|
75
|
+
# @param chain [Array] Remaining middleware in chain
|
|
76
|
+
# @param subscriber [Object] Subscriber instance
|
|
77
|
+
# @param payload [Hash] Event payload
|
|
78
|
+
# @param metadata [Hash] Event metadata
|
|
79
|
+
# @yield Block to execute at the end
|
|
80
|
+
def traverse(chain, subscriber, payload, metadata, &block)
|
|
81
|
+
if chain.empty?
|
|
82
|
+
yield
|
|
83
|
+
else
|
|
84
|
+
middleware = chain.shift
|
|
85
|
+
middleware.call(subscriber, payload, metadata) do
|
|
86
|
+
traverse(chain, subscriber, payload, metadata, &block)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
module Middleware
|
|
7
|
+
# Middleware that logs message processing start and completion
|
|
8
|
+
class Logging
|
|
9
|
+
def call(subscriber, _payload, metadata)
|
|
10
|
+
start = Time.now
|
|
11
|
+
|
|
12
|
+
log_start(subscriber, metadata)
|
|
13
|
+
|
|
14
|
+
yield
|
|
15
|
+
|
|
16
|
+
elapsed = ((Time.now - start) * 1000).round(2)
|
|
17
|
+
log_complete(subscriber, metadata, elapsed)
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
elapsed = ((Time.now - start) * 1000).round(2)
|
|
20
|
+
log_error(subscriber, metadata, elapsed, e)
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def log_start(subscriber, metadata)
|
|
27
|
+
NatsPubsub::Logging.info(
|
|
28
|
+
"Processing #{metadata[:subject]} with #{subscriber.class.name} (attempt #{metadata[:deliveries]})",
|
|
29
|
+
tag: 'NatsPubsub::Middleware::Logging'
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log_complete(subscriber, _metadata, elapsed)
|
|
34
|
+
NatsPubsub::Logging.info(
|
|
35
|
+
"Completed #{subscriber.class.name} in #{elapsed}ms",
|
|
36
|
+
tag: 'NatsPubsub::Middleware::Logging'
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def log_error(subscriber, _metadata, elapsed, error)
|
|
41
|
+
NatsPubsub::Logging.error(
|
|
42
|
+
"Failed #{subscriber.class.name} after #{elapsed}ms: #{error.class} #{error.message}",
|
|
43
|
+
tag: 'NatsPubsub::Middleware::Logging'
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
module Middleware
|
|
7
|
+
# Middleware that logs retry attempts
|
|
8
|
+
class RetryLogger
|
|
9
|
+
def call(subscriber, _payload, metadata)
|
|
10
|
+
if metadata[:deliveries] && metadata[:deliveries] > 1
|
|
11
|
+
max_deliver = NatsPubsub.config.max_deliver
|
|
12
|
+
|
|
13
|
+
NatsPubsub::Logging.warn(
|
|
14
|
+
"Retrying #{metadata[:subject]} with #{subscriber.class.name} " \
|
|
15
|
+
"(attempt #{metadata[:deliveries]}/#{max_deliver})",
|
|
16
|
+
tag: 'NatsPubsub::Middleware::RetryLogger'
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
yield
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|