cosmonats 0.1.4 → 0.3.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 +4 -4
  2. data/README.md +129 -67
  3. data/lib/cosmo/api/busy.rb +66 -0
  4. data/lib/cosmo/api/counter.rb +70 -0
  5. data/lib/cosmo/api/job.rb +46 -0
  6. data/lib/cosmo/api/kv.rb +63 -0
  7. data/lib/cosmo/api/stats.rb +44 -0
  8. data/lib/cosmo/api/stream.rb +123 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +8 -5
  11. data/lib/cosmo/client.rb +58 -3
  12. data/lib/cosmo/config.rb +13 -38
  13. data/lib/cosmo/engine.rb +1 -1
  14. data/lib/cosmo/job/processor.rb +66 -57
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +8 -1
  17. data/lib/cosmo/processor.rb +110 -2
  18. data/lib/cosmo/stream/processor.rb +23 -59
  19. data/lib/cosmo/stream.rb +2 -2
  20. data/lib/cosmo/utils/hash.rb +3 -27
  21. data/lib/cosmo/utils/overrides.rb +15 -0
  22. data/lib/cosmo/utils/ttl_cache.rb +44 -0
  23. data/lib/cosmo/utils/warnings.rb +17 -0
  24. data/lib/cosmo/utils.rb +15 -0
  25. data/lib/cosmo/version.rb +1 -1
  26. data/lib/cosmo/web/assets/app.css +477 -0
  27. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  28. data/lib/cosmo/web/context.rb +28 -0
  29. data/lib/cosmo/web/controllers/actions.rb +16 -0
  30. data/lib/cosmo/web/controllers/application.rb +43 -0
  31. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  32. data/lib/cosmo/web/controllers/streams.rb +70 -0
  33. data/lib/cosmo/web/helpers/application.rb +87 -0
  34. data/lib/cosmo/web/renderer.rb +58 -0
  35. data/lib/cosmo/web/views/actions/index.erb +7 -0
  36. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  37. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  38. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  39. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  40. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  41. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  42. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  43. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  44. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  45. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  46. data/lib/cosmo/web/views/layout.erb +33 -0
  47. data/lib/cosmo/web/views/streams/_info.erb +92 -0
  48. data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
  49. data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
  50. data/lib/cosmo/web/views/streams/_table.erb +25 -0
  51. data/lib/cosmo/web/views/streams/index.erb +11 -0
  52. data/lib/cosmo/web/views/streams/info.erb +11 -0
  53. data/lib/cosmo/web.rb +68 -0
  54. data/lib/cosmo.rb +2 -7
  55. data/sig/cosmo/api/busy.rbs +35 -0
  56. data/sig/cosmo/api/counter.rbs +34 -0
  57. data/sig/cosmo/api/job.rbs +31 -0
  58. data/sig/cosmo/api/kv.rbs +30 -0
  59. data/sig/cosmo/api/stats.rbs +21 -0
  60. data/sig/cosmo/api/stream.rbs +50 -0
  61. data/sig/cosmo/client.rbs +21 -3
  62. data/sig/cosmo/config.rbs +3 -15
  63. data/sig/cosmo/job/processor.rbs +16 -8
  64. data/sig/cosmo/processor.rbs +26 -0
  65. data/sig/cosmo/stream/processor.rbs +4 -10
  66. data/sig/cosmo/utils/hash.rbs +0 -8
  67. data/sig/cosmo/utils/ttl_cache.rbs +20 -0
  68. metadata +62 -3
  69. data/lib/cosmo/defaults.yml +0 -69
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/job"
4
+
5
+ module Cosmo
6
+ module API
7
+ class Stream
8
+ LIMIT = 20
9
+
10
+ include Enumerable
11
+
12
+ def self.all
13
+ client.list_streams.map { new(_1.dig("config", "name")) }
14
+ end
15
+
16
+ def self.jobs
17
+ client.list_streams.select { _1.dig("config", "metadata", "_cosmo.type") == "jobs" }
18
+ .reject { %w[scheduled dead].include?(_1.dig("config", "name")) }
19
+ .map { new(_1.dig("config", "name")) }
20
+ end
21
+
22
+ def self.client
23
+ @client ||= Client.instance
24
+ end
25
+
26
+ attr_reader :name
27
+
28
+ def initialize(name)
29
+ @name = name
30
+ end
31
+
32
+ def info
33
+ info = client.stream_info(name)
34
+ { state: info.state, config: info.config }
35
+ end
36
+
37
+ def total
38
+ info[:state].messages.to_i
39
+ rescue StandardError
40
+ 0
41
+ end
42
+ alias size total
43
+
44
+ def retries
45
+ client.list_consumers(name).sum { _1["num_redelivered"].to_i }
46
+ end
47
+
48
+ def each
49
+ return if total.zero?
50
+
51
+ state = info[:state]
52
+ current = @offset || state.first_seq.to_i
53
+ last = state.last_seq.to_i
54
+
55
+ loop do
56
+ break if current > last
57
+
58
+ job = message(current)
59
+ break unless job
60
+
61
+ yield job
62
+ current += 1
63
+ end
64
+ end
65
+
66
+ def offset(value)
67
+ @offset = value.to_i
68
+ self
69
+ end
70
+
71
+ def messages(page: nil, limit: nil)
72
+ jobs = []
73
+ limit = (limit || LIMIT).to_i
74
+ state = info[:state]
75
+ start = state.first_seq.to_i
76
+ start += (page.to_i - 1) * limit if page
77
+
78
+ offset(start).each do |message|
79
+ jobs << message
80
+ break if jobs.size >= limit
81
+ end
82
+
83
+ jobs
84
+ end
85
+
86
+ def message(seq)
87
+ Job.new(name, client.get_message(name, seq: seq, direct: true))
88
+ rescue NATS::JetStream::Error::NotFound
89
+ # nop, acked/nacked
90
+ end
91
+
92
+ def retry(seq)
93
+ job = message(seq)
94
+ return unless job
95
+
96
+ client.publish(job.x_subject, job.message.data)
97
+ delete(seq)
98
+ end
99
+
100
+ def delete(seq)
101
+ client.delete_message(name, seq)
102
+ end
103
+
104
+ def pause!
105
+ client.pause_stream(name)
106
+ end
107
+
108
+ def unpause!
109
+ client.unpause_stream(name)
110
+ end
111
+
112
+ def paused?
113
+ client.stream_paused?(name)
114
+ end
115
+
116
+ private
117
+
118
+ def client
119
+ self.class.client
120
+ end
121
+ end
122
+ end
123
+ end
data/lib/cosmo/api.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/stream"
4
+ require "cosmo/api/counter"
5
+ require "cosmo/api/kv"
6
+ require "cosmo/api/stats"
7
+
8
+ module Cosmo
9
+ module API
10
+ end
11
+ end
data/lib/cosmo/cli.rb CHANGED
@@ -102,13 +102,16 @@ module Cosmo
102
102
  load_config(flags)
