hyperion-rb 1.0.0.rc17

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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/notification'
5
+ require 'protocol/http2/server'
6
+ require 'protocol/http2/framer'
7
+ require 'protocol/http2/stream'
8
+
9
+ module Hyperion
10
+ # Real HTTP/2 dispatch driven by `protocol-http2`.
11
+ #
12
+ # Each TLS connection that negotiated `h2` via ALPN ends up here. We frame
13
+ # the socket, read the connection preface, and then drive a frame loop on
14
+ # the connection's fiber: it reads one frame at a time and lets
15
+ # `protocol-http2` update its connection/stream state machines. As soon as
16
+ # a client stream finishes its request half (state `:half_closed_remote`
17
+ # via `end_stream?`), we hand the stream off to a sibling fiber for
18
+ # dispatch — slow handlers no longer block other streams on the same
19
+ # connection.
20
+ #
21
+ # All framer writes (HEADERS, DATA, RST_STREAM) are serialized through a
22
+ # single connection-scoped Mutex (`@send_mutex`). The OpenSSL::SSL::SSLSocket
23
+ # underneath is not safe to drive from two fibers concurrently, and
24
+ # protocol-http2's HPACK encoder is also stateful across HEADERS frames,
25
+ # so all sends must be serialized.
26
+ #
27
+ # Flow control: `RequestStream#window_updated` overrides the protocol-http2
28
+ # default to fan a notification out to any fiber blocked in `send_body`
29
+ # waiting for the remote peer's flow-control window to grow. The body
30
+ # writer chunks the response payload by the per-stream available frame
31
+ # size and yields on the notification when the window is exhausted, so
32
+ # large bodies never trip a FlowControlError.
33
+ class Http2Handler
34
+ # Per-stream subclass that captures decoded request pseudo-headers,
35
+ # regular headers, and any DATA frame body bytes for later dispatch.
36
+ # Also exposes a `window_available` notification fan-out so the
37
+ # response-writer fiber can sleep until WINDOW_UPDATE arrives.
38
+ class RequestStream < ::Protocol::HTTP2::Stream
39
+ attr_reader :request_headers, :request_body, :request_complete
40
+
41
+ def initialize(*)
42
+ super
43
+ @request_headers = []
44
+ @request_body = +''
45
+ @request_complete = false
46
+ @window_available = ::Async::Notification.new
47
+ end
48
+
49
+ def process_headers(frame)
50
+ decoded = super
51
+ # decoded is an Array of [name, value] pairs (HPACK output).
52
+ decoded.each { |pair| @request_headers << pair }
53
+ @request_complete = true if frame.end_stream?
54
+ decoded
55
+ end
56
+
57
+ def process_data(frame)
58
+ data = super
59
+ # rubocop:disable Rails/Present
60
+ @request_body << data if data && !data.empty?
61
+ # rubocop:enable Rails/Present
62
+ @request_complete = true if frame.end_stream?
63
+ data
64
+ end
65
+
66
+ # Called by protocol-http2 whenever the remote peer's flow-control
67
+ # window opens up — either via a stream-level WINDOW_UPDATE or via the
68
+ # connection-level fan-out in `Connection#consume_window`. We poke the
69
+ # notification so any fiber waiting in `wait_for_window` resumes.
70
+ def window_updated(size)
71
+ @window_available.signal
72
+ super
73
+ end
74
+
75
+ # Block the calling fiber until the remote window grows. Cheap no-op
76
+ # signal each time `window_updated` fires; the caller re-checks
77
+ # available_frame_size in a loop.
78
+ def wait_for_window
79
+ @window_available.wait
80
+ end
81
+ end
82
+
83
+ def initialize(app:, thread_pool: nil)
84
+ @app = app
85
+ @thread_pool = thread_pool
86
+ @metrics = Hyperion.metrics
87
+ @logger = Hyperion.logger
88
+ end
89
+
90
+ def serve(socket)
91
+ @metrics.increment(:connections_accepted)
92
+ @metrics.increment(:connections_active)
93
+ framer = ::Protocol::HTTP2::Framer.new(socket)
94
+ server = build_server(framer)
95
+ server.read_connection_preface
96
+
97
+ # Extract once — the same TCP peer drives every stream on this conn.
98
+ peer_addr = peer_address(socket)
99
+
100
+ # All framer writes (HEADERS / DATA / RST_STREAM / GOAWAY) must be
101
+ # serialized: the underlying SSLSocket is not safe across fibers, and
102
+ # the HPACK encoder is also stateful. The connection's own frame loop
103
+ # uses this mutex too — see `dispatch_stream` and `send_body`.
104
+ send_mutex = ::Mutex.new
105
+
106
+ task = ::Async::Task.current
107
+
108
+ # Track in-flight per-stream dispatch fibers so we can drain them on
109
+ # connection close.
110
+ stream_tasks = []
111
+
112
+ until server.closed?
113
+ ready_ids = []
114
+ server.read_frame do |frame|
115
+ ready_ids << frame.stream_id if frame.stream_id.positive?
116
+ end
117
+
118
+ ready_ids.uniq.each do |sid|
119
+ stream = server.streams[sid]
120
+ next unless stream.is_a?(RequestStream)
121
+ next unless stream.request_complete
122
+ next if stream.closed?
123
+ next if stream.instance_variable_get(:@hyperion_dispatched)
124
+
125
+ # Mark before spawning so we never dispatch the same stream twice
126
+ # if subsequent frames (e.g. RST_STREAM races) arrive.
127
+ stream.instance_variable_set(:@hyperion_dispatched, true)
128
+
129
+ stream_tasks << task.async do
130
+ dispatch_stream(stream, send_mutex, peer_addr)
131
+ end
132
+ end
133
+ end
134
+
135
+ # Drain in-flight stream dispatches before we close the socket.
136
+ stream_tasks.each do |t|
137
+ t.wait
138
+ rescue StandardError
139
+ nil
140
+ end
141
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
142
+ # Peer disconnect — nothing to do.
143
+ rescue ::Protocol::HTTP2::GoawayError, ::Protocol::HTTP2::ProtocolError, ::Protocol::HTTP2::HandshakeError
144
+ # Protocol-level error — protocol-http2 has already emitted GOAWAY.
145
+ rescue StandardError => e
146
+ @logger.error do
147
+ {
148
+ message: 'h2 connection error',
149
+ error: e.message,
150
+ error_class: e.class.name,
151
+ backtrace: (e.backtrace || []).first(10).join(' | ')
152
+ }
153
+ end
154
+ ensure
155
+ @metrics.decrement(:connections_active)
156
+ socket.close unless socket.closed?
157
+ end
158
+
159
+ private
160
+
161
+ def build_server(framer)
162
+ server = ::Protocol::HTTP2::Server.new(framer)
163
+ server.define_singleton_method(:accept_stream) do |stream_id, &block|
164
+ unless valid_remote_stream_id?(stream_id)
165
+ raise ::Protocol::HTTP2::ProtocolError, "Invalid stream id: #{stream_id}"
166
+ end
167
+
168
+ if block
169
+ create_stream(stream_id, &block)
170
+ else
171
+ create_stream(stream_id) { |conn, id| RequestStream.create(conn, id) }
172
+ end
173
+ end # quiet rubocop unused-warning placeholder; not actually returned
174
+ server
175
+ end
176
+
177
+ def dispatch_stream(stream, send_mutex, peer_addr = nil)
178
+ pseudo, regular = partition_pseudo(stream.request_headers)
179
+
180
+ method = pseudo[':method'] || 'GET'
181
+ path_raw = pseudo[':path'] || '/'
182
+ authority = pseudo[':authority']
183
+ path, query = path_raw.split('?', 2)
184
+
185
+ hyperion_headers = regular
186
+ hyperion_headers['host'] ||= authority if authority
187
+
188
+ request = Hyperion::Request.new(
189
+ method: method,
190
+ path: path,
191
+ query_string: query || '',
192
+ http_version: 'HTTP/2',
193
+ headers: hyperion_headers,
194
+ body: stream.request_body,
195
+ peer_address: peer_addr
196
+ )
197
+
198
+ @metrics.increment(:requests_total)
199
+ @metrics.increment(:requests_in_flight)
200
+ status, response_headers, body_chunks = begin
201
+ if @thread_pool
202
+ @thread_pool.call(@app, request)
203
+ else
204
+ Hyperion::Adapter::Rack.call(@app, request)
205
+ end
206
+ ensure
207
+ @metrics.decrement(:requests_in_flight)
208
+ end
209
+
210
+ out_headers = [[':status', status.to_s]]
211
+ response_headers.each do |k, v|
212
+ next if k.to_s.downcase == 'connection' # forbidden in h2
213
+
214
+ Array(v).each do |val|
215
+ val.to_s.split("\n").each do |line|
216
+ out_headers << [k.to_s.downcase, line]
217
+ end
218
+ end
219
+ end
220
+
221
+ payload = +''
222
+ body_chunks.each { |c| payload << c.to_s }
223
+ body_chunks.close if body_chunks.respond_to?(:close)
224
+
225
+ send_mutex.synchronize { stream.send_headers(out_headers) }
226
+ send_body(stream, payload, send_mutex)
227
+ @metrics.increment_status(status)
228
+ rescue StandardError => e
229
+ @metrics.increment(:app_errors)
230
+ @logger.error do
231
+ {
232
+ message: 'h2 stream dispatch failed',
233
+ error: e.message,
234
+ error_class: e.class.name,
235
+ backtrace: (e.backtrace || []).first(10).join(' | ')
236
+ }
237
+ end
238
+ begin
239
+ send_mutex.synchronize { stream.send_reset_stream(::Protocol::HTTP2::Error::INTERNAL_ERROR) }
240
+ rescue StandardError
241
+ nil
242
+ end
243
+ end
244
+
245
+ # Send the response body, respecting the peer's max frame size and
246
+ # per-stream flow-control window. When the window is exhausted, we
247
+ # block the dispatch fiber on the stream's `window_available`
248
+ # notification — protocol-http2 calls `window_updated` on every active
249
+ # stream when WINDOW_UPDATE frames arrive (either stream- or
250
+ # connection-scoped), which signals the notification.
251
+ def send_body(stream, payload, send_mutex)
252
+ if payload.empty?
253
+ send_mutex.synchronize { stream.send_data('', ::Protocol::HTTP2::END_STREAM) }
254
+ return
255
+ end
256
+
257
+ offset = 0
258
+ bytesize = payload.bytesize
259
+ while offset < bytesize
260
+ # `available_frame_size` is the min of the connection's max-frame
261
+ # setting and the smaller of the stream/connection remote windows.
262
+ available = stream.available_frame_size
263
+
264
+ if available <= 0
265
+ # Window exhausted. Wait for WINDOW_UPDATE and re-check.
266
+ stream.wait_for_window
267
+ next
268
+ end
269
+
270
+ chunk = payload.byteslice(offset, available)
271
+ offset += chunk.bytesize
272
+ flags = offset >= bytesize ? ::Protocol::HTTP2::END_STREAM : 0
273
+
274
+ send_mutex.synchronize { stream.send_data(chunk, flags) }
275
+ end
276
+ end
277
+
278
+ # Mirrors Connection#peer_address — see the comment there. SSLSocket
279
+ # wraps a TCPSocket; both expose #peeraddr after handshake.
280
+ def peer_address(socket)
281
+ raw = socket.respond_to?(:io) ? socket.io : socket
282
+ return nil unless raw.respond_to?(:peeraddr)
283
+
284
+ addr = raw.peeraddr
285
+ ip = addr[3] || addr[2]
286
+ return nil if ip.nil? || ip.to_s.empty?
287
+
288
+ ip
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ def partition_pseudo(headers_array)
294
+ pseudo = {}
295
+ regular = {}
296
+ headers_array.each do |pair|
297
+ name, value = pair
298
+ if name.start_with?(':')
299
+ pseudo[name] = value
300
+ else
301
+ regular[name] ||= +''
302
+ regular[name] = if regular[name].empty?
303
+ value.to_s
304
+ else
305
+ "#{regular[name]},#{value}"
306
+ end
307
+ end
308
+ end
309
+ [pseudo, regular]
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module Hyperion
8
+ # Structured logger.
9
+ #
10
+ # Usage:
11
+ # logger = Hyperion::Logger.new
12
+ # logger.info { { message: 'listening', host: '127.0.0.1', port: 9292 } }
13
+ # logger.warn { { message: 'parse error', error: e.message, error_class: e.class.name } }
14
+ # logger.error 'plain string also works for legacy callers'
15
+ #
16
+ # Level is set from:
17
+ # 1. The `level:` constructor kwarg (highest precedence).
18
+ # 2. ENV['HYPERION_LOG_LEVEL'] if set.
19
+ # 3. Defaults to :info.
20
+ #
21
+ # Format is :text (key=value), :json (JSONL), or :auto (default — picks the
22
+ # right one based on the runtime environment, see #pick_format below).
23
+ #
24
+ # Each log line is prefixed with timestamp + level + a 'hyperion' tag so
25
+ # operators can grep multi-process worker output. When the resolved format
26
+ # is :text and the underlying IO is a TTY, level names are ANSI-coloured
27
+ # for readability.
28
+ class Logger
29
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
30
+
31
+ LEVEL_COLORS = {
32
+ debug: "\e[90m", # bright black / grey
33
+ info: "\e[32m", # green
34
+ warn: "\e[33m", # yellow
35
+ error: "\e[31m", # red
36
+ fatal: "\e[35m" # magenta
37
+ }.freeze
38
+ COLOR_RESET = "\e[0m"
39
+
40
+ PRODUCTION_ENVS = %w[production staging].freeze
41
+
42
+ attr_reader :level, :format
43
+
44
+ # Levels at WARN or higher are routed to the error stream (stderr by
45
+ # default). info / debug go to the regular stream (stdout by default).
46
+ # 12-factor: app logs to stdout, errors to stderr.
47
+ ERROR_LEVELS = %i[warn error fatal].freeze
48
+
49
+ def initialize(out: $stdout, err: $stderr, io: nil, level: nil, format: nil)
50
+ # `io:` is a back-compat alias for tests / single-IO use cases — it
51
+ # routes both streams to the same target (e.g. a StringIO in specs).
52
+ @out = io || out
53
+ @err = io || err
54
+ @level = parse_level(level || ENV.fetch('HYPERION_LOG_LEVEL', 'info'))
55
+ requested = format || ENV['HYPERION_LOG_FORMAT']
56
+ @format = pick_format(requested)
57
+ # Colorize when format is text AND the destination is a TTY. We only
58
+ # check the regular stream here — colored text is for humans.
59
+ @colorize = @format == :text && tty?(@out)
60
+ end
61
+
62
+ LEVELS.each_key do |lvl|
63
+ define_method(lvl) do |payload = nil, &block|
64
+ next unless emit?(lvl)
65
+
66
+ actual = block ? block.call : payload
67
+ write(lvl, actual)
68
+ end
69
+ end
70
+
71
+ # Pick the destination IO for a given level.
72
+ # warn / error / fatal → @err (stderr default).
73
+ # debug / info → @out (stdout default).
74
+ def io_for(lvl)
75
+ ERROR_LEVELS.include?(lvl) ? @err : @out
76
+ end
77
+
78
+ def level=(lvl)
79
+ @level = parse_level(lvl)
80
+ end
81
+
82
+ # Per-thread access-log buffer flush threshold. ~32 average-size lines
83
+ # per write(2) call, well under PIPE_BUF (4096) so writes stay atomic.
84
+ # Larger = fewer syscalls but higher latency-to-disk (up to ~32 reqs of
85
+ # delay before the line shows up in the log file). 4 KiB is a good
86
+ # balance: a 16-thread fleet at 24k r/s flushes ~750 buffers/sec total
87
+ # vs ~24 000 syscalls/sec without buffering.
88
+ ACCESS_FLUSH_BYTES = 4096
89
+
90
+ # Hot-path access-log emitter — bypasses the generic format_text /
91
+ # format_json kvs.join + hash#map allocations. The whole line is built
92
+ # via a single interpolation, the timestamp is cached per-thread per
93
+ # millisecond, and we batch lines into a per-thread buffer that flushes
94
+ # when full (lock-free emit; POSIX write(2) is atomic for writes
95
+ # <= PIPE_BUF / 4096 bytes).
96
+ #
97
+ # Returns silently on any IO error — logging must never crash the server.
98
+ def access(method, path, query, status, duration_ms, remote_addr, http_version)
99
+ return unless emit?(:info)
100
+
101
+ ts = cached_timestamp
102
+ line = if @format == :json
103
+ build_access_json(ts, method, path, query, status, duration_ms, remote_addr, http_version)
104
+ else
105
+ build_access_text(ts, method, path, query, status, duration_ms, remote_addr, http_version)
106
+ end
107
+
108
+ buf = (Thread.current[:__hyperion_access_buf__] ||= +'')
109
+ buf << line
110
+ return if buf.bytesize < ACCESS_FLUSH_BYTES
111
+
112
+ @out.write(buf)
113
+ buf.clear
114
+ rescue StandardError
115
+ # Swallow logger failures — never let logging crash the server.
116
+ end
117
+
118
+ # Flush this thread's buffered access-log lines. Called by the connection
119
+ # loop when a connection closes (so log lines from a closing keep-alive
120
+ # session don't get stuck behind the buffer until the next connection).
121
+ def flush_access_buffer
122
+ buf = Thread.current[:__hyperion_access_buf__]
123
+ return if buf.nil? || buf.empty?
124
+
125
+ @out.write(buf)
126
+ buf.clear
127
+ rescue StandardError
128
+ # Swallow logger failures — never let logging crash the server.
129
+ end
130
+
131
+ private
132
+
133
+ # Cached UTC iso8601(3) timestamp, refreshed at most once per millisecond
134
+ # per thread. At 24k r/s with 16 threads we render ~1500 r/s/thread; only
135
+ # ~1000 of those allocate a new String. The other 500 reuse the cached one.
136
+ def cached_timestamp
137
+ now_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
138
+ cache = (Thread.current[:__hyperion_ts_cache__] ||= [-1, ''])
139
+ return cache[1] if cache[0] == now_ms
140
+
141
+ cache[0] = now_ms
142
+ cache[1] = Time.now.utc.iso8601(3)
143
+ cache[1]
144
+ end
145
+
146
+ # Resolve the effective format.
147
+ # 1. If the operator passed an explicit value (kwarg or env), honour it.
148
+ # 2. Else, if the app is running in a production-ish env (RAILS_ENV /
149
+ # RACK_ENV / HYPERION_ENV), default to JSON — log aggregators love it.
150
+ # 3. Else, if stderr is a TTY, default to colored text — humans prefer it.
151
+ # 4. Else (piped/redirected output, no env hint), default to JSON so
152
+ # captured logs are parseable by tooling.
153
+ def pick_format(requested)
154
+ if requested && !requested.to_s.empty? && requested.to_s != 'auto'
155
+ sym = requested.to_s.to_sym
156
+ return sym if %i[text json].include?(sym)
157
+ end
158
+
159
+ env_name = ENV['HYPERION_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV']
160
+ return :json if env_name && PRODUCTION_ENVS.include?(env_name)
161
+ return :text if tty?(@out)
162
+
163
+ :json
164
+ end
165
+
166
+ def tty?(io)
167
+ io.respond_to?(:tty?) && io.tty?
168
+ rescue StandardError
169
+ false
170
+ end
171
+
172
+ def emit?(lvl)
173
+ LEVELS.fetch(lvl) >= LEVELS.fetch(@level)
174
+ end
175
+
176
+ def write(lvl, payload)
177
+ hash = case payload
178
+ when Hash then payload
179
+ when nil then { message: '' }
180
+ else { message: payload.to_s }
181
+ end
182
+
183
+ line = case @format
184
+ when :json then format_json(lvl, hash)
185
+ else format_text(lvl, hash)
186
+ end
187
+
188
+ # No mutex: POSIX write(2) is atomic for writes <= PIPE_BUF (4096 bytes)
189
+ # on regular FDs, pipes, and sockets. A single log line is ~200 bytes.
190
+ # Ruby's IO#write is a thin wrapper; concurrent threads writing short
191
+ # lines don't interleave bytes within a line. Skipping flush, too:
192
+ # stdout/stderr are line-buffered or unbuffered respectively when
193
+ # attached to a terminal, and removing the syscall saves ~30% of the
194
+ # logger overhead.
195
+ io_for(lvl).write(line)
196
+ rescue StandardError
197
+ # Swallow logger failures — never let logging crash the server.
198
+ end
199
+
200
+ def format_text(lvl, hash)
201
+ ts = cached_timestamp
202
+ level_label = lvl.to_s.upcase.ljust(5)
203
+ level_label = "#{LEVEL_COLORS[lvl]}#{level_label}#{COLOR_RESET}" if @colorize
204
+ kvs = hash.map { |k, v| "#{k}=#{format_value(v)}" }.join(' ')
205
+ "#{ts} #{level_label} [hyperion] #{kvs}\n"
206
+ end
207
+
208
+ def format_json(lvl, hash)
209
+ hash = { ts: cached_timestamp, level: lvl.to_s, source: 'hyperion' }.merge(hash)
210
+ "#{JSON.generate(hash)}\n"
211
+ end
212
+
213
+ # Hand-rolled text access-log line — single interpolation, no Hash#map,
214
+ # no Array#join. Matches the structured-hash format users got in rc9
215
+ # (key=value pairs starting with `message=request`) so existing log
216
+ # parsers keep working.
217
+ def build_access_text(ts, method, path, query, status, duration_ms, remote_addr, http_version)
218
+ level_label = @colorize ? "#{LEVEL_COLORS[:info]}INFO #{COLOR_RESET}" : 'INFO '
219
+ addr = remote_addr || 'nil'
220
+ query_part = query.nil? || query.empty? ? '' : " query=#{quote_if_needed(query)}"
221
+ "#{ts} #{level_label} [hyperion] message=request method=#{method} path=#{path}#{query_part} " \
222
+ "status=#{status} duration_ms=#{duration_ms} remote_addr=#{addr} http_version=#{http_version}\n"
223
+ end
224
+
225
+ # Hand-rolled JSON access-log line — single interpolation, skips
226
+ # JSON.generate's Hash walk. The values we write are all server-controlled
227
+ # (method/path/status/etc) and well-formed; we only need to escape path
228
+ # and query. Status/duration_ms are numeric, not quoted.
229
+ def build_access_json(ts, method, path, query, status, duration_ms, remote_addr, http_version)
230
+ query_field = query.nil? || query.empty? ? '' : %(,"query":#{json_str(query)})
231
+ addr_field = remote_addr.nil? ? 'null' : json_str(remote_addr)
232
+ %({"ts":"#{ts}","level":"info","source":"hyperion","message":"request",) +
233
+ %("method":"#{method}","path":#{json_str(path)}#{query_field},) +
234
+ %("status":#{status},"duration_ms":#{duration_ms},"remote_addr":#{addr_field},) +
235
+ %("http_version":"#{http_version}"}\n)
236
+ end
237
+
238
+ # Cheap JSON string serializer — defers to JSON.generate for any value
239
+ # that needs escaping (control chars, quotes, backslashes). Hot path
240
+ # (paths like /, /api/v1/games) skips JSON.generate entirely.
241
+ def json_str(value)
242
+ s = value.to_s
243
+ return %("#{s}") if s.match?(%r{\A[A-Za-z0-9._\-/?=&%:+,@!~*';()\[\] ]*\z})
244
+
245
+ JSON.generate(s)
246
+ end
247
+
248
+ # Mirror format_value's quoting rule for access-log query strings.
249
+ def quote_if_needed(value)
250
+ s = value.to_s
251
+ s.match?(/[\s"=]/) ? s.inspect : s
252
+ end
253
+
254
+ def format_value(v)
255
+ case v
256
+ when nil then 'nil'
257
+ when String
258
+ v.match?(/[\s"=]/) ? v.inspect : v
259
+ else
260
+ v.to_s
261
+ end
262
+ end
263
+
264
+ def parse_level(lvl)
265
+ sym = lvl.to_s.downcase.to_sym
266
+ LEVELS.key?(sym) ? sym : :info
267
+ end
268
+ end
269
+ end