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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +149 -8
- data/lib/generators/wide_events/install/install_generator.rb +16 -2
- data/lib/generators/wide_events/skill_installer.rb +24 -0
- data/lib/generators/wide_events/skills/skills_generator.rb +9 -7
- data/lib/generators/wide_events/store/store_generator.rb +145 -0
- data/lib/generators/wide_events/store/templates/accessory.yml.erb +33 -0
- data/lib/wide_event/configuration.rb +64 -3
- data/lib/wide_event/job_instrumentation.rb +2 -0
- data/lib/wide_event/kamal/deploy_editor.rb +499 -0
- data/lib/wide_event/kamal/secrets_editor.rb +103 -0
- data/lib/wide_event/middleware.rb +2 -0
- data/lib/wide_event/railtie.rb +8 -0
- data/lib/wide_event/registry/defaults.yml +5 -0
- data/lib/wide_event/setup/checker.rb +402 -0
- data/lib/wide_event/setup/command_runner.rb +25 -0
- data/lib/wide_event/sinks/store.rb +33 -0
- data/lib/wide_event/store/client.rb +258 -0
- data/lib/wide_event/store/envelope.rb +60 -0
- data/lib/wide_event/store/formatter.rb +60 -0
- data/lib/wide_event/store/query_result.rb +19 -0
- data/lib/wide_event/store/sender.rb +448 -0
- data/lib/wide_event/tasks/setup.rake +55 -0
- data/lib/wide_event/tasks/store.rake +122 -0
- data/lib/wide_event/version.rb +1 -1
- data/lib/wide_event.rb +21 -0
- data/skills/debugging-with-wide-events/SKILL.md +92 -7
- data/skills/setting-up-wide-events-store/SKILL.md +120 -0
- metadata +36 -6
|
@@ -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
|