103
103
  boot_application
104
104
 
105
- Config[:streams].each do |name, config|
106
- Client.instance.stream_info(name)
107
- rescue NATS::JetStream::Error::NotFound
108
- Client.instance.create_stream(name, config)
105
+ Config[:setup]&.each_value do |configs|
106
+ configs.each do |name, config|
107
+ Client.instance.stream_info(name)
108
+ rescue NATS::JetStream::Error::NotFound
109
+ meta = { metadata: { "_cosmo.type" => "jobs" } }
110
+ Client.instance.create_stream(name, config.merge(meta))
111
+ end
109
112
  end
110
113
 
111
- puts "Cosmo streams were created/updated"
114
+ puts "Cosmo streams were created successfully"
112
115
  exit(0)
113
116
  end
114
117
 
data/lib/cosmo/client.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nats/client"
4
+ require "cosmo/utils/overrides"
4
5
 
5
6
  module Cosmo
6
7
  class Client
@@ -37,16 +38,70 @@ module Cosmo
37
38
  js.delete_stream(name, params)
38
39
  end
39
40
 
41
+ def update_stream(name, config)
42
+ js.update_stream(name: name, **config)
43
+ end
44
+
40
45
  def list_streams
41
46
  response = nc.request("$JS.API.STREAM.LIST", "")
42
47
  data = Utils::Json.parse(response.data, symbolize_names: false)
43
48
  return [] if data.nil? || data["streams"].nil?
44
49
 
