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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Core
5
+ # Unified message context
6
+ #
7
+ # Consolidates all message metadata into a single, well-typed context object.
8
+ #
9
+ # @!attribute [r] event_id
10
+ # @return [String] Unique event identifier (UUID)
11
+ # @!attribute [r] subject
12
+ # @return [String] Full NATS subject
13
+ # @!attribute [r] topic
14
+ # @return [String] Extracted topic from subject
15
+ # @!attribute [r] trace_id
16
+ # @return [String, nil] Optional distributed tracing ID
17
+ # @!attribute [r] correlation_id
18
+ # @return [String, nil] Optional correlation ID for request tracking
19
+ # @!attribute [r] occurred_at
20
+ # @return [Time] Timestamp when the event occurred
21
+ # @!attribute [r] deliveries
22
+ # @return [Integer] Number of delivery attempts
23
+ # @!attribute [r] stream
24
+ # @return [String, nil] JetStream stream name
25
+ # @!attribute [r] stream_seq
26
+ # @return [Integer, nil] JetStream stream sequence number
27
+ # @!attribute [r] producer
28
+ # @return [String, nil] Application that produced the event
29
+ # @!attribute [r] domain
30
+ # @return [String, nil] Legacy: domain field (for backward compatibility)
31
+ # @!attribute [r] resource
32
+ # @return [String, nil] Legacy: resource field (for backward compatibility)
33
+ # @!attribute [r] action
34
+ # @return [String, nil] Legacy: action field (for backward compatibility)
35
+ #
36
+ # @example Using in a subscriber
37
+ # class EmailSubscriber < NatsPubsub::Subscriber
38
+ # subscribe_to 'notifications.email'
39
+ #
40
+ # def handle(message, context)
41
+ # puts "Processing event #{context.event_id}"
42
+ # puts "Trace ID: #{context.trace_id}"
43
+ # puts "Delivery attempt: #{context.deliveries}"
44
+ # end
45
+ # end
46
+ #
47
+ class MessageContext
48
+ attr_reader :event_id, :subject, :topic, :trace_id, :correlation_id,
49
+ :occurred_at, :deliveries, :stream, :stream_seq, :producer,
50
+ :domain, :resource, :action
51
+
52
+ # Initialize a new message context
53
+ #
54
+ # @param event_id [String] Unique event identifier
55
+ # @param subject [String] Full NATS subject
56
+ # @param topic [String] Extracted topic
57
+ # @param trace_id [String, nil] Distributed tracing ID
58
+ # @param correlation_id [String, nil] Request correlation ID
59
+ # @param occurred_at [Time] Event timestamp
60
+ # @param deliveries [Integer] Number of delivery attempts
61
+ # @param stream [String, nil] JetStream stream name
62
+ # @param stream_seq [Integer, nil] JetStream stream sequence
63
+ # @param producer [String, nil] Producer application name
64
+ # @param domain [String, nil] Legacy domain field
65
+ # @param resource [String, nil] Legacy resource field
66
+ # @param action [String, nil] Legacy action field
67
+ def initialize(
68
+ event_id:,
69
+ subject:,
70
+ topic:,
71
+ trace_id: nil,
72
+ correlation_id: nil,
73
+ occurred_at:,
74
+ deliveries:,
75
+ stream: nil,
76
+ stream_seq: nil,
77
+ producer: nil,
78
+ domain: nil,
79
+ resource: nil,
80
+ action: nil
81
+ )
82
+ @event_id = event_id
83
+ @subject = subject
84
+ @topic = topic
85
+ @trace_id = trace_id
86
+ @correlation_id = correlation_id
87
+ @occurred_at = occurred_at
88
+ @deliveries = deliveries
89
+ @stream = stream
90
+ @stream_seq = stream_seq
91
+ @producer = producer
92
+ @domain = domain
93
+ @resource = resource
94
+ @action = action
95
+
96
+ freeze
97
+ end
98
+
99
+ # Create context from legacy metadata hash
100
+ #
101
+ # @param metadata [Hash] Legacy metadata hash
102
+ # @return [MessageContext] New context instance
103
+ #
104
+ # @example
105
+ # context = MessageContext.from_metadata(metadata)
106
+ #
107
+ def self.from_metadata(metadata)
108
+ # Extract topic from subject
109
+ subject = metadata[:subject] || metadata['subject']
110
+ topic = extract_topic_from_subject(subject)
111
+
112
+ new(
113
+ event_id: metadata[:event_id] || metadata['event_id'],
114
+ subject: subject,
115
+ topic: topic,
116
+ trace_id: metadata[:trace_id] || metadata['trace_id'],
117
+ correlation_id: metadata[:correlation_id] || metadata['correlation_id'],
118
+ occurred_at: parse_time(metadata[:occurred_at] || metadata['occurred_at']),
119
+ deliveries: metadata[:deliveries] || metadata['deliveries'] || 1,
120
+ stream: metadata[:stream] || metadata['stream'],
121
+ stream_seq: metadata[:stream_seq] || metadata['stream_seq'],
122
+ producer: metadata[:producer] || metadata['producer'],
123
+ domain: metadata[:domain] || metadata['domain'],
124
+ resource: metadata[:resource] || metadata['resource'],
125
+ action: metadata[:action] || metadata['action']
126
+ )
127
+ end
128
+
129
+ # Convert to hash
130
+ #
131
+ # @return [Hash] Hash representation
132
+ def to_h
133
+ {
134
+ event_id: event_id,
135
+ subject: subject,
136
+ topic: topic,
137
+ trace_id: trace_id,
138
+ correlation_id: correlation_id,
139
+ occurred_at: occurred_at,
140
+ deliveries: deliveries,
141
+ stream: stream,
142
+ stream_seq: stream_seq,
143
+ producer: producer,
144
+ domain: domain,
145
+ resource: resource,
146
+ action: action
147
+ }
148
+ end
149
+
150
+ alias to_hash to_h
151
+
152
+ private
153
+
154
+ # Extract topic from NATS subject
155
+ #
156
+ # @param subject [String] Full NATS subject
157
+ # @return [String] Extracted topic
158
+ #
159
+ # @example
160
+ # extract_topic_from_subject('production.myapp.notifications.email')
161
+ # # => 'notifications.email'
162
+ #
163
+ def self.extract_topic_from_subject(subject)
164
+ return '' if subject.nil? || subject.empty?
165
+
166
+ parts = subject.split('.')
167
+ # Remove env and app_name (first two parts)
168
+ parts[2..-1]&.join('.') || ''
169
+ end
170
+
171
+ # Parse time from various formats
172
+ #
173
+ # @param value [Time, String, Integer, nil] Time value
174
+ # @return [Time] Parsed time
175
+ def self.parse_time(value)
176
+ case value
177
+ when Time
178
+ value
179
+ when String
180
+ Time.parse(value)
181
+ when Integer
182
+ Time.at(value)
183
+ else
184
+ Time.now
185
+ end
186
+ rescue StandardError
187
+ Time.now
188
+ end
189
+
190
+ private_class_method :extract_topic_from_subject, :parse_time
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Core
5
+ # Configuration presets for common environments
6
+ #
7
+ # Provides pre-configured settings optimized for different deployment scenarios.
8
+ #
9
+ # @example Development preset
10
+ # NatsPubsub.configure do |config|
11
+ # Presets.development(config, app_name: 'my-service')
12
+ # end
13
+ #
14
+ # @example Production preset
15
+ # NatsPubsub.configure do |config|
16
+ # Presets.production(
17
+ # config,
18
+ # app_name: 'my-service',
19
+ # nats_urls: ENV.fetch('NATS_CLUSTER_URLS').split(',')
20
+ # )
21
+ # end
22
+ #
23
+ module Presets
24
+ # Development preset optimized for local development
25
+ #
26
+ # Features:
27
+ # - Lower concurrency for easier debugging
28
+ # - Shorter timeouts for faster feedback
29
+ # - DLQ enabled for error visibility
30
+ # - Debug logging
31
+ #
32
+ # @param config [Config] Configuration object
33
+ # @param options [Hash] Additional options
34
+ # @option options [String] :app_name Application name (required)
35
+ # @option options [String, Array<String>] :nats_urls NATS server URLs (default: 'nats://localhost:4222')
36
+ #
37
+ # @example
38
+ # NatsPubsub.configure do |config|
39
+ # Presets.development(config, app_name: 'my-service')
40
+ # end
41
+ #
42
+ def self.development(config, **options)
43
+ validate_required_options!(options, :app_name)
44
+
45
+ config.app_name = options[:app_name]
46
+ config.nats_urls = options.fetch(:nats_urls, 'nats://localhost:4222')
47
+ config.env = options.fetch(:env, 'development')
48
+
49
+ # Lower concurrency for easier debugging
50
+ config.concurrency = 5
51
+
52
+ # Faster feedback during development
53
+ config.max_deliver = 3
54
+ config.ack_wait = 15_000 # 15 seconds
55
+ config.backoff = [1_000, 3_000, 5_000] # Shorter backoff
56
+
57
+ # DLQ enabled for visibility
58
+ config.use_dlq = true
59
+
60
+ # Inbox/Outbox typically not needed in dev
61
+ config.use_inbox = false
62
+ config.use_outbox = false
63
+
64
+ config
65
+ end
66
+
67
+ # Production preset optimized for reliability and performance
68
+ #
69
+ # Features:
70
+ # - Higher concurrency for throughput
71
+ # - Longer timeouts for network latency
72
+ # - Aggressive retry strategy
73
+ # - DLQ enabled for operational safety
74
+ # - Info-level logging
75
+ #
76
+ # @param config [Config] Configuration object
77
+ # @param options [Hash] Additional options
78
+ # @option options [String] :app_name Application name (required)
79
+ # @option options [String, Array<String>] :nats_urls NATS server URLs (required)
80
+ #
81
+ # @example
82
+ # NatsPubsub.configure do |config|
83
+ # Presets.production(
84
+ # config,
85
+ # app_name: 'my-service',
86
+ # nats_urls: ENV.fetch('NATS_CLUSTER_URLS').split(',')
87
+ # )
88
+ # end
89
+ #
90
+ def self.production(config, **options)
91
+ validate_required_options!(options, :app_name, :nats_urls)
92
+
93
+ config.app_name = options[:app_name]
94
+ config.nats_urls = options[:nats_urls]
95
+ config.env = options.fetch(:env, 'production')
96
+
97
+ # Higher concurrency for throughput
98
+ config.concurrency = 20
99
+
100
+ # More aggressive retry strategy
101
+ config.max_deliver = 5
102
+ config.ack_wait = 30_000 # 30 seconds
103
+ config.backoff = [1_000, 5_000, 15_000, 30_000, 60_000] # Exponential backoff
104
+
105
+ # DLQ enabled for operational safety
106
+ config.use_dlq = true
107
+
108
+ # Consider enabling for transactional guarantees
109
+ config.use_inbox = options.fetch(:use_inbox, false)
110
+ config.use_outbox = options.fetch(:use_outbox, false)
111
+
112
+ config
113
+ end
114
+
115
+ # Staging preset balanced between development and production
116
+ #
117
+ # Features:
118
+ # - Moderate concurrency
119
+ # - Production-like retry strategy
120
+ # - DLQ enabled
121
+ # - Debug logging for troubleshooting
122
+ #
123
+ # @param config [Config] Configuration object
124
+ # @param options [Hash] Additional options
125
+ # @option options [String] :app_name Application name (required)
126
+ # @option options [String, Array<String>] :nats_urls NATS server URLs (required)
127
+ #
128
+ # @example
129
+ # NatsPubsub.configure do |config|
130
+ # Presets.staging(
131
+ # config,
132
+ # app_name: 'my-service',
133
+ # nats_urls: 'nats://staging-nats:4222'
134
+ # )
135
+ # end
136
+ #
137
+ def self.staging(config, **options)
138
+ validate_required_options!(options, :app_name, :nats_urls)
139
+
140
+ config.app_name = options[:app_name]
141
+ config.nats_urls = options[:nats_urls]
142
+ config.env = options.fetch(:env, 'staging')
143
+
144
+ # Moderate concurrency
145
+ config.concurrency = 10
146
+
147
+ # Production-like retry strategy
148
+ config.max_deliver = 5
149
+ config.ack_wait = 30_000
150
+ config.backoff = [1_000, 5_000, 15_000, 30_000, 60_000]
151
+
152
+ # DLQ enabled
153
+ config.use_dlq = true
154
+
155
+ # Inbox/Outbox optional
156
+ config.use_inbox = options.fetch(:use_inbox, false)
157
+ config.use_outbox = options.fetch(:use_outbox, false)
158
+
159
+ config
160
+ end
161
+
162
+ # Testing preset optimized for unit and integration tests
163
+ #
164
+ # Features:
165
+ # - Minimal concurrency
166
+ # - Very short timeouts for fast tests
167
+ # - No retries for deterministic behavior
168
+ # - DLQ disabled
169
+ #
170
+ # @param config [Config] Configuration object
171
+ # @param options [Hash] Additional options
172
+ # @option options [String] :app_name Application name (required)
173
+ # @option options [String, Array<String>] :nats_urls NATS server URLs (default: 'nats://localhost:4222')
174
+ #
175
+ # @example
176
+ # NatsPubsub.configure do |config|
177
+ # Presets.testing(config, app_name: 'test-service')
178
+ # end
179
+ #
180
+ def self.testing(config, **options)
181
+ validate_required_options!(options, :app_name)
182
+
183
+ config.app_name = options[:app_name]
184
+ config.nats_urls = options.fetch(:nats_urls, 'nats://localhost:4222')
185
+ config.env = options.fetch(:env, 'test')
186
+
187
+ # Minimal concurrency
188
+ config.concurrency = 1
189
+
190
+ # No retries for deterministic behavior
191
+ config.max_deliver = 1
192
+ config.ack_wait = 5_000 # 5 seconds
193
+ config.backoff = []
194
+
195
+ # DLQ disabled for simpler testing
196
+ config.use_dlq = false
197
+
198
+ # Inbox/Outbox disabled
199
+ config.use_inbox = false
200
+ config.use_outbox = false
201
+
202
+ config
203
+ end
204
+
205
+ # Validate required options
206
+ #
207
+ # @param options [Hash] Options hash
208
+ # @param required [Array<Symbol>] Required option keys
209
+ # @raise [ArgumentError] If required options are missing
210
+ #
211
+ # @api private
212
+ def self.validate_required_options!(options, *required)
213
+ missing = required - options.keys
214
+ return if missing.empty?
215
+
216
+ raise ArgumentError, "Missing required options: #{missing.join(', ')}"
217
+ end
218
+
219
+ private_class_method :validate_required_options!
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/io/client'
4
+ require_relative 'logging'
5
+
6
+ module NatsPubsub
7
+ # Handles retry logic with exponential backoff for operations.
8
+ # Extracted from Publisher to follow Single Responsibility Principle.
9
+ class RetryStrategy
10
+ DEFAULT_RETRIES = 3
11
+
12
+ # Build list of retriable errors dynamically to handle different NATS versions
13
+ RETRIABLE_ERRORS = begin
14
+ errors = [NATS::IO::Timeout, NATS::IO::Error]
15
+ errors << NATS::IO::NoServersError if defined?(NATS::IO::NoServersError)
16
+ errors << NATS::IO::StaleConnectionError if defined?(NATS::IO::StaleConnectionError)
17
+ errors << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
18
+ errors.freeze
19
+ end
20
+
21
+ # Execute a block with retry logic
22
+ #
23
+ # @param retries [Integer] Number of retry attempts
24
+ # @param operation_name [String] Name of the operation for logging
25
+ # @yield Block to execute with retries
26
+ # @return [Object] Result of the block
27
+ # @raise [StandardError] If all retries are exhausted
28
+ def self.execute(retries: DEFAULT_RETRIES, operation_name: 'operation', &block)
29
+ new(retries: retries, operation_name: operation_name).execute(&block)
30
+ end
31
+
32
+ def initialize(retries: DEFAULT_RETRIES, operation_name: 'operation')
33
+ @retries = retries
34
+ @operation_name = operation_name
35
+ end
36
+
37
+ def execute
38
+ attempt = 0
39
+ begin
40
+ yield
41
+ rescue *RETRIABLE_ERRORS => e
42
+ attempt += 1
43
+ if attempt <= @retries
44
+ backoff_time = calculate_backoff(attempt)
45
+ Logging.warn(
46
+ "#{@operation_name} failed (attempt #{attempt}/#{@retries}): #{e.class} #{e.message}. " \
47
+ "Retrying in #{backoff_time}s...",
48
+ tag: 'NatsPubsub::RetryStrategy'
49
+ )
50
+ sleep backoff_time
51
+ retry
52
+ end
53
+ Logging.error(
54
+ "#{@operation_name} failed after #{@retries} retries: #{e.class} #{e.message}",
55
+ tag: 'NatsPubsub::RetryStrategy'
56
+ )
57
+ raise
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Calculate exponential backoff time
64
+ # @param attempt [Integer] Current attempt number
65
+ # @return [Float] Sleep duration in seconds
66
+ def calculate_backoff(attempt)
67
+ # Exponential backoff: 0.1s, 0.2s, 0.4s, 0.8s, etc.
68
+ [0.1 * (2**(attempt - 1)), 5.0].min # Cap at 5 seconds
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module NatsPubsub
7
+ module Core
8
+ # Structured logger for machine-parseable JSON logs
9
+ # Provides consistent logging format with correlation IDs and metadata
10
+ class StructuredLogger
11
+ LEVELS = {
12
+ debug: 0,
13
+ info: 1,
14
+ warn: 2,
15
+ error: 3,
16
+ fatal: 4
17
+ }.freeze
18
+
19
+ attr_reader :output, :level, :context
20
+
21
+ # Initialize a new structured logger
22
+ #
23
+ # @param output [IO] Output stream (default: $stdout)
24
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
25
+ # @param context [Hash] Base context included in all log entries
26
+ def initialize(output: $stdout, level: :info, context: {})
27
+ @output = output
28
+ @level = normalize_level(level)
29
+ @context = context.transform_keys(&:to_s)
30
+ end
31
+
32
+ # Log at debug level
33
+ def debug(message, metadata = {})
34
+ log(:debug, message, metadata)
35
+ end
36
+
37
+ # Log at info level
38
+ def info(message, metadata = {})
39
+ log(:info, message, metadata)
40
+ end
41
+
42
+ # Log at warn level
43
+ def warn(message, metadata = {})
44
+ log(:warn, message, metadata)
45
+ end
46
+
47
+ # Log at error level
48
+ def error(message, metadata = {})
49
+ log(:error, message, metadata)
50
+ end
51
+
52
+ # Log at fatal level
53
+ def fatal(message, metadata = {})
54
+ log(:fatal, message, metadata)
55
+ end
56
+
57
+ # Create child logger with additional context
58
+ #
59
+ # @param child_context [Hash] Additional context
60
+ # @return [StructuredLogger] New logger with merged context
61
+ def with_context(child_context)
62
+ self.class.new(
63
+ output: output,
64
+ level: level,
65
+ context: context.merge(child_context.transform_keys(&:to_s))
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ # Log a message
72
+ def log(severity, message, metadata)
73
+ return if LEVELS[severity] < LEVELS[level]
74
+
75
+ log_entry = build_log_entry(severity, message, metadata)
76
+ output.puts(JSON.generate(log_entry))
77
+ output.flush
78
+ rescue StandardError => e
79
+ # Fallback to plain text if JSON fails
80
+ output.puts("[#{severity}] #{message} | Error: #{e.message}")
81
+ output.flush
82
+ end
83
+
84
+ # Build structured log entry
85
+ def build_log_entry(severity, message, metadata)
86
+ {
87
+ timestamp: Time.now.utc.iso8601(3),
88
+ level: severity.to_s.upcase,
89
+ message: message,
90
+ pid: Process.pid,
91
+ thread_id: Thread.current.object_id
92
+ }.merge(context)
93
+ .merge(normalize_metadata(metadata))
94
+ end
95
+
96
+ # Normalize metadata keys to strings
97
+ def normalize_metadata(metadata)
98
+ return {} unless metadata.is_a?(Hash)
99
+
100
+ metadata.transform_keys(&:to_s)
101
+ end
102
+
103
+ # Normalize log level
104
+ def normalize_level(lvl)
105
+ lvl = lvl.to_sym if lvl.is_a?(String)
106
+ return lvl if LEVELS.key?(lvl)
107
+
108
+ :info
109
+ end
110
+ end
111
+
112
+ # Logger factory for creating structured loggers
113
+ module LoggerFactory
114
+ # Create a structured logger from configuration
115
+ #
116
+ # @param config [Config] Application configuration
117
+ # @return [StructuredLogger] Configured logger
118
+ def self.create_from_config(config)
119
+ level = config.logger&.level || :info
120
+
121
+ StructuredLogger.new(
122
+ output: $stdout,
123
+ level: level,
124
+ context: {
125
+ app_name: config.app_name,
126
+ env: config.env
127
+ }
128
+ )
129
+ end
130
+
131
+ # Create a logger for a specific component
132
+ #
133
+ # @param component [String] Component name
134
+ # @param config [Config] Application configuration
135
+ # @return [StructuredLogger] Logger with component context
136
+ def self.for_component(component, config)
137
+ create_from_config(config).with_context(component: component)
138
+ end
139
+ end
140
+ end
141
+ end