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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE +21 -0
- data/README.md +260 -0
- data/bin/hyperion +6 -0
- data/ext/hyperion_http/extconf.rb +19 -0
- data/ext/hyperion_http/llhttp/api.c +509 -0
- data/ext/hyperion_http/llhttp/http.c +170 -0
- data/ext/hyperion_http/llhttp/llhttp.c +10103 -0
- data/ext/hyperion_http/llhttp/llhttp.h +907 -0
- data/ext/hyperion_http/parser.c +428 -0
- data/lib/hyperion/adapter/rack.rb +143 -0
- data/lib/hyperion/c_parser.rb +19 -0
- data/lib/hyperion/cli.rb +151 -0
- data/lib/hyperion/config.rb +107 -0
- data/lib/hyperion/connection.rb +338 -0
- data/lib/hyperion/fiber_local.rb +104 -0
- data/lib/hyperion/http2_handler.rb +312 -0
- data/lib/hyperion/logger.rb +269 -0
- data/lib/hyperion/master.rb +221 -0
- data/lib/hyperion/metrics.rb +68 -0
- data/lib/hyperion/parser.rb +128 -0
- data/lib/hyperion/pool.rb +34 -0
- data/lib/hyperion/request.rb +25 -0
- data/lib/hyperion/response_writer.rb +98 -0
- data/lib/hyperion/server.rb +198 -0
- data/lib/hyperion/thread_pool.rb +116 -0
- data/lib/hyperion/tls.rb +29 -0
- data/lib/hyperion/version.rb +5 -0
- data/lib/hyperion/worker.rb +91 -0
- data/lib/hyperion.rb +82 -0
- metadata +193 -0
|
@@ -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
|