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,402 @@
1
+ require "yaml"
2
+ require "json"
3
+ require "zlib"
4
+ require "ipaddr"
5
+ require "resolv"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "uri"
9
+ require "securerandom"
10
+ require "stringio"
11
+ require "wide_event/setup/command_runner"
12
+ require "wide_event/kamal/deploy_editor"
13
+ require "wide_event/kamal/secrets_editor"
14
+ require "wide_event/store/client"
15
+ require "wide_event/store/envelope"
16
+ require "wide_event/store/query_result"
17
+ require "wide_event/store/formatter"
18
+
19
+ module WideEvent
20
+ module Setup
21
+ # The single entry point behind `bin/rails wide_events:setup:check`.
22
+ # Re-derives everything it needs from the app's own generated files
23
+ # (config/deploy.yml, .kamal/wide-events-*-token) so the same command is
24
+ # correct with no flags whether it is run before the first `kamal
25
+ # setup`, while waiting on DNS, or against an already-deployed store.
26
+ #
27
+ # Every check that would otherwise touch the outside world - running
28
+ # `kamal`, sleeping, resolving DNS, or calling the store over HTTP - goes
29
+ # through an injected seam (runner/sleeper/resolver/http_get/
30
+ # client_factory) so tests never shell out, sleep for real, or hit a
31
+ # real network.
32
+ class Checker
33
+ MIN_KAMAL_VERSION = WideEvent::Kamal::DeployEditor::MINIMUM_VERSION
34
+ DNS_TIMEOUT_SECONDS = 120
35
+ DNS_POLL_INTERVAL_SECONDS = 3
36
+ RESUME_COMMAND = "bin/rails wide_events:setup:check"
37
+ DEPLOY_YML_RELATIVE_PATH = "config/deploy.yml"
38
+ OPEN_TIMEOUT = 3
39
+ READ_TIMEOUT = 5
40
+ QUERY_DEADLINE = 15
41
+
42
+ NETWORK_ERRORS = [
43
+ Net::OpenTimeout, Net::ReadTimeout, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET,
44
+ SocketError, OpenSSL::SSL::SSLError, EOFError, IOError
45
+ ].freeze
46
+
47
+ ROUTE_STATS_SQL = <<~SQL.strip
48
+ SELECT route, count(*) AS requests, quantile_cont(duration_ms, 0.95) AS p95_ms
49
+ FROM wide_events
50
+ WHERE occurred_at > current_timestamp - INTERVAL 1 DAY
51
+ GROUP BY route
52
+ ORDER BY requests DESC
53
+ LIMIT 20
54
+ SQL
55
+
56
+ Result = Struct.new(:state, :checks, :message, keyword_init: true) do
57
+ def success?
58
+ state == "healthy"
59
+ end
60
+ end
61
+
62
+ def initialize(root:, runner: CommandRunner.new, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
63
+ sleeper: ->(seconds) { sleep(seconds) }, resolver: method(:default_resolve),
64
+ http_get: method(:default_http_get), client_factory: method(:default_client_factory),
65
+ uuid: -> { SecureRandom.uuid }, wall_clock: -> { Time.now })
66
+ @root = root.to_s
67
+ @runner = runner
68
+ @clock = clock
69
+ @sleeper = sleeper
70
+ @resolver = resolver
71
+ @http_get = http_get
72
+ @client_factory = client_factory
73
+ @uuid = uuid
74
+ @wall_clock = wall_clock
75
+ end
76
+
77
+ def run
78
+ checks = {}
79
+
80
+ config = load_deploy_config
81
+ unless config[:ok]
82
+ checks[:deploy_config] = false
83
+ return build_result("failed", checks, config[:error])
84
+ end
85
+ checks[:deploy_config] = true
86
+
87
+ version = check_kamal_version
88
+ checks[:kamal_version] = version[:ok]
89
+ return build_result("failed", checks, version[:message]) unless version[:ok]
90
+
91
+ kamal_config = check_kamal_config
92
+ checks[:kamal_config] = kamal_config[:ok]
93
+ return build_result("failed", checks, kamal_config[:message]) unless kamal_config[:ok]
94
+
95
+ directory = check_host_directory(config)
96
+ checks[:host_directory] = directory[:ok]
97
+ return build_result("failed", checks, directory[:message]) unless directory[:ok]
98
+
99
+ dns = check_dns(config)
100
+ checks[:dns] = dns[:ok]
101
+ return build_result(dns[:state], checks, dns[:message]) unless dns[:ok]
102
+
103
+ endpoint = check_endpoint(config)
104
+ checks[:endpoint] = endpoint[:state] == :healthy
105
+ case endpoint[:state]
106
+ when :ready_to_deploy then return build_result("ready_to_deploy", checks, endpoint[:message])
107
+ when :degraded then return build_result("degraded", checks, endpoint[:message])
108
+ when :failed then return build_result("failed", checks, endpoint[:message])
109
+ end
110
+
111
+ synthetic = run_synthetic_round_trip(config)
112
+ checks[:synthetic] = synthetic[:ok]
113
+ return build_result("failed", checks, synthetic[:message]) unless synthetic[:ok]
114
+
115
+ build_result("healthy", checks, synthetic[:message])
116
+ end
117
+
118
+ private
119
+
120
+ def build_result(state, checks, message)
121
+ Result.new(state: state, checks: checks, message: message)
122
+ end
123
+
124
+ # ---- config/deploy.yml -----------------------------------------------
125
+
126
+ def load_deploy_config
127
+ path = File.join(@root, DEPLOY_YML_RELATIVE_PATH)
128
+ unless File.exist?(path)
129
+ return not_generated_yet("config/deploy.yml was not found")
130
+ end
131
+
132
+ parsed = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
133
+ accessory = parsed.is_a?(Hash) ? parsed.dig("accessories", "wide_events") : nil
134
+ return not_generated_yet("config/deploy.yml has no wide_events accessory") unless accessory.is_a?(Hash)
135
+
136
+ host = accessory["host"]
137
+ hostname = accessory.dig("proxy", "host")
138
+ service = parsed["service"]
139
+ environment = accessory.dig("env", "clear", "WIDE_EVENTS_ENVIRONMENT") || "production"
140
+
141
+ if blank?(host) || blank?(hostname) || blank?(service)
142
+ return not_generated_yet("config/deploy.yml's wide_events accessory is missing host, hostname, or service")
143
+ end
144
+
145
+ { ok: true, host: host, hostname: hostname, service: service, environment: environment }
146
+ rescue Psych::Exception => e
147
+ { ok: false, error: "config/deploy.yml could not be parsed (#{e.message})" }
148
+ end
149
+
150
+ def not_generated_yet(reason)
151
+ { ok: false, error: "#{reason} - run `bin/rails generate wide_events:store` first" }
152
+ end
153
+
154
+ def blank?(value)
155
+ value.to_s.strip.empty?
156
+ end
157
+
158
+ # ---- Kamal version/config/directory preflights -----------------------
159
+
160
+ def check_kamal_version
161
+ result = @runner.call(%w[kamal version])
162
+ unless result.success?
163
+ return { ok: false, message: "could not run `kamal version` (#{result.stderr.to_s.strip}); " \
164
+ "install Kamal #{MIN_KAMAL_VERSION} or newer" }
165
+ end
166
+
167
+ version_text = result.stdout.to_s[/\d+\.\d+\.\d+/]
168
+ if version_text.nil?
169
+ return { ok: false, message: "could not parse a version from `kamal version` output " \
170
+ "(#{result.stdout.to_s.strip.inspect})" }
171
+ end
172
+
173
+ if Gem::Version.new(version_text) < Gem::Version.new(MIN_KAMAL_VERSION)
174
+ return { ok: false, message: "Kamal #{version_text} is installed; wide_events requires " \
175
+ "#{MIN_KAMAL_VERSION} or newer - upgrade with `gem install kamal -v '>= #{MIN_KAMAL_VERSION}'`" }
176
+ end
177
+
178
+ { ok: true }
179
+ end
180
+
181
+ def check_kamal_config
182
+ result = @runner.call(%w[kamal config])
183
+ return { ok: true } if result.success?
184
+ { ok: false, message: result.stderr.to_s.strip }
185
+ end
186
+
187
+ # Because setting the accessory's data-directory ownership requires
188
+ # root on the store host, this preflights directory creation before
189
+ # `kamal setup` ever boots the accessory - a deploy user who cannot
190
+ # create/chown it stops here with the exact one-time command an
191
+ # administrator needs to run instead of booting a store that will
192
+ # later fail to persist data.
193
+ def check_host_directory(config)
194
+ directory = "/var/lib/#{config[:service]}-wide-events"
195
+ argv = [ "kamal", "server", "exec", "--hosts=#{config[:host]}", "--",
196
+ "install", "-d", "-m", "0750", "-o", "1000", "-g", "1000", directory ]
197
+ result = @runner.call(argv)
198
+ return { ok: true } if result.success?
199
+
200
+ {
201
+ ok: false,
202
+ message: "the deploy user cannot create #{directory} as uid/gid 1000:1000 " \
203
+ "(#{result.stderr.to_s.strip}). Ask an administrator to run this once:\n\n #{argv.join(" ")}\n"
204
+ }
205
+ end
206
+
207
+ # ---- DNS ---------------------------------------------------------------
208
+
209
+ # Two different failure modes here, deliberately kept distinct:
210
+ #
211
+ # - The *store host* (the SSH address recorded by the store generator)
212
+ # doesn't resolve at all. That is a config error - a mistyped
213
+ # address never starts resolving no matter how long you wait - so it
214
+ # is "failed", not resumable, and points at the host setting.
215
+ # - The *telemetry hostname* hasn't propagated to point at the store
216
+ # host yet. That is expected and resumable, so a timeout here (after
217
+ # polling every DNS_POLL_INTERVAL_SECONDS for up to
218
+ # DNS_TIMEOUT_SECONDS monotonic seconds) is "needs_dns" with the
219
+ # record still needed and the exact resume command. Generated files
220
+ # are never touched in either case.
221
+ def check_dns(config)
222
+ target_addresses = resolve_target_addresses(config[:host])
223
+ if target_addresses.empty?
224
+ return { ok: false, state: "failed",
225
+ message: "could not resolve the store host #{config[:host]} to an address - " \
226
+ "check the host set on the wide_events accessory in config/deploy.yml " \
227
+ "(re-run `bin/rails generate wide_events:store --host=...` if it's wrong)" }
228
+ end
229
+
230
+ started = @clock.call
231
+ loop do
232
+ resolved = Array(@resolver.call(config[:hostname]))
233
+ return { ok: true } if (resolved & target_addresses).any?
234
+
235
+ elapsed = @clock.call - started
236
+ if elapsed >= DNS_TIMEOUT_SECONDS
237
+ return { ok: false, state: "needs_dns", message: dns_timeout_message(config, target_addresses) }
238
+ end
239
+
240
+ @sleeper.call(DNS_POLL_INTERVAL_SECONDS)
241
+ end
242
+ end
243
+
244
+ def dns_timeout_message(config, target_addresses)
245
+ target = target_addresses.first
246
+ record_type = target.include?(":") ? "AAAA" : "A"
247
+ "DNS has not resolved after #{DNS_TIMEOUT_SECONDS} seconds. Create a #{record_type} record: " \
248
+ "#{config[:hostname]} -> #{target}. Once it propagates, run `#{RESUME_COMMAND}` to resume - " \
249
+ "generated files are unchanged and nothing else needs to be redone."
250
+ end
251
+
252
+ def resolve_target_addresses(host)
253
+ return [ host ] if ip_address?(host)
254
+ Array(@resolver.call(host))
255
+ end
256
+
257
+ def ip_address?(value)
258
+ IPAddr.new(value)
259
+ true
260
+ rescue IPAddr::Error
261
+ false
262
+ end
263
+
264
+ def default_resolve(hostname)
265
+ Resolv::DNS.open { |dns| dns.getaddresses(hostname).map(&:to_s) }
266
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout
267
+ []
268
+ end
269
+
270
+ # ---- Deployed endpoint (/up) --------------------------------------------
271
+
272
+ def check_endpoint(config)
273
+ uri = URI("https://#{config[:hostname]}/up")
274
+ response = @http_get.call(uri)
275
+
276
+ if response[:error] == :connection_refused || response[:status] == 404
277
+ return { state: :ready_to_deploy,
278
+ message: "#{config[:hostname]} is not serving the store yet - run `kamal setup`, then " \
279
+ "`#{RESUME_COMMAND}` again to verify it." }
280
+ end
281
+
282
+ if response[:error]
283
+ return { state: :failed,
284
+ message: "could not reach https://#{config[:hostname]}/up (#{response[:message]})" }
285
+ end
286
+
287
+ unless response[:status] == 200
288
+ return { state: :failed, message: "https://#{config[:hostname]}/up returned HTTP #{response[:status]}" }
289
+ end
290
+
291
+ body = parse_json(response[:body])
292
+ unless body.is_a?(Hash)
293
+ return { state: :failed, message: "https://#{config[:hostname]}/up returned an unparsable response" }
294
+ end
295
+
296
+ protocol_version = body["protocol_version"]
297
+ unless protocol_version == 1
298
+ return { state: :failed, message: "the store speaks protocol #{protocol_version.inspect}; " \
299
+ "wide_events expects 1 - upgrade the gem or the pinned store image" }
300
+ end
301
+
302
+ unless body["write_ready"] == true
303
+ reason = body["degradation_reason"] || "unknown reason"
304
+ return { state: :degraded,
305
+ message: "#{config[:hostname]} is routable but not accepting writes (#{reason}) - " \
306
+ "check `wide_events_store_status` on the store (`kamal telemetry`)" }
307
+ end
308
+
309
+ { state: :healthy }
310
+ end
311
+
312
+ def parse_json(body)
313
+ JSON.parse(body.to_s)
314
+ rescue JSON::ParserError, TypeError
315
+ nil
316
+ end
317
+
318
+ def default_http_get(uri)
319
+ http = Net::HTTP.new(uri.host, uri.port)
320
+ http.use_ssl = uri.scheme == "https"
321
+ http.open_timeout = OPEN_TIMEOUT
322
+ http.read_timeout = READ_TIMEOUT
323
+ response = http.get(uri.request_uri)
324
+ { status: response.code.to_i, body: response.body }
325
+ rescue Errno::ECONNREFUSED
326
+ { error: :connection_refused }
327
+ rescue *NETWORK_ERRORS => e
328
+ { error: :network, message: e.message }
329
+ end
330
+
331
+ # ---- Synthetic round trip -----------------------------------------------
332
+
333
+ def run_synthetic_round_trip(config)
334
+ ingest_token = read_token(WideEvent::Kamal::SecretsEditor::INGEST_TOKEN_PATH)
335
+ query_token = read_token(WideEvent::Kamal::SecretsEditor::QUERY_TOKEN_PATH)
336
+ unless ingest_token && query_token
337
+ return { ok: false, message: "missing #{WideEvent::Kamal::SecretsEditor::INGEST_TOKEN_PATH} or " \
338
+ "#{WideEvent::Kamal::SecretsEditor::QUERY_TOKEN_PATH} - run `bin/rails generate wide_events:store` again" }
339
+ end
340
+
341
+ client = @client_factory.call(url: "https://#{config[:hostname]}", ingest_token: ingest_token, query_token: query_token)
342
+
343
+ id = @uuid.call
344
+ event_json = WideEvent::Store::Envelope.build(
345
+ { "duration_ms" => 0.0, "http.route.controller" => "wide_events:setup:check" },
346
+ clock: @wall_clock, uuid: -> { id }, env: {}
347
+ )
348
+ client.ingest(Zlib.gzip(build_batch(event_json, config)))
349
+
350
+ row_result = client.query("SELECT id, occurred_at, route, duration_ms FROM wide_events WHERE id = '#{id}'",
351
+ format: :json, deadline: QUERY_DEADLINE)
352
+ if row_result.rows.empty?
353
+ return { ok: false, message: "sent a synthetic event (#{id}) but could not read it back by id" }
354
+ end
355
+
356
+ message = render_synthetic_result(client, row_result)
357
+ { ok: true, message: message }
358
+ rescue WideEvent::Store::Client::Error => e
359
+ { ok: false, message: "synthetic round trip failed: #{e.message}" }
360
+ end
361
+
362
+ def build_batch(event_json, config)
363
+ envelope = {
364
+ "protocol_version" => 1,
365
+ "batch_id" => @uuid.call,
366
+ "service" => config[:service],
367
+ "environment" => config[:environment],
368
+ "sent_at" => @wall_clock.call.utc.iso8601(3),
369
+ "dropped_events_since_last_batch" => 0,
370
+ "events" => [ JSON.parse(event_json) ]
371
+ }
372
+ JSON.generate(envelope)
373
+ end
374
+
375
+ def render_synthetic_result(client, row_result)
376
+ io = StringIO.new
377
+ io.puts "store is healthy; captured synthetic event:"
378
+ WideEvent::Store::Formatter.table(row_result, io: io)
379
+ io.puts
380
+ io.puts "route count / p95 latency (last 24h):"
381
+ begin
382
+ stats = client.query(ROUTE_STATS_SQL, format: :json, deadline: QUERY_DEADLINE)
383
+ WideEvent::Store::Formatter.table(stats, io: io)
384
+ rescue WideEvent::Store::Client::Error => e
385
+ io.puts "(could not run the route-count/p95 query: #{e.message})"
386
+ end
387
+ io.string
388
+ end
389
+
390
+ def read_token(relative_path)
391
+ path = File.join(@root, relative_path)
392
+ return nil unless File.exist?(path)
393
+ content = File.read(path).strip
394
+ content.empty? ? nil : content
395
+ end
396
+
397
+ def default_client_factory(url:, ingest_token:, query_token:)
398
+ WideEvent::Store::Client.new(url: url, ingest_token: ingest_token, query_token: query_token)
399
+ end
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,25 @@
1
+ require "open3"
2
+
3
+ module WideEvent
4
+ module Setup
5
+ # Runs one external command (Kamal, in practice) via Open3.capture3 and
6
+ # reduces it to the three things WideEvent::Setup::Checker actually
7
+ # needs: whether it succeeded, and its stdout/stderr text. `argv` is
8
+ # always an array of literal strings (the executable and its arguments,
9
+ # never a single shell string), so no argument is ever re-interpreted by
10
+ # a shell. This is the only piece of Checker that ever shells out; every
11
+ # test replaces it with a fake that returns canned Results instead.
12
+ class CommandRunner
13
+ Result = Struct.new(:stdout, :stderr, :success, keyword_init: true) do
14
+ def success?
15
+ !!success
16
+ end
17
+ end
18
+
19
+ def call(argv)
20
+ stdout, stderr, status = Open3.capture3(*argv)
21
+ Result.new(stdout: stdout, stderr: stderr, success: status.success?)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ require "securerandom"
2
+
3
+ module WideEvent
4
+ module Sinks
5
+ # Delivers the wide event to the DuckDB store: builds the canonical
6
+ # event envelope (WideEvent::Store::Envelope) and hands the resulting
7
+ # JSON to the bounded, PID-aware sender. Never performs network I/O on
8
+ # the calling (application) thread and never changes the custom-sink
9
+ # interface: #flush(attrs) is the only method other sinks need.
10
+ class Store
11
+ def initialize(sender:, clock: -> { Time.now }, uuid: -> { SecureRandom.uuid }, env: ENV)
12
+ @sender = sender
13
+ @clock = clock
14
+ @uuid = uuid
15
+ @env = env
16
+ end
17
+
18
+ def flush(attrs)
19
+ json = ::WideEvent::Store::Envelope.build(attrs, clock: @clock, uuid: @uuid, env: @env)
20
+ @sender.enqueue(json)
21
+ nil
22
+ rescue StandardError => e
23
+ WideEvent.handle_error(e, "store_sink_flush")
24
+ nil
25
+ end
26
+
27
+ # Delegates to the sender's best-effort shutdown flush.
28
+ def shutdown(timeout: ::WideEvent::Store::Sender::SHUTDOWN_TIMEOUT)
29
+ @sender.shutdown(timeout: timeout)
30
+ end
31
+ end
32
+ end
33
+ end