45
- data["streams"].filter_map { _1.dig("config", "name") }
50
+ data["streams"]
51
+ end
52
+
53
+ def pause_stream(name)
54
+ config = stream_info(name).config.to_h
55
+ config[:metadata] ||= {}
56
+ config[:metadata][:"_cosmo.paused"] = "true"
57
+ update_stream(name, config)
58
+ end
59
+
60
+ def unpause_stream(name)
61
+ config = stream_info(name).config.to_h
62
+ config[:metadata] ||= {}
63
+ config[:metadata].delete(:"_cosmo.paused")
64
+ update_stream(name, config)
65
+ end
66
+
67
+ def stream_paused?(name)
68
+ stream_info(name).config.metadata&.[](:"_cosmo.paused") == "true"
69
+ rescue NATS::IO::Timeout
70
+ false
71
+ end
72
+
73
+ def list_consumers(stream_name)
74
+ response = nc.request("$JS.API.CONSUMER.LIST.#{stream_name}", "")
75
+ data = Utils::Json.parse(response.data, default: {}, symbolize_names: false)
76
+ Array(data["consumers"])
77
+ end
78
+
79
+ def consumer_info(stream_name, consumer_name)
80
+ js.consumer_info(stream_name, consumer_name)
81
+ end
82
+
83
+ def get_message(name, **options)
84
+ js.get_msg(name, **options)
85
+ end
86
+
87
+ def delete_message(name, seq)
88
+ response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
89
+ Utils::Json.parse(response.data, symbolize_names: false)
90
+ end
91
+
92
+ def purge(stream_name, subject)
93
+ payload = subject ? Utils::Json.dump({ filter: subject }) : ""
94
+ response = @nc.request("$JS.API.STREAM.PURGE.#{stream_name}", payload)
95
+ result = Utils::Json.parse(response.data, default: {}, symbolize_names: false)
96
+ raise NATS::JetStream::Error, result.dig("error", "description") if result["error"]
97
+
98
+ result["purged"] # number of messages purged
46
99
  end
47
100
 
48
- def get_message(name, seq)
49
- js.get_msg(name, seq: seq)
101
+ def kv(name, **options)
102
+ js.key_value(name)
103
+ rescue NATS::KeyValue::BucketNotFoundError
104
+ js.create_key_value({ bucket: name }.merge(options))
50
105
  end
51
106
 
52
107
  def close
data/lib/cosmo/config.rb CHANGED
@@ -4,7 +4,7 @@ require "yaml"
4
4
  require "forwardable"
5
5
 
6
6
  module Cosmo
7
- class Config
7
+ class Config < ::Hash
8
8
  NANO = 1_000_000_000
9
9
  DEFAULT_PATH = "config/cosmo.yml"
10
10
 
@@ -18,7 +18,7 @@ module Cosmo
18
18
  YAML.load_file(path, aliases: true).tap { normalize!(_1) }
19
19
  end
20
20
 
21
- def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
21
+ def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
22
22
  Utils::Hash.symbolize_keys!(config)
23
23
 
24
24
  config[:consumers]&.each_key do |name|
@@ -30,11 +30,13 @@ module Cosmo
30
30
  end
31
31
  end
32
32
 
33
- config[:streams]&.each_key do |name|
34
- c = config[:streams][name]
35
- c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
36
- c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
37
- c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
33
+ config[:setup]&.each_key do |type|
34
+ config[:setup][type]&.each_key do |name|
35
+ c = config[:setup][type][name]
36
+ c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
37
+ c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
38
+ c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
39
+ end
38
40
  end
39
41
  end
40
42
 
@@ -57,45 +59,18 @@ module Cosmo
57
59
  @instance ||= new
58
60
  end
59
61
 
60
- def self.system
61
- @system ||= {}
62
- end
63
-
64
- def initialize
65
- @config = nil
66
- @system = {}
67
- @defaults = self.class.parse_file(File.expand_path("defaults.yml", __dir__))
68
- end
69
-
70
- def [](key)
71
- dig(key)
72
- end
73
-
74
- def fetch(key, default = nil)
75
- return @config.fetch(key, default) if @config && Utils::Hash.keys?(@config, key)
76
-
77
- @defaults.fetch(key, default)
78
- end
79
-
80
- def dig(*keys)
81
- return @config&.dig(*keys) if @config && Utils::Hash.keys?(@config, *keys)
82
-
83
- @defaults.dig(*keys)
84
- end
85
-
86
- def to_h
87
- Utils::Hash.merge(@defaults, @config)
62
+ def self.internal
63
+ @internal ||= {}
88
64
  end
89
65
 
90
66
  def set(...)
