natswork-server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module NatsWork
6
+ module Instrumentation
7
+ extend self
8
+
9
+ # Event subscribers
10
+ @subscribers = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
11
+ @enabled = true
12
+
13
+ def subscribe(event, &block)
14
+ return unless block_given?
15
+
16
+ subscription = Subscription.new(event, block)
17
+ @subscribers[event.to_s] << subscription
18
+ subscription
19
+ end
20
+
21
+ def unsubscribe(subscription)
22
+ @subscribers.each_value { |subs| subs.delete(subscription) }
23
+ end
24
+
25
+ def instrument(event, payload = {})
26
+ return yield if block_given? && !@enabled
27
+
28
+ event_name = event.to_s
29
+ payload = payload.dup
30
+ payload[:event] = event_name
31
+ payload[:started_at] = Time.now
32
+
33
+ begin
34
+ if block_given?
35
+ result = yield
36
+ payload[:finished_at] = Time.now
37
+ payload[:duration] = (payload[:finished_at] - payload[:started_at]) * 1000
38
+ payload[:result] = result
39
+
40
+ notify_subscribers(event_name, payload)
41
+ result
42
+ else
43
+ notify_subscribers(event_name, payload)
44
+ end
45
+ rescue StandardError => e
46
+ payload[:finished_at] = Time.now
47
+ payload[:duration] = (payload[:finished_at] - payload[:started_at]) * 1000
48
+ payload[:error] = e
49
+ payload[:exception] = [e.class.name, e.message]
50
+
51
+ notify_subscribers("#{event_name}.error", payload)
52
+ notify_subscribers(event_name, payload)
53
+ raise
54
+ end
55
+ end
56
+
57
+ def enable!
58
+ @enabled = true
59
+ end
60
+
61
+ def disable!
62
+ @enabled = false
63
+ end
64
+
65
+ def enabled?
66
+ @enabled
67
+ end
68
+
69
+ def clear_subscribers!
70
+ @subscribers.clear
71
+ end
72
+
73
+ private
74
+
75
+ def notify_subscribers(event, payload)
76
+ return unless @enabled
77
+
78
+ @subscribers[event].each do |subscription|
79
+ subscription.call(payload)
80
+ rescue StandardError => e
81
+ # Log but don't let subscriber errors break the flow
82
+ NatsWork::Logger.error('Instrumentation subscriber error',
83
+ event: event,
84
+ error: e.message)
85
+ end
86
+
87
+ # Also notify wildcard subscribers
88
+ @subscribers['*'].each do |subscription|
89
+ subscription.call(payload)
90
+ rescue StandardError => e
91
+ NatsWork::Logger.error('Wildcard instrumentation subscriber error',
92
+ event: event,
93
+ error: e.message)
94
+ end
95
+ end
96
+
97
+ class Subscription
98
+ attr_reader :event, :callback
99
+
100
+ def initialize(event, callback)
101
+ @event = event.to_s
102
+ @callback = callback
103
+ end
104
+
105
+ def call(payload)
106
+ @callback.call(payload)
107
+ end
108
+ end
109
+
110
+ # ActiveSupport::Notifications compatibility layer
111
+ module ActiveSupportCompatibility
112
+ def self.included(base)
113
+ base.extend(ClassMethods)
114
+ end
115
+
116
+ module ClassMethods
117
+ def instrument(event, payload = {}, &block)
118
+ if defined?(ActiveSupport::Notifications)
119
+ ActiveSupport::Notifications.instrument("natswork.#{event}", payload, &block)
120
+ else
121
+ NatsWork::Instrumentation.instrument(event, payload, &block)
122
+ end
123
+ end
124
+
125
+ def subscribe(pattern, &block)
126
+ if defined?(ActiveSupport::Notifications)
127
+ ActiveSupport::Notifications.subscribe(/^natswork\.#{pattern}/) do |name, started, finished, _id, payload|
128
+ payload[:event] = name.sub('natswork.', '')
129
+ payload[:started_at] = started
130
+ payload[:finished_at] = finished
131
+ payload[:duration] = (finished - started) * 1000 if finished && started
132
+ block.call(payload)
133
+ end
134
+ else
135
+ NatsWork::Instrumentation.subscribe(pattern, &block)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logger'
4
+ require_relative 'metrics'
5
+ require_relative 'instrumentation'
6
+ require_relative 'health_check'
7
+
8
+ module NatsWork
9
+ class JobExecutor
10
+ attr_reader :worker_name, :logger, :metrics
11
+
12
+ def initialize(worker_name, options = {})
13
+ @worker_name = worker_name
14
+ @logger = options[:logger] || Logger.new(namespace: "worker.#{worker_name}")
15
+ @metrics = options[:metrics] || Metrics.global
16
+ @middleware = options[:middleware] || []
17
+ @error_handlers = []
18
+ @job_filters = {
19
+ before: [],
20
+ after: [],
21
+ around: []
22
+ }
23
+ @skip_instrumentation = options[:skip_instrumentation] || false
24
+
25
+ setup_instrumentation unless @skip_instrumentation
26
+ end
27
+
28
+ def execute_job(job_message)
29
+ job_id = job_message['job_id']
30
+ job_class = job_message['job_class']
31
+ arguments = job_message['arguments'] || []
32
+
33
+ context = {
34
+ job_id: job_id,
35
+ job_class: job_class,
36
+ worker: @worker_name,
37
+ queue: job_message['queue'],
38
+ retry_count: job_message['retry_count'] || 0
39
+ }
40
+
41
+ filtered_args = filter_sensitive_data(arguments)
42
+
43
+ @logger.info('Starting job', context.merge(
44
+ arguments: filtered_args,
45
+ enqueued_at: job_message['created_at']
46
+ ))
47
+
48
+ @metrics.increment('jobs.started', 1,
49
+ queue: job_message['queue'],
50
+ job_class: job_class)
51
+
52
+ result = nil
53
+ start_time = Time.now
54
+
55
+ begin
56
+ # Instrument the job execution
57
+ Instrumentation.instrument('job.execute', context) do
58
+ # Run before filters
59
+ run_filters(:before, job_message, context)
60
+
61
+ # Execute the actual job
62
+ result = @metrics.time('job.execution_time',
63
+ queue: job_message['queue'],
64
+ job_class: job_class) do
65
+ perform_job(job_class, arguments)
66
+ end
67
+
68
+ # Run after filters
69
+ run_filters(:after, job_message, context.merge(result: result))
70
+
71
+ result
72
+ end
73
+
74
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
75
+
76
+ @logger.info('Completed job', context.merge(
77
+ duration_ms: duration_ms,
78
+ result_class: result.class.name
79
+ ))
80
+
81
+ @metrics.increment('jobs.completed', 1,
82
+ queue: job_message['queue'],
83
+ job_class: job_class,
84
+ status: 'success')
85
+
86
+ @metrics.histogram('job.duration', duration_ms,
87
+ queue: job_message['queue'],
88
+ job_class: job_class)
89
+
90
+ result
91
+ rescue StandardError => e
92
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
93
+
94
+ error_context = context.merge(
95
+ duration_ms: duration_ms,
96
+ error_class: e.class.name,
97
+ error_message: e.message,
98
+ backtrace: e.backtrace&.first(10)
99
+ )
100
+
101
+ @logger.error('Job failed', error_context)
102
+
103
+ @metrics.increment('jobs.failed', 1,
104
+ queue: job_message['queue'],
105
+ job_class: job_class,
106
+ error_class: e.class.name)
107
+
108
+ @metrics.histogram('job.duration', duration_ms,
109
+ queue: job_message['queue'],
110
+ job_class: job_class,
111
+ status: 'error')
112
+
113
+ # Run error handlers
114
+ handle_job_error(e, job_message, context)
115
+
116
+ raise e
117
+ end
118
+ end
119
+
120
+ def add_middleware(middleware)
121
+ @middleware << middleware
122
+ end
123
+
124
+ def add_error_handler(&block)
125
+ @error_handlers << block if block_given?
126
+ end
127
+
128
+ def add_job_filter(type, &block)
129
+ @job_filters[type.to_sym] << block if block_given?
130
+ end
131
+
132
+ def on_job_retry(&block)
133
+ add_job_filter(:retry, &block)
134
+ end
135
+
136
+ def on_job_failure(&block)
137
+ add_error_handler(&block)
138
+ end
139
+
140
+ private
141
+
142
+ def setup_instrumentation
143
+ # Subscribe to job events for additional logging
144
+ Instrumentation.subscribe('job.execute') do |payload|
145
+ if payload[:error]
146
+ logger.error('Job instrumentation error',
147
+ event: payload[:event],
148
+ job_id: payload[:job_id],
149
+ error: payload[:exception])
150
+ end
151
+ end
152
+
153
+ # Subscribe to metrics for health monitoring
154
+ Instrumentation.subscribe('job.execute.error') do |payload|
155
+ # Track error patterns
156
+ metrics.increment('errors.by_class', 1,
157
+ error_class: payload[:exception]&.first,
158
+ job_class: payload[:job_class])
159
+ end
160
+ end
161
+
162
+ def perform_job(job_class_name, arguments)
163
+ # This is where the actual job class would be instantiated and performed
164
+ # For now, simulate job execution
165
+ job_class = begin
166
+ Object.const_get(job_class_name)
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ # Ensure arguments are symbolized if it's a hash
172
+ args = if arguments.is_a?(Hash)
173
+ arguments.transform_keys(&:to_sym)
174
+ elsif arguments.is_a?(Array) && arguments.length == 1 && arguments[0].is_a?(Hash)
175
+ # If single hash argument in array, extract and symbolize
176
+ arguments[0].transform_keys(&:to_sym)
177
+ else
178
+ arguments
179
+ end
180
+
181
+ if job_class.respond_to?(:perform_async)
182
+ # Handle NatsWork::Job classes
183
+ job_instance = job_class.new
184
+ job_instance.perform(args)
185
+ elsif job_class.respond_to?(:perform)
186
+ # Handle plain Ruby classes with perform method
187
+ job_class.perform(args)
188
+ else
189
+ raise ArgumentError, "Unknown job class or missing perform method: #{job_class_name}"
190
+ end
191
+ end
192
+
193
+ def run_filters(type, job_message, context)
194
+ @job_filters[type].each do |filter|
195
+ filter.call(job_message, context)
196
+ rescue StandardError => e
197
+ @logger.warn('Job filter error',
198
+ filter_type: type,
199
+ job_id: context[:job_id],
200
+ error: e.message)
201
+ end
202
+ end
203
+
204
+ def handle_job_error(error, job_message, context)
205
+ @error_handlers.each do |handler|
206
+ handler.call(error, job_message, context)
207
+ rescue StandardError => e
208
+ @logger.error('Error handler failed',
209
+ job_id: context[:job_id],
210
+ handler_error: e.message)
211
+ end
212
+
213
+ # Emit error tracking event
214
+ Instrumentation.instrument('job.error', context.merge(
215
+ error: error,
216
+ error_class: error.class.name,
217
+ error_message: error.message
218
+ ))
219
+ end
220
+
221
+ def filter_sensitive_data(arguments)
222
+ # Filter out sensitive data from job arguments for logging
223
+ case arguments
224
+ when Hash
225
+ filter_hash(arguments)
226
+ when Array
227
+ arguments.map do |arg|
228
+ case arg
229
+ when Hash
230
+ filter_hash(arg)
231
+ when String
232
+ arg.length > 1000 ? "#{arg[0, 100]}...[truncated]" : arg
233
+ else
234
+ arg
235
+ end
236
+ end
237
+ else
238
+ arguments
239
+ end
240
+ end
241
+
242
+ def filter_hash(hash)
243
+ filtered = {}
244
+
245
+ hash.each do |key, value|
246
+ key_str = key.to_s.downcase
247
+
248
+ filtered[key] = if sensitive_key?(key_str)
249
+ '[FILTERED]'
250
+ elsif value.is_a?(Hash)
251
+ filter_hash(value)
252
+ elsif value.is_a?(String) && value.length > 1000
253
+ "#{value[0, 100]}...[truncated]"
254
+ else
255
+ value
256
+ end
257
+ end
258
+
259
+ filtered
260
+ end
261
+
262
+ def sensitive_key?(key)
263
+ sensitive_patterns = %w[
264
+ password passwd pass secret token key credential auth
265
+ ssn social_security credit_card cvv pin private_key
266
+ ]
267
+
268
+ sensitive_patterns.any? { |pattern| key.include?(pattern) }
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWork
4
+ class JobHooks
5
+ VALID_HOOKS = %i[before after retry failure success].freeze
6
+
7
+ class << self
8
+ def global
9
+ @global ||= new
10
+ end
11
+ end
12
+
13
+ def initialize
14
+ @hooks = {}
15
+ VALID_HOOKS.each { |hook| @hooks[hook] = [] }
16
+ end
17
+
18
+ def register(type, &block)
19
+ raise ArgumentError, "Invalid hook type: #{type}" unless VALID_HOOKS.include?(type)
20
+
21
+ @hooks[type] << block
22
+ end
23
+
24
+ def before(&block)
25
+ register(:before, &block)
26
+ end
27
+
28
+ def after(&block)
29
+ register(:after, &block)
30
+ end
31
+
32
+ def on_retry(&block)
33
+ register(:retry, &block)
34
+ end
35
+
36
+ def on_failure(&block)
37
+ register(:failure, &block)
38
+ end
39
+
40
+ def on_success(&block)
41
+ register(:success, &block)
42
+ end
43
+
44
+ def run(type, job, job_message, *args)
45
+ return unless @hooks[type]
46
+
47
+ @hooks[type].each do |hook|
48
+ hook.call(job, job_message, *args)
49
+ rescue StandardError
50
+ # Log error but don't fail job execution
51
+ # In production, use proper logging
52
+ end
53
+ end
54
+
55
+ def clear(type = nil)
56
+ if type
57
+ @hooks[type] = [] if @hooks[type]
58
+ else
59
+ @hooks.keys.each { |key| @hooks[key] = [] }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module NatsWork
8
+ class Logger
9
+ LEVELS = {
10
+ debug: ::Logger::DEBUG,
11
+ info: ::Logger::INFO,
12
+ warn: ::Logger::WARN,
13
+ error: ::Logger::ERROR,
14
+ fatal: ::Logger::FATAL
15
+ }.freeze
16
+
17
+ attr_reader :logger, :namespace, :structured
18
+
19
+ def initialize(options = {})
20
+ @namespace = options[:namespace] || 'natswork'
21
+ @structured = options.fetch(:structured, false)
22
+ @output = options[:output] || $stdout
23
+ @level = options[:level] || :info
24
+ @formatter = options[:formatter]
25
+
26
+ setup_logger
27
+ end
28
+
29
+ def debug(message, context = {}, &block)
30
+ log(:debug, message, context, &block)
31
+ end
32
+
33
+ def info(message, context = {}, &block)
34
+ log(:info, message, context, &block)
35
+ end
36
+
37
+ def warn(message, context = {}, &block)
38
+ log(:warn, message, context, &block)
39
+ end
40
+
41
+ def error(message, context = {}, &block)
42
+ log(:error, message, context, &block)
43
+ end
44
+
45
+ def fatal(message, context = {}, &block)
46
+ log(:fatal, message, context, &block)
47
+ end
48
+
49
+ def with_namespace(namespace)
50
+ self.class.new(
51
+ namespace: "#{@namespace}.#{namespace}",
52
+ structured: @structured,
53
+ output: @output,
54
+ level: @level,
55
+ formatter: @formatter
56
+ )
57
+ end
58
+
59
+ def level=(level)
60
+ @level = level
61
+ @logger.level = LEVELS[level] || ::Logger::INFO
62
+ end
63
+
64
+ private
65
+
66
+ def setup_logger
67
+ @logger = ::Logger.new(@output)
68
+ @logger.level = LEVELS[@level] || ::Logger::INFO
69
+
70
+ @logger.formatter = if @formatter
71
+ @formatter
72
+ elsif @structured
73
+ method(:json_formatter)
74
+ else
75
+ method(:text_formatter)
76
+ end
77
+ end
78
+
79
+ def log(level, message, context = {})
80
+ context = context.merge(
81
+ namespace: @namespace,
82
+ timestamp: Time.now.iso8601,
83
+ pid: Process.pid,
84
+ thread_id: Thread.current.object_id
85
+ )
86
+
87
+ if block_given?
88
+ start_time = Time.now
89
+ begin
90
+ result = yield
91
+ context[:duration] = ((Time.now - start_time) * 1000).round(2)
92
+ @logger.send(level, [message, context])
93
+ result
94
+ rescue StandardError => e
95
+ context[:duration] = ((Time.now - start_time) * 1000).round(2)
96
+ context[:error] = {
97
+ class: e.class.name,
98
+ message: e.message,
99
+ backtrace: e.backtrace&.first(10)
100
+ }
101
+ @logger.error([message, context])
102
+ raise
103
+ end
104
+ else
105
+ @logger.send(level, [message, context])
106
+ end
107
+ end
108
+
109
+ def json_formatter(severity, datetime, _progname, msg)
110
+ data = if msg.is_a?(Hash)
111
+ msg
112
+ elsif msg.is_a?(Array) && msg.size == 2
113
+ message, context = msg
114
+ context.merge(message: message)
115
+ else
116
+ { message: msg.to_s }
117
+ end
118
+
119
+ data[:severity] = severity
120
+ data[:time] = datetime.iso8601
121
+
122
+ "#{JSON.dump(data)}\n"
123
+ end
124
+
125
+ def text_formatter(severity, datetime, _progname, msg)
126
+ if msg.is_a?(Array) && msg.size == 2
127
+ message, context = msg
128
+ context_str = context.reject { |k, _| %i[namespace timestamp pid thread_id].include?(k) }
129
+ .map { |k, v| "#{k}=#{v.inspect}" }
130
+ .join(' ')
131
+
132
+ "[#{datetime.iso8601}] [#{severity}] [#{context[:namespace]}] #{message}#{context_str.empty? ? '' : " #{context_str}"}\n"
133
+ else
134
+ "[#{datetime.iso8601}] [#{severity}] [#{@namespace}] #{msg}\n"
135
+ end
136
+ end
137
+
138
+ class << self
139
+ def global
140
+ @global ||= new
141
+ end
142
+
143
+ def configure(options = {})
144
+ @global = new(options)
145
+ end
146
+
147
+ def method_missing(method, *args, &block)
148
+ if global.respond_to?(method)
149
+ global.send(method, *args, &block)
150
+ else
151
+ super
152
+ end
153
+ end
154
+
155
+ def respond_to_missing?(method, include_private = false)
156
+ global.respond_to?(method, include_private) || super
157
+ end
158
+ end
159
+ end
160
+
161
+ # LoggerProxy allows for lazy initialization and swappable loggers
162
+ class LoggerProxy
163
+ def initialize(target = nil)
164
+ @target = target
165
+ end
166
+
167
+ attr_writer :target
168
+
169
+ def method_missing(method, *args, &block)
170
+ target.send(method, *args, &block)
171
+ end
172
+
173
+ def respond_to_missing?(method, include_private = false)
174
+ target.respond_to?(method, include_private) || super
175
+ end
176
+
177
+ private
178
+
179
+ def target
180
+ @target ||= Logger.global
181
+ end
182
+ end
183
+ end