wide_events 0.1.4 → 0.2.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,258 @@
1
+ require "net/http"
2
+ require "openssl"
3
+ require "uri"
4
+ require "json"
5
+
6
+ module WideEvent
7
+ module Store
8
+ # Minimal standard-library HTTPS client for POST /v1/events
9
+ # (store/internal/httpapi/server.go) and POST /v1/query
10
+ # (store/internal/httpapi/query.go). One instance issues one request at
11
+ # a time. #ingest is called only from WideEvent::Store::Sender's worker
12
+ # thread; #query is called from the wide_events:sql Rails task on
13
+ # whatever thread invokes it (there is no persistent worker for
14
+ # queries).
15
+ class Client
16
+ # Tags a failure as retryable (timeout/network, 429, 507, 5xx) or
17
+ # permanent (other 4xx). Never carries the ingest token or an event
18
+ # body in its message.
19
+ class Error < StandardError
20
+ def initialize(message, retryable:)
21
+ super(message)
22
+ @retryable = retryable
23
+ end
24
+
25
+ def retryable?
26
+ @retryable
27
+ end
28
+ end
29
+
30
+ Ack = Struct.new(:protocol_version, :batch_id, :duplicate, :warnings, keyword_init: true)
31
+
32
+ OPEN_TIMEOUT = 1
33
+ READ_TIMEOUT = 2
34
+ MAX_RESPONSE_BYTES = 64 * 1024
35
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
36
+ INGEST_PATH = "/v1/events"
37
+ QUERY_PATH = "/v1/query"
38
+ QUERY_PROTOCOL_VERSION = 1
39
+
40
+ # The store caps its own query responses at 10 MiB
41
+ # (store/internal/query/executor.go's defaultMaxResponseBytes); we read
42
+ # one byte past that so a response that actually reaches the cap+1
43
+ # byte is distinguishable from one that legitimately ends at (or
44
+ # under) 10 MiB, instead of silently parsing a truncated body.
45
+ QUERY_RESPONSE_CAP = 10 * 1024 * 1024
46
+ DEFAULT_QUERY_DEADLINE = 30
47
+ QUERY_FORMATS = %w[json csv].freeze
48
+ RETRY_AFTER_DEFAULT = 1
49
+ RETRY_AFTER_CAP = 10
50
+
51
+ NETWORK_ERRORS = [
52
+ Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
53
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE, SocketError,
54
+ OpenSSL::SSL::SSLError, EOFError, IOError
55
+ ].freeze
56
+
57
+ # Token presence is validated per-operation (in #ingest and #query),
58
+ # not here: a Client built only to query has no ingest token to give
59
+ # it, and vice versa. The constructor still validates the URL, since
60
+ # that's shared by both operations and needed before either can run.
61
+ def initialize(url:, ingest_token: nil, query_token: nil, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT)
62
+ @uri = URI.parse(url.to_s)
63
+ raise ArgumentError, "store url must be an absolute http(s) URL" unless @uri.host
64
+
65
+ unless https?(@uri) || loopback?(@uri)
66
+ raise ArgumentError, "store url must use HTTPS (plain http is only allowed for loopback testing)"
67
+ end
68
+
69
+ @ingest_token = ingest_token.to_s
70
+ @query_token = query_token.to_s
71
+ @open_timeout = open_timeout
72
+ @read_timeout = read_timeout
73
+ end
74
+
75
+ # Sends one already-gzipped canonical batch body. Returns an Ack on a
76
+ # 200 response; raises Error (tagged retryable?) otherwise.
77
+ def ingest(gzip_body)
78
+ raise ArgumentError, "store ingest token is required" if @ingest_token.empty?
79
+ code, body = perform(gzip_body)
80
+ handle_response(code, body)
81
+ end
82
+
83
+ # Runs one read-only statement. Retries a busy (429) response, bounded
84
+ # by the server's Retry-After header, until `deadline` monotonic
85
+ # seconds have elapsed overall; policy/limit errors (400/422/503) are
86
+ # never retried here. Returns a QueryResult for format: :json, or the
87
+ # server's CSV text verbatim for format: :csv.
88
+ def query(sql, format: :json, deadline: DEFAULT_QUERY_DEADLINE)
89
+ raise ArgumentError, "store query token is required" if @query_token.empty?
90
+ format = format.to_s
91
+ raise ArgumentError, "query format must be :json or :csv" unless QUERY_FORMATS.include?(format)
92
+
93
+ deadline_at = monotonic_now + deadline
94
+ loop do
95
+ code, body, retry_after = perform_query(sql, format)
96
+ if code == 429
97
+ sleep(bounded_retry_wait(retry_after, deadline_at))
98
+ next
99
+ end
100
+ return handle_query_response(code, body, format)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ # Ruby's Net::HTTP unconditionally finishes reading the response body
107
+ # itself (via HTTPResponse#reading_body's trailing `self.body` call)
108
+ # once the block passed to #request returns *normally* — a plain
109
+ # `break` inside #read_body's block only stops OUR loop; Net::HTTP
110
+ # then tries to read the REST of the body anyway, which would hang
111
+ # forever against a server that never stops sending. Raising instead
112
+ # unwinds past that trailing read entirely; we catch it below.
113
+ BoundedBodyExceeded = Class.new(StandardError) do
114
+ attr_reader :buffer
115
+
116
+ def initialize(buffer, max_bytes)
117
+ @buffer = buffer
118
+ super("response body exceeded #{max_bytes} bytes")
119
+ end
120
+ end
121
+
122
+ def perform(gzip_body)
123
+ request = Net::HTTP::Post.new(INGEST_PATH)
124
+ request["Authorization"] = "Bearer #{@ingest_token}"
125
+ request["Content-Type"] = "application/json"
126
+ request["Content-Encoding"] = "gzip"
127
+ request.body = gzip_body
128
+
129
+ code, body, = perform_request(request, MAX_RESPONSE_BYTES)
130
+ [ code, body ]
131
+ rescue *NETWORK_ERRORS => e
132
+ raise Error.new("store request failed (#{e.class})", retryable: true)
133
+ end
134
+
135
+ def perform_query(sql, format)
136
+ request = Net::HTTP::Post.new(QUERY_PATH)
137
+ request["Authorization"] = "Bearer #{@query_token}"
138
+ request["Content-Type"] = "application/json"
139
+ request.body = JSON.generate(protocol_version: QUERY_PROTOCOL_VERSION, sql: sql, format: format)
140
+
141
+ code, body, response = perform_request(request, QUERY_RESPONSE_CAP + 1)
142
+ [ code, body, response && response["Retry-After"] ]
143
+ rescue *NETWORK_ERRORS => e
144
+ raise Error.new("store query request failed (#{e.class})", retryable: true)
145
+ end
146
+
147
+ # Shared connect/send/bounded-read path for both endpoints. Returns
148
+ # the response object too (query needs its Retry-After header; ingest
149
+ # ignores it) — the caller must not read the body of that object
150
+ # again, since #read_body has already been drained into `body`.
151
+ def perform_request(request, max_bytes)
152
+ http = Net::HTTP.new(@uri.host, @uri.port)
153
+ http.use_ssl = https?(@uri)
154
+ http.open_timeout = @open_timeout
155
+ http.read_timeout = @read_timeout
156
+
157
+ code = nil
158
+ body = nil
159
+ captured_response = nil
160
+ begin
161
+ http.start do |connection|
162
+ connection.request(request) do |response|
163
+ code = response.code.to_i
164
+ captured_response = response
165
+ body = read_bounded(response, max_bytes)
166
+ end
167
+ end
168
+ rescue BoundedBodyExceeded => e
169
+ body = e.buffer
170
+ end
171
+ [ code, body, captured_response ]
172
+ end
173
+
174
+ def read_bounded(response, max_bytes)
175
+ buffer = "".b
176
+ response.read_body do |chunk|
177
+ remaining = max_bytes - buffer.bytesize
178
+ buffer << (chunk.bytesize > remaining ? chunk.byteslice(0, remaining) : chunk)
179
+ raise BoundedBodyExceeded.new(buffer, max_bytes) if buffer.bytesize >= max_bytes
180
+ end
181
+ buffer
182
+ end
183
+
184
+ def handle_response(code, body)
185
+ case code
186
+ when 200 then parse_ack(body)
187
+ when 429, 507, 500..599 then raise Error.new("store responded with #{code}", retryable: true)
188
+ else raise Error.new("store responded with #{code}", retryable: false)
189
+ end
190
+ end
191
+
192
+ def handle_query_response(code, body, format)
193
+ case code
194
+ when 200
195
+ format == "csv" ? validate_csv_size(body) : parse_query_result(body)
196
+ when 400 then raise Error.new("query was rejected (invalid SQL, or not read-only)", retryable: false)
197
+ when 422 then raise Error.new("query exceeded a store limit (rows, time, or response size)", retryable: false)
198
+ when 503 then raise Error.new("store is temporarily unavailable (maintenance)", retryable: true)
199
+ else raise Error.new("store responded with #{code}", retryable: false)
200
+ end
201
+ end
202
+
203
+ def validate_csv_size(body)
204
+ raise Error.new("store query response exceeded the #{QUERY_RESPONSE_CAP} byte cap", retryable: false) if body.bytesize > QUERY_RESPONSE_CAP
205
+ body
206
+ end
207
+
208
+ def parse_query_result(body)
209
+ raise Error.new("store query response exceeded the #{QUERY_RESPONSE_CAP} byte cap", retryable: false) if body.bytesize > QUERY_RESPONSE_CAP
210
+ json = JSON.parse(body)
211
+ QueryResult.new(
212
+ columns: Array(json["columns"]),
213
+ rows: Array(json["rows"]).map { |row| Array(row) },
214
+ truncated: json["truncated"] == true
215
+ )
216
+ rescue JSON::ParserError, TypeError
217
+ raise Error.new("store returned an unparsable query result", retryable: false)
218
+ end
219
+
220
+ # `retry_after` is the raw (possibly nil/blank/malformed) header
221
+ # value; a missing or non-numeric value falls back to
222
+ # RETRY_AFTER_DEFAULT rather than trusting an unbounded string.
223
+ # Whatever it resolves to is clamped to RETRY_AFTER_CAP and to
224
+ # whatever time remains before `deadline_at`.
225
+ def bounded_retry_wait(retry_after, deadline_at)
226
+ remaining = deadline_at - monotonic_now
227
+ raise Error.new("store is busy and the query deadline elapsed", retryable: true) if remaining <= 0
228
+ seconds = Integer(retry_after) rescue nil
229
+ seconds = RETRY_AFTER_DEFAULT if seconds.nil? || seconds.negative?
230
+ [ seconds, RETRY_AFTER_CAP, remaining ].min
231
+ end
232
+
233
+ def monotonic_now
234
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
235
+ end
236
+
237
+ def parse_ack(body)
238
+ json = JSON.parse(body)
239
+ Ack.new(
240
+ protocol_version: json["protocol_version"],
241
+ batch_id: json["batch_id"],
242
+ duplicate: json["duplicate"] == true,
243
+ warnings: Array(json["warnings"])
244
+ )
245
+ rescue JSON::ParserError, TypeError
246
+ raise Error.new("store returned an unparsable acknowledgement", retryable: true)
247
+ end
248
+
249
+ def https?(uri)
250
+ uri.scheme == "https"
251
+ end
252
+
253
+ def loopback?(uri)
254
+ uri.scheme == "http" && LOOPBACK_HOSTS.include?(uri.host)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,60 @@
1
+ require "json"
2
+ require "socket"
3
+
4
+ module WideEvent
5
+ module Store
6
+ # Renders one flushed wide-event hash as the canonical event JSON the
7
+ # DuckDB store's protocol decoder expects (store/internal/protocol/events.go):
8
+ # an `id`, UTC RFC3339-with-milliseconds `occurred_at`, `kind`
9
+ # ("request"/"job"), required `duration_ms`, optional `deployment`/
10
+ # `source`, and an `attributes` map of sanitized primitives.
11
+ #
12
+ # Pure and synchronous: no I/O, no mutation of the input, and no
13
+ # WideEvent.handle_error rescue of its own — callers that send events
14
+ # over the network (Task 8) rescue StandardError around #build and route
15
+ # it through WideEvent.handle_error themselves.
16
+ module Envelope
17
+ MAX_ATTRIBUTES = 256
18
+ MAX_EVENT_BYTES = 64 * 1024
19
+
20
+ class << self
21
+ def build(attrs, clock:, uuid:, env:)
22
+ attributes = WideEvent.sanitize(attrs)
23
+ duration_ms = attributes.delete("duration_ms")
24
+ raise ArgumentError, "duration_ms is required" unless duration_ms.is_a?(Numeric)
25
+
26
+ if attributes.size > MAX_ATTRIBUTES
27
+ raise ArgumentError, "event has #{attributes.size} attributes, max #{MAX_ATTRIBUTES}"
28
+ end
29
+
30
+ event = {
31
+ "id" => uuid.call,
32
+ "occurred_at" => clock.call.utc.iso8601(3),
33
+ "kind" => attributes["job.class"] ? "job" : "request",
34
+ "duration_ms" => duration_ms,
35
+ "deployment" => deployment(env),
36
+ "source" => source(env),
37
+ "attributes" => attributes
38
+ }.compact
39
+
40
+ json = JSON.generate(event)
41
+ if json.bytesize > MAX_EVENT_BYTES
42
+ raise ArgumentError, "event is #{json.bytesize} bytes, max #{MAX_EVENT_BYTES}"
43
+ end
44
+ json.freeze
45
+ end
46
+
47
+ private
48
+
49
+ def deployment(env)
50
+ value = env["KAMAL_VERSION"]
51
+ value if value.present?
52
+ end
53
+
54
+ def source(env)
55
+ [ env["KAMAL_CONTAINER_NAME"], env["HOSTNAME"] ].find(&:present?) || Socket.gethostname
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ require "json"
2
+ require "csv"
3
+
4
+ module WideEvent
5
+ module Store
6
+ # Renders a bounded QueryResult for a terminal (table), for machine
7
+ # consumption (json), or for a spreadsheet/pipe (csv). Every format
8
+ # reads the whole (already-capped) result once; none of them stream or
9
+ # re-request data, and none of them ever emit ANSI escape sequences —
10
+ # the table formatter is plain text whether or not `io` is a TTY.
11
+ module Formatter
12
+ NULL_TEXT = "NULL"
13
+
14
+ def self.table(result, io:)
15
+ header = result.columns
16
+ body_rows = result.rows.map { |row| row.map { |value| value.nil? ? NULL_TEXT : value.to_s } }
17
+ widths = column_widths(header, body_rows)
18
+
19
+ io.puts(row_line(header, widths))
20
+ io.puts(separator_line(widths))
21
+ body_rows.each { |row| io.puts(row_line(row, widths)) }
22
+ io.puts("(truncated: the store capped this result)") if result.truncated
23
+ end
24
+
25
+ def self.json(result, io:)
26
+ io.puts(JSON.generate(columns: result.columns, rows: result.rows, truncated: result.truncated))
27
+ end
28
+
29
+ def self.csv(result, io:)
30
+ io.print(CSV.generate_line(result.columns))
31
+ result.rows.each do |row|
32
+ # Pass `nil` through as-is (CSV renders it as an empty, unquoted
33
+ # field) rather than the string "" (which CSV quotes as `""` to
34
+ # keep it distinct from a genuinely absent field) — matching the
35
+ # store's own CSV null rendering (query/executor.go's csvValue).
36
+ io.print(CSV.generate_line(row.map { |value| value.nil? ? nil : value.to_s }))
37
+ end
38
+ end
39
+
40
+ def self.column_widths(header, body_rows)
41
+ widths = header.map(&:length)
42
+ body_rows.each do |row|
43
+ row.each_with_index { |value, index| widths[index] = [ widths[index], value.length ].max }
44
+ end
45
+ widths
46
+ end
47
+ private_class_method :column_widths
48
+
49
+ def self.row_line(values, widths)
50
+ "| " + values.each_with_index.map { |value, index| value.ljust(widths[index]) }.join(" | ") + " |"
51
+ end
52
+ private_class_method :row_line
53
+
54
+ def self.separator_line(widths)
55
+ "+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+"
56
+ end
57
+ private_class_method :separator_line
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,19 @@
1
+ module WideEvent
2
+ module Store
3
+ # An immutable, already-bounded result of a JSON /v1/query request
4
+ # (store/internal/httpapi/query.go's `{"columns":...,"rows":...,"truncated":...}`
5
+ # body, parsed). `rows` is an array of arrays in column order; a `nil`
6
+ # element is a SQL NULL. `truncated` is true when the store cut the
7
+ # result off at its row or response-size limit.
8
+ class QueryResult
9
+ attr_reader :columns, :rows, :truncated
10
+
11
+ def initialize(columns:, rows:, truncated:)
12
+ @columns = columns.map(&:to_s).freeze
13
+ @rows = rows.map { |row| row.freeze }.freeze
14
+ @truncated = truncated == true
15
+ freeze
16
+ end
17
+ end
18
+ end
19
+ end