91
- @config ||= {}
92
- Utils::Hash.set(@config, ...)
67
+ Utils::Hash.set(self, ...)
93
68
  end
94
69
 
95
70
  def load(path = nil)
96
71
  return unless path
97
72
 
98
- @config = self.class.parse_file(path)
73
+ replace(self.class.parse_file(path))
99
74
  end
100
75
  end
101
76
  end
data/lib/cosmo/engine.rb CHANGED
@@ -25,7 +25,7 @@ module Cosmo
25
25
 
26
26
  def run(type, options)
27
27
  handler = Utils::Signal.trap(:INT, :TERM)
28
- Logger.info "Starting processing, hit Ctrl-C to stop"
28
+ Logger.info "Starting processing, hit Ctrl-C to stop [concurrency=#{@concurrency}]"
29
29
 
30
30
  processor_classes = type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
31
31
  @processors = processor_classes.map { _1.run(@pool, @running, options) }
@@ -3,63 +3,27 @@
3
3
  module Cosmo
4
4
  module Job
5
5
  class Processor < ::Cosmo::Processor
6
- def initialize(pool, running, options)
7
- super
8
- @weights = []
9
- end
10
-
11
6
  private
12
7
 
13
- def run_loop
14
- Thread.new { work_loop }
15
- Thread.new { schedule_loop }
16
- end
17
-
18
8
  def setup
19
9
  jobs_config = Config.dig(:consumers, :jobs)
20
10
  jobs_config&.each do |stream_name, config|
21
- consumer_name = "consumer-#{stream_name}"
22
- subject = config.delete(:subject)
23
- priority = config.delete(:priority)
24
- @weights += ([stream_name] * priority.to_i) if priority
25
- subscription = client.subscribe(subject, consumer_name, config)
26
- @consumers << [subscription, stream_name]
27
- end
28
- end
11
+ next if stream_name == :scheduled # scheduled jobs are handled in schedule_loop
29
12
 
30
- def work_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
31
- shutdown = false
32
-
33
- while running?
34
- break if shutdown
35
-
36
- @weights.shuffle.each do |stream_name|
37
- break unless running?
38
-
39
- begin
40
- timeout = ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
41
- @pool.post do
42
- subscription = @consumers.find { |(_, sn)| sn == stream_name }&.first
43
- messages = fetch(subscription, batch_size: 1, timeout:)
44
- process(messages) if messages&.any?
45
- end
46
- rescue Concurrent::RejectedExecutionError
47
- shutdown = true
48
- break # pool doesn't accept new jobs, we are shutting down
49
- end
50
-
51
- break unless running?
52
- end
13
+ @consumers << subscribe(stream_name, config)
53
14
  end
54
15
  end
55
16
 
56
17
  def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
18
+ config = Config.dig(:consumers, :jobs, :scheduled)
19
+ return unless config
20
+
21
+ subscription, = subscribe(:scheduled, config)
57
22
  while running?
58
23
  break unless running?
59
24
 
60
25
  now = Time.now.to_i
61
26
  timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
62
- subscription = @consumers.find { |(_, sn)| sn == :scheduled }&.first
63
27
  messages = fetch(subscription, batch_size: 100, timeout:)
64
28
  messages&.each do |message|
65
29
  headers = message.header.except("X-Stream", "X-Subject", "X-Execute-At", "Nats-Expected-Stream")
@@ -80,22 +44,24 @@ module Cosmo
80
44
  end
81
45
  end
82
46
 
83
- def process(messages) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
47
+ def process(messages, _) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
84
48
  message = messages.first
85
49
  Logger.debug "received messages #{messages.inspect}"
86
50
  data = Utils::Json.parse(message.data)
87
51
  unless data
88
- Logger.debug ArgumentError.new("malformed payload")
52
+ Logger.error ArgumentError.new("malformed payload")
53
+ move_message(message)
89
54
  return
90
55
  end
91
56
 
92
57
  worker_class = Utils::String.safe_constantize(data[:class])
93
58
  unless worker_class
94
- Logger.debug ArgumentError.new("#{data[:class]} class not found")
59
+ Logger.error ArgumentError.new("#{data[:class]} class not found")
60
+ move_message(message, data)
95
61
  return
96
62
  end
97
63
 
98
- begin
64
+ with_stats(message) do
99
65
  sw = stopwatch
100
66
  Logger.with(jid: data[:jid])
101
67
  Logger.info "start"
