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.
@@ -33,6 +33,7 @@ module WideEvent
33
33
  "job.queue_latency_ms" => enqueued ? ((Time.now - enqueued) * 1000).round : nil,
34
34
  "job.scheduled" => wide_event_scheduled?
35
35
  }.merge(WideEvent.uptime_attributes))
36
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
37
  begin
37
38
  block.call
38
39
  rescue Exception => e
@@ -40,6 +41,7 @@ module WideEvent
40
41
  "exception.message" => e.message.to_s[0, 500])
41
42
  raise
42
43
  ensure
44
+ WideEvent.set("duration_ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2))
43
45
  WideEvent.flush(attrs)
44
46
  end
45
47
  end
@@ -0,0 +1,499 @@
1
+ require "yaml"
2
+ require "erb"
3
+
4
+ module WideEvent
5
+ module Kamal
6
+ # Applies the wide_events Kamal accessory to a host application's
7
+ # config/deploy.yml without disturbing unrelated comments, ordering, or
8
+ # sections. Detection of structure and conflicts uses Psych.safe_load;
9
+ # every actual mutation is a targeted line insertion/replacement on the
10
+ # original text so byte-identical content survives everywhere we did not
11
+ # intentionally touch it.
12
+ #
13
+ # Anything the line-based patcher cannot safely reason about (ERB, YAML
14
+ # aliases/merge keys, tabs, duplicate root keys, non-mapping env/
15
+ # accessories/aliases, an existing non-owned wide_events accessory,
16
+ # conflicting WIDE_EVENTS_* values, or any other shape this editor was
17
+ # not built to understand) causes a refusal: the original content is
18
+ # returned completely unchanged, alongside a complete mergeable snippet
19
+ # the caller can hand to a human or an agent.
20
+ class DeployEditor
21
+ MINIMUM_VERSION = "2.10.0"
22
+ BEGIN_MARKER = "# BEGIN wide_events store"
23
+ END_MARKER = "# END wide_events store"
24
+
25
+ CLEAR_VARS = %w[WIDE_EVENTS_URL WIDE_EVENTS_SERVICE WIDE_EVENTS_ENVIRONMENT].freeze
26
+ SECRET_VARS = %w[WIDE_EVENTS_INGEST_TOKEN WIDE_EVENTS_QUERY_TOKEN].freeze
27
+
28
+ ALIAS_TELEMETRY_KEY = "telemetry"
29
+ ALIAS_TELEMETRY_LINE = 'telemetry: app exec -i --reuse "bin/rails wide_events:sql"'
30
+ ALIAS_BACKUP_KEY = "wide-events-backup"
31
+ # --reuse is load-bearing: without it Kamal's `accessory exec` starts a
32
+ # *new* container from the store image, where (a) the image entrypoint
33
+ # already is /usr/local/bin/wide-events, so the appended command becomes
34
+ # argv[0] and main exits 2 with usage, and (b) there is no running
35
+ # server holding /run/wide-events/admin.sock to take the backup through.
36
+ # With --reuse this is a `docker exec` into the live accessory container,
37
+ # which is the only place an on-demand backup can be requested.
38
+ ALIAS_BACKUP_LINE = 'wide-events-backup: accessory exec wide_events --reuse "wide-events admin backup"'
39
+
40
+ ACCESSORY_TEMPLATE_PATH = File.expand_path(
41
+ "../../generators/wide_events/store/templates/accessory.yml.erb", __dir__
42
+ )
43
+
44
+ ROOT_KEY_RE = /\A([A-Za-z_][A-Za-z0-9_.-]*):(.*)\n?\z/
45
+
46
+ Result = Struct.new(:content, :changed, :snippet, :conflicts, keyword_init: true) do
47
+ def changed?
48
+ !!changed
49
+ end
50
+ end
51
+
52
+ # Raised internally to unwind straight to a refusal; never escapes #apply.
53
+ Refusal = Class.new(StandardError)
54
+
55
+ def initialize(content, service:, host:, hostname:, environment: "production", retention_days: 30, image: nil)
56
+ @original = content.to_s
57
+ @service = service
58
+ @host = host
59
+ @hostname = hostname
60
+ @environment = environment
61
+ @retention_days = retention_days
62
+ @image = image || "ghcr.io/adammiribyan/wide-events-store:#{WideEvent::VERSION}"
63
+ end
64
+
65
+ def apply
66
+ parsed = preflight!
67
+ lines = @original.lines
68
+ changed = false
69
+
70
+ changed |= apply_minimum_version(lines)
71
+
72
+ env_range = root_block_range(lines, "env")
73
+ changed |= apply_env_in_place(lines, env_range) if env_range
74
+
75
+ aliases_range = root_block_range(lines, "aliases")
76
+ changed |= apply_aliases_in_place(lines, aliases_range, parsed["aliases"] || {}) if aliases_range
77
+
78
+ accessories_range = root_block_range(lines, "accessories")
79
+ changed |= apply_accessories_in_place(lines, accessories_range) if accessories_range
80
+
81
+ changed |= append_env_section(lines) unless env_range
82
+ changed |= append_aliases_section(lines) unless aliases_range
83
+ changed |= append_accessories_section(lines) unless accessories_range
84
+
85
+ new_content = lines.join
86
+ verify_parseable!(new_content)
87
+
88
+ Result.new(content: new_content, changed: changed, snippet: nil, conflicts: [])
89
+ rescue Refusal => e
90
+ Result.new(content: @original, changed: false, snippet: build_snippet, conflicts: [ e.message ])
91
+ end
92
+
93
+ private
94
+
95
+ # ---- Preflight -----------------------------------------------------
96
+
97
+ def preflight!
98
+ raise Refusal, "config/deploy.yml contains ERB tags" if @original.include?("<%")
99
+ raise Refusal, "config/deploy.yml contains tab characters" if @original.include?("\t")
100
+
101
+ parsed = safe_parse(@original)
102
+ raise Refusal, "config/deploy.yml's root document is not a mapping" unless parsed.is_a?(Hash)
103
+ raise Refusal, "config/deploy.yml has duplicate root keys" if duplicate_root_keys?(@original)
104
+
105
+ raise_if_inline_value!(parsed, "env")
106
+ raise_if_inline_value!(parsed, "accessories")
107
+ raise_if_inline_value!(parsed, "aliases")
108
+
109
+ %w[env accessories aliases].each do |key|
110
+ value = parsed[key]
111
+ next if value.nil?
112
+
113
+ raise Refusal, "config/deploy.yml's #{key} key is not a mapping" unless value.is_a?(Hash)
114
+ end
115
+
116
+ env = parsed["env"] || {}
117
+ if env.key?("clear") && !env["clear"].is_a?(Hash)
118
+ raise Refusal, "config/deploy.yml's env.clear key is not a mapping"
119
+ end
120
+ if env.key?("secret") && !env["secret"].is_a?(Array)
121
+ raise Refusal, "config/deploy.yml's env.secret key is not a sequence"
122
+ end
123
+
124
+ clear = env["clear"] || {}
125
+ CLEAR_VARS.each do |key|
126
+ next unless clear.key?(key)
127
+
128
+ raise Refusal, "config/deploy.yml already sets #{key} to a different value" if clear[key].to_s != clear_value_for(key)
129
+ end
130
+
131
+ accessories = parsed["accessories"] || {}
132
+ if accessories.key?("wide_events") && !owned_accessory?(@original)
133
+ raise Refusal, "config/deploy.yml already defines an accessories.wide_events block wide_events did not create"
134
+ end
135
+
136
+ minimum_version = parsed["minimum_version"]
137
+ if minimum_version && !parseable_version?(minimum_version)
138
+ raise Refusal, "config/deploy.yml's minimum_version (#{minimum_version.inspect}) is not a recognizable version"
139
+ end
140
+
141
+ parsed
142
+ end
143
+
144
+ def safe_parse(content)
145
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
146
+ rescue Psych::Exception, ArgumentError => e
147
+ raise Refusal, "config/deploy.yml could not be safely parsed (#{e.message})"
148
+ end
149
+
150
+ def raise_if_inline_value!(parsed, key)
151
+ return unless parsed.key?(key)
152
+
153
+ line = @original.lines.find { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == key }
154
+ return unless line
155
+
156
+ remainder = ROOT_KEY_RE.match(line)[2].to_s.strip
157
+ return if remainder.empty? || remainder.start_with?("#")
158
+
159
+ raise Refusal, "config/deploy.yml's #{key} key uses an inline value this editor cannot merge into"
160
+ end
161
+
162
+ def duplicate_root_keys?(content)
163
+ keys = content.lines.filter_map { |l| (m = ROOT_KEY_RE.match(l)) && m[1] }
164
+ keys.size != keys.uniq.size
165
+ end
166
+
167
+ def parseable_version?(value)
168
+ Gem::Version.new(value.to_s)
169
+ true
170
+ rescue ArgumentError
171
+ false
172
+ end
173
+
174
+ # Only called once the parsed hash has already confirmed
175
+ # accessories.wide_events exists. If the line scanner cannot locate it
176
+ # (an indentation shape it does not understand, for example), that is
177
+ # a scan failure, not evidence of ownership - treat it as unowned so
178
+ # preflight refuses instead of silently accepting or replacing a
179
+ # foreign accessory it never actually verified.
180
+ def owned_accessory?(content)
181
+ lines = content.lines
182
+ range = root_block_range(lines, "accessories")
183
+ return false unless range
184
+
185
+ child_range = child_block_range(lines, range, "wide_events")
186
+ return false unless child_range
187
+
188
+ start_r, = child_range
189
+ preceding = lines[0...start_r].reverse.find { |l| !l.strip.empty? }
190
+ preceding&.strip == BEGIN_MARKER
191
+ end
192
+
193
+ def clear_value_for(key)
194
+ case key
195
+ when "WIDE_EVENTS_URL" then "https://#{@hostname}"
196
+ when "WIDE_EVENTS_SERVICE" then @service.to_s
197
+ when "WIDE_EVENTS_ENVIRONMENT" then @environment.to_s
198
+ end
199
+ end
200
+
201
+ # ---- Line-range helpers ---------------------------------------------
202
+
203
+ def child_key_regex(indent)
204
+ /\A {#{indent}}([A-Za-z_][A-Za-z0-9_.-]*):(.*)\n?\z/
205
+ end
206
+
207
+ def root_block_range(lines, key)
208
+ start_idx = lines.find_index { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == key }
209
+ return nil unless start_idx
210
+
211
+ rest = lines[(start_idx + 1)..] || []
212
+ offset = rest.find_index { |l| ROOT_KEY_RE.match(l) }
213
+ end_idx = offset ? start_idx + 1 + offset : lines.length
214
+ [ start_idx, end_idx ]
215
+ end
216
+
217
+ def child_block_range(lines, range, key, indent: 2)
218
+ start_r, end_r = range
219
+ regex = child_key_regex(indent)
220
+ rel_start = ((start_r + 1)...end_r).find { |i| (m = regex.match(lines[i])) && m[1] == key }
221
+ return nil unless rel_start
222
+
223
+ rel_end = ((rel_start + 1)...end_r).find { |i| regex.match(lines[i]) }
224
+ rel_end ||= end_r
225
+ [ rel_start, rel_end ]
226
+ end
227
+
228
+ def mapping_keys(lines, range, indent: 2)
229
+ regex = child_key_regex(indent)
230
+ (range[0] + 1...range[1]).filter_map { |i| (m = regex.match(lines[i])) && m[1] }
231
+ end
232
+
233
+ def sequence_items(lines, range, indent: 4)
234
+ regex = /\A {#{indent}}- (.+?)\s*\z/
235
+ (range[0] + 1...range[1]).filter_map { |i| (m = regex.match(lines[i])) && m[1] }
236
+ end
237
+
238
+ def insert_before(lines, idx, block)
239
+ lines.insert(idx, *block)
240
+ end
241
+
242
+ # A block's naive end boundary (the next real key at the same level,
243
+ # or EOF) absorbs any trailing blank lines and standalone comments that
244
+ # actually describe whatever comes *after* this block. Trims those off
245
+ # so insertions land immediately after the block's real content
246
+ # instead of after comments that belong to the next section.
247
+ def content_end(lines, range)
248
+ start_r, end_r = range
249
+ idx = end_r
250
+ while idx > start_r + 1
251
+ previous = lines[idx - 1].strip
252
+ break unless previous.empty? || previous.start_with?("#")
253
+
254
+ idx -= 1
255
+ end
256
+ idx
257
+ end
258
+
259
+ def append_root_block(lines, block)
260
+ if lines.any? && !lines.last.end_with?("\n")
261
+ lines[-1] = "#{lines.last}\n"
262
+ end
263
+ lines << "\n" unless lines.empty? || lines.last == "\n"
264
+ lines.concat(block)
265
+ end
266
+
267
+ # ---- env: clear/secret merge ---------------------------------------
268
+
269
+ def apply_env_in_place(lines, _range)
270
+ range = root_block_range(lines, "env")
271
+ changed = ensure_env_clear(lines, range)
272
+ range = root_block_range(lines, "env")
273
+ changed |= ensure_env_secret(lines, range)
274
+ changed
275
+ end
276
+
277
+ def ensure_env_clear(lines, env_range)
278
+ clear_range = child_block_range(lines, env_range, "clear")
279
+ if clear_range.nil?
280
+ block = [
281
+ " #{BEGIN_MARKER}\n",
282
+ " clear:\n",
283
+ *CLEAR_VARS.map { |k| " #{k}: #{clear_value_for(k)}\n" },
284
+ " #{END_MARKER}\n"
285
+ ]
286
+ insert_before(lines, content_end(lines, env_range), block)
287
+ return true
288
+ end
289
+
290
+ existing = mapping_keys(lines, clear_range, indent: 4)
291
+ missing = CLEAR_VARS.reject { |k| existing.include?(k) }
292
+ return false if missing.empty?
293
+
294
+ block = [ " #{BEGIN_MARKER}\n", *missing.map { |k| " #{k}: #{clear_value_for(k)}\n" }, " #{END_MARKER}\n" ]
295
+ insert_before(lines, content_end(lines, clear_range), block)
296
+ true
297
+ end
298
+
299
+ def ensure_env_secret(lines, env_range)
300
+ secret_range = child_block_range(lines, env_range, "secret")
301
+ if secret_range.nil?
302
+ block = [
303
+ " #{BEGIN_MARKER}\n",
304
+ " secret:\n",
305
+ *SECRET_VARS.map { |k| " - #{k}\n" },
306
+ " #{END_MARKER}\n"
307
+ ]
308
+ insert_before(lines, content_end(lines, env_range), block)
309
+ return true
310
+ end
311
+
312
+ existing = sequence_items(lines, secret_range, indent: 4)
313
+ missing = SECRET_VARS.reject { |k| existing.include?(k) }
314
+ return false if missing.empty?
315
+
316
+ block = [ " #{BEGIN_MARKER}\n", *missing.map { |k| " - #{k}\n" }, " #{END_MARKER}\n" ]
317
+ insert_before(lines, content_end(lines, secret_range), block)
318
+ true
319
+ end
320
+
321
+ def append_env_section(lines)
322
+ block = [
323
+ "#{BEGIN_MARKER}\n",
324
+ "env:\n",
325
+ " clear:\n",
326
+ *CLEAR_VARS.map { |k| " #{k}: #{clear_value_for(k)}\n" },
327
+ " secret:\n",
328
+ *SECRET_VARS.map { |k| " - #{k}\n" },
329
+ "#{END_MARKER}\n"
330
+ ]
331
+ append_root_block(lines, block)
332
+ true
333
+ end
334
+
335
+ # ---- aliases merge ---------------------------------------------------
336
+
337
+ # Existence is decided from the safely-parsed hash, not a line scan:
338
+ # a quoted or otherwise unusually-written key (e.g. "telemetry": ...)
339
+ # still normalizes to the same string key in `aliases`, so relying on
340
+ # the parsed hash here (rather than the child_key_regex line scan)
341
+ # keeps an existing user alias from being silently shadowed by a
342
+ # second, identically-named key that Psych would resolve to ours.
343
+ def apply_aliases_in_place(lines, range, aliases)
344
+ missing_lines = []
345
+ missing_lines << ALIAS_TELEMETRY_LINE unless aliases.key?(ALIAS_TELEMETRY_KEY)
346
+ missing_lines << ALIAS_BACKUP_LINE unless aliases.key?(ALIAS_BACKUP_KEY)
347
+ return false if missing_lines.empty?
348
+
349
+ block = [ " #{BEGIN_MARKER}\n", *missing_lines.map { |l| " #{l}\n" }, " #{END_MARKER}\n" ]
350
+ insert_before(lines, content_end(lines, range), block)
351
+ true
352
+ end
353
+
354
+ def append_aliases_section(lines)
355
+ block = [
356
+ "#{BEGIN_MARKER}\n",
357
+ "aliases:\n",
358
+ " #{ALIAS_TELEMETRY_LINE}\n",
359
+ " #{ALIAS_BACKUP_LINE}\n",
360
+ "#{END_MARKER}\n"
361
+ ]
362
+ append_root_block(lines, block)
363
+ true
364
+ end
365
+
366
+ # ---- accessories.wide_events insertion -------------------------------
367
+
368
+ # By the time this runs, preflight has already refused any
369
+ # accessories.wide_events block this editor did not create (see
370
+ # #owned_accessory?), so a `child_range` found here is guaranteed to be
371
+ # our own marker-wrapped block. Re-rendering and replacing that whole
372
+ # marked region on every apply - rather than leaving it alone once
373
+ # created - is what makes the BEGIN/END markers useful for
374
+ # "deterministic updates": a gem upgrade (new pinned image) or a
375
+ # corrected --host/--hostname/--retention-days on a later
376
+ # `bin/rails generate wide_events:store` run actually takes effect.
377
+ # Identical re-renders leave the file byte-for-byte unchanged.
378
+ def apply_accessories_in_place(lines, range)
379
+ child_range = child_block_range(lines, range, "wide_events")
380
+ return replace_owned_accessory_block(lines, child_range) if child_range
381
+
382
+ block = [ " #{BEGIN_MARKER}\n", *indented(wide_events_template_lines, 2), " #{END_MARKER}\n" ]
383
+ insert_before(lines, content_end(lines, range), block)
384
+ true
385
+ end
386
+
387
+ def replace_owned_accessory_block(lines, child_range)
388
+ start_r, naive_end_r = child_range
389
+ start_r -= 1 if lines[start_r - 1]&.strip == BEGIN_MARKER
390
+
391
+ # child_range's naive end is the next sibling key, or (when
392
+ # wide_events is the last child) the parent's own end - which can
393
+ # extend past our own END marker into blank/comment lines that
394
+ # belong to whatever root section comes next. Search for our own
395
+ # marker explicitly instead of trusting that boundary, so the
396
+ # replacement span never reaches past it.
397
+ marker_idx = ((start_r + 1)...naive_end_r).find { |i| lines[i].strip == END_MARKER }
398
+ end_r = marker_idx ? marker_idx + 1 : naive_end_r
399
+
400
+ new_block = [ " #{BEGIN_MARKER}\n", *indented(wide_events_template_lines, 2), " #{END_MARKER}\n" ]
401
+ return false if lines[start_r...end_r] == new_block
402
+
403
+ lines[start_r...end_r] = new_block
404
+ true
405
+ end
406
+
407
+ def append_accessories_section(lines)
408
+ # Wraps only the wide_events child with markers - not the whole
409
+ # "accessories:" root key - so ownership detection (which looks for
410
+ # a marker immediately before the wide_events: line) behaves
411
+ # identically whether accessories already existed or was created
412
+ # here for the first time. That consistency is what lets a later
413
+ # re-apply find and replace this exact block instead of refusing it
414
+ # as foreign.
415
+ block = [
416
+ "accessories:\n",
417
+ " #{BEGIN_MARKER}\n",
418
+ *indented(wide_events_template_lines, 2),
419
+ " #{END_MARKER}\n"
420
+ ]
421
+ append_root_block(lines, block)
422
+ true
423
+ end
424
+
425
+ def wide_events_template_lines
426
+ template = File.read(ACCESSORY_TEMPLATE_PATH)
427
+ rendered = ERB.new(template, trim_mode: "-").result_with_hash(
428
+ image: @image,
429
+ host: @host,
430
+ hostname: @hostname,
431
+ service: @service,
432
+ environment: @environment,
433
+ retention_days: @retention_days,
434
+ local_directory: "/var/lib/#{@service}-wide-events"
435
+ )
436
+ rendered.lines
437
+ end
438
+
439
+ def indented(lines_arr, spaces)
440
+ prefix = " " * spaces
441
+ lines_arr.map { |l| l.strip.empty? ? l : "#{prefix}#{l}" }
442
+ end
443
+
444
+ # ---- Snippet for refusal ---------------------------------------------
445
+
446
+ def build_snippet
447
+ lines = []
448
+ lines << "minimum_version: #{MINIMUM_VERSION}"
449
+ lines << ""
450
+ lines << "env:"
451
+ lines << " clear:"
452
+ CLEAR_VARS.each { |k| lines << " #{k}: #{clear_value_for(k)}" }
453
+ lines << " secret:"
454
+ SECRET_VARS.each { |k| lines << " - #{k}" }
455
+ lines << ""
456
+ lines << "accessories:"
457
+ lines.concat(indented(wide_events_template_lines, 2).map(&:chomp))
458
+ lines << ""
459
+ lines << "aliases:"
460
+ lines << " #{ALIAS_TELEMETRY_LINE}"
461
+ lines << " #{ALIAS_BACKUP_LINE}"
462
+ "#{lines.join("\n")}\n"
463
+ end
464
+
465
+ # ---- Post-mutation safety net ----------------------------------------
466
+
467
+ # The line-based patcher assumes indentation/style shapes the fixtures
468
+ # cover; a shape it does not understand (mismatched sequence indents, a
469
+ # flow-style child value, unusual nesting, ...) can produce invalid
470
+ # YAML even though preflight raised no Refusal. Catching that here,
471
+ # after mutation but before returning, converts every such case into
472
+ # the same "refuse, leave the file untouched, print a snippet" contract
473
+ # as an explicit preflight refusal, instead of letting a RuntimeError
474
+ # escape #apply with a half-built Result and no snippet.
475
+ def verify_parseable!(content)
476
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
477
+ rescue Psych::Exception => e
478
+ raise Refusal, "editing config/deploy.yml would produce invalid YAML (#{e.message}); this shape is not supported"
479
+ end
480
+
481
+ def apply_minimum_version(lines)
482
+ idx = lines.find_index { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == "minimum_version" }
483
+ if idx
484
+ m = ROOT_KEY_RE.match(lines[idx])
485
+ existing = m[2].to_s.split("#").first.to_s.strip.gsub(/\A["']|["']\z/, "")
486
+ if existing.empty? || Gem::Version.new(existing) < Gem::Version.new(MINIMUM_VERSION)
487
+ lines[idx] = "minimum_version: #{MINIMUM_VERSION}\n"
488
+ true
489
+ else
490
+ false
491
+ end
492
+ else
493
+ lines.unshift("#{BEGIN_MARKER}\n", "minimum_version: #{MINIMUM_VERSION}\n", "#{END_MARKER}\n", "\n")
494
+ true
495
+ end
496
+ end
497
+ end
498
+ end
499
+ end
@@ -0,0 +1,103 @@
1
+ require "securerandom"
2
+ require "fileutils"
3
+
4
+ module WideEvent
5
+ module Kamal
6
+ # Plans, then durably writes, the ingest/query token files a Kamal
7
+ # deployment needs plus their references in .kamal/secrets and
8
+ # .gitignore. #apply never touches the filesystem beyond reading and
9
+ # stat-ing the two token files; it only decides what should exist.
10
+ # Callers persist the plan with Result#write! once every other preflight
11
+ # (such as WideEvent::Kamal::DeployEditor#apply) has also succeeded, so a
12
+ # rejected deploy edit never leaves secret files behind.
13
+ class SecretsEditor
14
+ INGEST_TOKEN_PATH = ".kamal/wide-events-ingest-token"
15
+ QUERY_TOKEN_PATH = ".kamal/wide-events-query-token"
16
+ SECRETS_PATH = ".kamal/secrets"
17
+ GITIGNORE_PATH = ".gitignore"
18
+
19
+ SECRETS_LINES = [
20
+ "WIDE_EVENTS_INGEST_TOKEN=$(cat #{INGEST_TOKEN_PATH})",
21
+ "WIDE_EVENTS_QUERY_TOKEN=$(cat #{QUERY_TOKEN_PATH})"
22
+ ].freeze
23
+
24
+ GITIGNORE_LINES = [ INGEST_TOKEN_PATH, QUERY_TOKEN_PATH ].freeze
25
+
26
+ # Raised when a token file exists but is empty or readable by group/
27
+ # world, since either state means the value can no longer be trusted
28
+ # as a private secret.
29
+ class UnsafeTokenFile < StandardError; end
30
+
31
+ Result = Struct.new(:root, :ingest_token, :query_token, :ingest_token_existing, :query_token_existing,
32
+ keyword_init: true) do
33
+ def write!
34
+ write_token_file(INGEST_TOKEN_PATH, ingest_token) unless ingest_token_existing
35
+ write_token_file(QUERY_TOKEN_PATH, query_token) unless query_token_existing
36
+ ensure_lines(SECRETS_PATH, SECRETS_LINES)
37
+ ensure_lines(GITIGNORE_PATH, GITIGNORE_LINES)
38
+ self
39
+ end
40
+
41
+ private
42
+
43
+ def write_token_file(relative_path, value)
44
+ path = File.join(root, relative_path)
45
+ FileUtils.mkdir_p(File.dirname(path))
46
+ File.write(path, "#{value}\n")
47
+ File.chmod(0600, path)
48
+ end
49
+
50
+ def ensure_lines(relative_path, required_lines)
51
+ path = File.join(root, relative_path)
52
+ existing = File.exist?(path) ? File.read(path) : ""
53
+ existing_lines = existing.lines.map { |l| l.chomp("\n") }
54
+ missing = required_lines.reject { |l| existing_lines.include?(l) }
55
+ return if missing.empty?
56
+
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ content = existing.dup
59
+ content << "\n" unless content.empty? || content.end_with?("\n")
60
+ missing.each { |l| content << "#{l}\n" }
61
+ File.write(path, content)
62
+ end
63
+ end
64
+
65
+ def initialize(root:)
66
+ @root = root
67
+ end
68
+
69
+ def apply
70
+ ingest = plan_token(INGEST_TOKEN_PATH)
71
+ query = plan_token(QUERY_TOKEN_PATH)
72
+ Result.new(
73
+ root: @root,
74
+ ingest_token: ingest.fetch(:value),
75
+ query_token: query.fetch(:value),
76
+ ingest_token_existing: ingest.fetch(:existing),
77
+ query_token_existing: query.fetch(:existing)
78
+ )
79
+ end
80
+
81
+ private
82
+
83
+ def plan_token(relative_path)
84
+ path = File.join(@root, relative_path)
85
+ return { value: SecureRandom.hex(32), existing: false } unless File.exist?(path)
86
+
87
+ content = File.read(path).strip
88
+ if content.empty?
89
+ raise UnsafeTokenFile, "#{relative_path} exists but is empty; remove it or restore its value"
90
+ end
91
+
92
+ mode = File.stat(path).mode & 0o777
93
+ if mode & 0o077 != 0
94
+ raise UnsafeTokenFile,
95
+ format("%s is group- or world-readable (mode %04o); fix with `chmod 0600 %s`", relative_path, mode,
96
+ relative_path)
97
+ end
98
+
99
+ { value: content, existing: true }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -21,6 +21,7 @@ module WideEvent
21
21
 
22
22
  WideEvent.with do |attrs|
23
23
  seed(env)
24
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
25
  begin
25
26
  status, headers, body = @app.call(env)
26
27
  ex = env["action_dispatch.exception"]
@@ -48,6 +49,7 @@ module WideEvent
48
49
  # and mutates env in place, so the id doesn't exist at seed time
49
50
  # but does by the time any outcome unwinds back to here.
50
51
  WideEvent.set("http.request.id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"])
52
+ WideEvent.set("duration_ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2))
51
53
  WideEvent.flush(attrs)
52
54
  end
53
55
  end
@@ -19,6 +19,12 @@ module WideEvent
19
19
  WideEvent.config.registry_path ||= path if app.root.join(path).exist?
20
20
  end
21
21
 
22
+ # Best-effort drain of the store sender's queue before the process
23
+ # exits. A no-op unless the store sink was actually resolved.
24
+ initializer "wide_event.shutdown" do
25
+ at_exit { WideEvent.shutdown! }
26
+ end
27
+
22
28
  # After the app's own initializers, so the host's OpenTelemetry::SDK
23
29
  # configuration (and any WideEvent.configure block) has already run.
24
30
  config.after_initialize do
@@ -35,6 +41,8 @@ module WideEvent
35
41
 
36
42
  rake_tasks do
37
43
  load File.expand_path("tasks/registry.rake", __dir__)
44
+ load File.expand_path("tasks/store.rake", __dir__)
45
+ load File.expand_path("tasks/setup.rake", __dir__)
38
46
  end
39
47
  end
40
48
  end
@@ -11,6 +11,11 @@ error:
11
11
  set_by: wide_events
12
12
  pii: none
13
13
  notes: Status >= 500, an unhandled exception, or an error! call.
14
+ duration_ms:
15
+ type: float
16
+ set_by: wide_events
17
+ pii: none
18
+ notes: Total wall-clock duration of the whole request or job execution, monotonic clock.
14
19
  uptime_sec:
15
20
  type: integer
16
21
  set_by: wide_events