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
|
@@ -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
|
data/lib/wide_event/railtie.rb
CHANGED
|
@@ -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
|