@@ -104,10 +70,12 @@ module Cosmo
104
70
  instance.perform(*data[:args])
105
71
  message.ack
106
72
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
73
+ true
107
74
  rescue StandardError => e
108
75
  Logger.debug e
109
76
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
110
- handle_failure(message, data)
77
+ dropped = handle_failure(message, data)
78
+ false if dropped
111
79
  rescue Exception # rubocop:disable Lint/RescueException
112
80
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
113
81
  raise
@@ -117,24 +85,65 @@ module Cosmo
117
85
  Logger.debug "processed message #{message.inspect}"
118
86
  end
119
87
 
120
- def handle_failure(message, data) # rubocop:disable Metrics/AbcSize
88
+ def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
121
89
  current_attempt = message.metadata.num_delivered
122
90
  max_retries = data[:retry].to_i + 1
123
91
 
124
92
  if current_attempt < max_retries
125
- # NATS will auto-retry based on max_deliver with exponential backoff
93
+ # NATS will auto-retry with delay (exponential backoff based on current attempt).
94
+ # When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
95
+ # The message is effectively abandoned by NATS — it stays in the stream (consuming a slot) but will never be delivered again to that consumer.
126
96
  delay_ns = ((current_attempt**4) + 15) * 1_000_000_000
127
97
  message.nak(delay: delay_ns)
128
- return
98
+ return false
129
99
  end
130
100
 
131
- if data[:dead]
132
- Client.instance.publish("jobs.dead.#{Utils::String.underscore(data[:class])}", message.data)
133
- message.ack
134
- Logger.debug "job moved #{data[:jid]} to DLQ"
135
- else
136
- message.term
137
- Logger.debug "job dropped #{data[:jid]}"
101
+ data[:dead] ? move_message(message, data) : drop_message(message, data)
102
+ true
103
+ end
104
+
105
+ def subscribe(stream_name, config)
106
+ config = config.dup
107
+ config[:batch_size] = 1
108
+ config[:stream] = stream_name
109
+ consumer_name = "consumer-#{stream_name}"
110
+ subscription = client.subscribe(config[:subject], consumer_name, config.except(:subject, :priority, :stream, :batch_size))
111
+ [subscription, config, nil]
112
+ end
113
+
114
+ def drop_message(message, data)
115
+ message.term
116
+ Logger.debug "job dropped #{data[:jid]}"
117
+ end
118
+
119
+ def move_message(message, data = nil)
120
+ klass = data ? Utils::String.underscore(data[:class]) : "default"
121
+ headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
122
+ Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
123
+ message.ack
124
+ Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
125
+ end
126
+
127
+ def scheduler?
128
+ true
129
+ end
130
+
131
+ def consumers
132
+ @weights ||= @consumers.filter_map { |(_, c, _)| [c[:stream]] * [c[:priority].to_i, 1].max }.flatten
133
+ @weights.shuffle.map { |s| @consumers.find { |(_, c, _)| c[:stream] == s } }
134
+ end
135
+
136
+ def fetch_subjects(config)
137
+ config[:subject]
138
+ end
139
+
140
+ def fetch_timeout(_config)
141
+ ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
142
+ end
143
+
144
+ def with_stats(message, &block)
145
+ API::Busy.instance.with(message) do
146
+ API::Counter.instance.with(&block)
138
147
  end
139
148
  end
140
149
  end
data/lib/cosmo/job.rb CHANGED
@@ -54,7 +54,7 @@ module Cosmo
54
54
  end
55
55
  end
56
56
 
57
- attr_reader :jid
57
+ attr_accessor :jid
58
58
 
59
59
  def perform(...)
60
60
  raise NotImplementedError, "#{self.class}#perform must be implemented"
data/lib/cosmo/logger.rb CHANGED
@@ -60,7 +60,14 @@ module Cosmo
60
60
  end
61
61
 
62
62
  def self.instance
63
- @instance ||= ::Logger.new($stdout).tap { _1.formatter = SimpleFormatter.new }
63
+ @instance ||= ::Logger.new($stdout).tap do |logger|
64
+ logger.formatter = SimpleFormatter.new
65
+ logger.level = ::Logger::Severity.coerce(ENV.fetch("COSMO_LOG_LEVEL", "info"))
66
+ end
67
+ end
68
+
69
+ def self.instance=(logger)
70
+ @instance = logger
64
71
  end
65
72
  end
66
73
  end