logbrew-sdk 0.1.0 → 0.1.2
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/README.md +425 -21
- data/examples/Makefile +21 -1
- data/examples/automatic_delivery.rb +32 -0
- data/examples/first_useful_telemetry.rb +118 -0
- data/examples/http_trace_correlation.rb +99 -0
- data/examples/persistent_worker_delivery.rb +33 -0
- data/examples/real_user_smoke.rb +15 -0
- data/examples/sidekiq_tracing.rb +22 -0
- data/lib/logbrew/automatic_delivery.rb +493 -0
- data/lib/logbrew/bounded_event_queue.rb +196 -0
- data/lib/logbrew/event_batcher.rb +67 -0
- data/lib/logbrew/faraday_tracing.rb +55 -0
- data/lib/logbrew/http_client_tracing.rb +282 -0
- data/lib/logbrew/operation_tracing.rb +194 -0
- data/lib/logbrew/persistent_event_store.rb +403 -0
- data/lib/logbrew/product_timeline.rb +153 -0
- data/lib/logbrew/sidekiq.rb +466 -0
- data/lib/logbrew/span_events.rb +34 -0
- data/lib/logbrew/support_ticket.rb +187 -0
- data/lib/logbrew/trace.rb +298 -0
- data/lib/logbrew/traceparent.rb +145 -0
- data/lib/logbrew/worker_lifecycle.rb +211 -0
- data/lib/logbrew.rb +427 -40
- metadata +21 -2
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
class PersistentEventStore
|
|
5
|
+
EVENT_FILE = /\A(\d{20})\.event\z/.freeze
|
|
6
|
+
TEMP_FILE = /\A\.tmp-[0-9a-f]{32}\z/.freeze
|
|
7
|
+
LOCK_FILE = ".lock"
|
|
8
|
+
ACK_FILE = ".ack"
|
|
9
|
+
|
|
10
|
+
StoredRecord = Struct.new(:sequence, :json, :bytes, :durable) do
|
|
11
|
+
def initialize(sequence, json, bytes, durable = true)
|
|
12
|
+
super(sequence, json.dup.freeze, bytes, durable)
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.open(path:)
|
|
18
|
+
validate_path(path)
|
|
19
|
+
new(path)
|
|
20
|
+
rescue SdkError
|
|
21
|
+
raise
|
|
22
|
+
rescue StandardError
|
|
23
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not be opened")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_path(path)
|
|
27
|
+
unless path.is_a?(String) && !path.empty? && File.absolute_path(path) == path && File.expand_path(path) == path
|
|
28
|
+
raise SdkError.new("validation_error", "persistent_queue_path must be a normalized absolute path")
|
|
29
|
+
end
|
|
30
|
+
rescue ArgumentError
|
|
31
|
+
raise SdkError.new("validation_error", "persistent_queue_path must be a normalized absolute path")
|
|
32
|
+
end
|
|
33
|
+
private_class_method :validate_path
|
|
34
|
+
|
|
35
|
+
def initialize(path)
|
|
36
|
+
@path = path.dup.freeze
|
|
37
|
+
@owner_process_id = current_process_id
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@closed = false
|
|
40
|
+
@failed = false
|
|
41
|
+
@directory_sync_pending = false
|
|
42
|
+
@records = []
|
|
43
|
+
@acknowledged_sequence = 0
|
|
44
|
+
@next_sequence = 1
|
|
45
|
+
@directory_io = nil
|
|
46
|
+
@directory_identity = nil
|
|
47
|
+
@lock_io = nil
|
|
48
|
+
|
|
49
|
+
prepare_directory
|
|
50
|
+
open_directory
|
|
51
|
+
acquire_lock
|
|
52
|
+
recover
|
|
53
|
+
rescue StandardError
|
|
54
|
+
close_resources
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
private_class_method :new
|
|
58
|
+
|
|
59
|
+
def records
|
|
60
|
+
assert_process_ownership
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
ensure_usable
|
|
63
|
+
assert_directory_identity
|
|
64
|
+
@records.dup.freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepare_delivery
|
|
69
|
+
assert_process_ownership
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
ensure_usable
|
|
72
|
+
assert_directory_identity
|
|
73
|
+
confirm_directory_sync
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def append(json)
|
|
79
|
+
assert_process_ownership
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
ensure_usable
|
|
82
|
+
assert_directory_identity
|
|
83
|
+
confirm_directory_sync
|
|
84
|
+
validate_event_json(json)
|
|
85
|
+
sequence = @next_sequence
|
|
86
|
+
directory_synced = write_atomic(event_file_name(sequence), json)
|
|
87
|
+
record = StoredRecord.new(sequence, json, json.bytesize, directory_synced)
|
|
88
|
+
@records << record
|
|
89
|
+
@next_sequence += 1
|
|
90
|
+
record
|
|
91
|
+
rescue SdkError => error
|
|
92
|
+
@failed = true unless error.code == "persistence_commit_error"
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError
|
|
95
|
+
@failed = true
|
|
96
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not store an event")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns a content-free compaction error only after the accepted marker is durable.
|
|
101
|
+
def acknowledge(records)
|
|
102
|
+
assert_process_ownership
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
ensure_usable
|
|
105
|
+
assert_directory_identity
|
|
106
|
+
return nil if records.empty?
|
|
107
|
+
|
|
108
|
+
confirm_directory_sync
|
|
109
|
+
validate_prefix(records)
|
|
110
|
+
accepted_sequence = records.last.sequence
|
|
111
|
+
marker_synced = write_atomic(ACK_FILE, "#{accepted_sequence}\n")
|
|
112
|
+
unless marker_synced
|
|
113
|
+
raise SdkError.new("persistent_queue_error", "persistent queue acknowledgement durability is incomplete")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
@acknowledged_sequence = accepted_sequence
|
|
117
|
+
@records.shift(records.length)
|
|
118
|
+
compact_records(records)
|
|
119
|
+
rescue SdkError
|
|
120
|
+
raise
|
|
121
|
+
rescue StandardError
|
|
122
|
+
raise SdkError.new("persistent_queue_error", "persistent queue could not acknowledge events")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def close
|
|
127
|
+
assert_process_ownership
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
return if @closed
|
|
130
|
+
|
|
131
|
+
@closed = true
|
|
132
|
+
close_resources
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def prepare_directory
|
|
140
|
+
if File.exist?(@path) || File.symlink?(@path)
|
|
141
|
+
validate_directory(File.lstat(@path))
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
parent = File.dirname(@path)
|
|
146
|
+
parent_stat = File.lstat(parent)
|
|
147
|
+
unless parent_stat.directory?
|
|
148
|
+
raise SdkError.new("validation_error", "persistent queue parent directory must exist")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Dir.mkdir(@path, 0o700)
|
|
152
|
+
validate_directory(File.lstat(@path))
|
|
153
|
+
rescue Errno::ENOENT
|
|
154
|
+
raise SdkError.new("validation_error", "persistent queue parent directory must exist")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_directory(stat)
|
|
158
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.uid && (stat.mode & 0o777) == 0o700
|
|
159
|
+
raise SdkError.new("persistent_queue_error", "persistent queue must use an owner-only dedicated directory")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def open_directory
|
|
164
|
+
@directory_io = File.open(@path, File::RDONLY | File::NOFOLLOW)
|
|
165
|
+
directory_stat = @directory_io.stat
|
|
166
|
+
validate_directory(directory_stat)
|
|
167
|
+
@directory_identity = [directory_stat.dev, directory_stat.ino].freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def acquire_lock
|
|
171
|
+
assert_directory_identity
|
|
172
|
+
lock_path = File.join(@path, LOCK_FILE)
|
|
173
|
+
@lock_io = File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
174
|
+
validate_private_file(@lock_io.stat)
|
|
175
|
+
unless @lock_io.flock(File::LOCK_EX | File::LOCK_NB)
|
|
176
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is already in use")
|
|
177
|
+
end
|
|
178
|
+
rescue Errno::EWOULDBLOCK, Errno::EAGAIN
|
|
179
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is already in use")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def recover
|
|
183
|
+
assert_directory_identity
|
|
184
|
+
entries = Dir.children(@path)
|
|
185
|
+
unexpected = entries.reject do |entry|
|
|
186
|
+
entry == LOCK_FILE || entry == ACK_FILE || EVENT_FILE.match?(entry) || TEMP_FILE.match?(entry)
|
|
187
|
+
end
|
|
188
|
+
unless unexpected.empty?
|
|
189
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unexpected entries")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
entries.each { |entry| validate_known_entry(entry) }
|
|
193
|
+
@acknowledged_sequence = read_acknowledged_sequence
|
|
194
|
+
|
|
195
|
+
cleaned = false
|
|
196
|
+
entries.grep(TEMP_FILE).each do |entry|
|
|
197
|
+
File.unlink(File.join(@path, entry))
|
|
198
|
+
cleaned = true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
event_entries = entries.grep(EVENT_FILE).sort
|
|
202
|
+
recovered = []
|
|
203
|
+
event_entries.each do |entry|
|
|
204
|
+
sequence = Integer(EVENT_FILE.match(entry)[1], 10)
|
|
205
|
+
if sequence <= @acknowledged_sequence
|
|
206
|
+
File.unlink(File.join(@path, entry))
|
|
207
|
+
cleaned = true
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
json = read_event(entry)
|
|
212
|
+
recovered << StoredRecord.new(sequence, json, json.bytesize)
|
|
213
|
+
end
|
|
214
|
+
sync_directory if cleaned
|
|
215
|
+
|
|
216
|
+
@records = recovered
|
|
217
|
+
latest_sequence = [@acknowledged_sequence, recovered.empty? ? 0 : recovered.last.sequence].max
|
|
218
|
+
@next_sequence = latest_sequence + 1
|
|
219
|
+
rescue SdkError
|
|
220
|
+
raise
|
|
221
|
+
rescue StandardError
|
|
222
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_known_entry(entry)
|
|
226
|
+
path = File.join(@path, entry)
|
|
227
|
+
stat = File.lstat(path)
|
|
228
|
+
validate_private_file(stat)
|
|
229
|
+
rescue Errno::ENOENT
|
|
230
|
+
raise SdkError.new("persistent_queue_error", "persistent queue changed during recovery")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_private_file(stat)
|
|
234
|
+
unless stat.file? && !stat.symlink? && stat.uid == Process.uid && (stat.mode & 0o077).zero?
|
|
235
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unsafe files")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def read_acknowledged_sequence
|
|
240
|
+
path = File.join(@path, ACK_FILE)
|
|
241
|
+
return 0 unless File.exist?(path)
|
|
242
|
+
|
|
243
|
+
content = read_private_file(path)
|
|
244
|
+
unless /\A\d{1,20}\n?\z/.match?(content)
|
|
245
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
Integer(content, 10)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def read_event(entry)
|
|
252
|
+
json = read_private_file(File.join(@path, entry))
|
|
253
|
+
json.force_encoding(Encoding::UTF_8)
|
|
254
|
+
validate_event_json(json)
|
|
255
|
+
json.freeze
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def read_private_file(path)
|
|
259
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
260
|
+
validate_private_file(file.stat)
|
|
261
|
+
file.read
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_event_json(json)
|
|
266
|
+
unless json.is_a?(String) && json.encoding == Encoding::UTF_8 && json.valid_encoding? && !json.empty?
|
|
267
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
event = JSON.parse(json)
|
|
271
|
+
valid = event.is_a?(Hash) && event["type"].is_a?(String) && !event["type"].empty? &&
|
|
272
|
+
event["timestamp"].is_a?(String) && !event["timestamp"].empty? &&
|
|
273
|
+
event["id"].is_a?(String) && !event["id"].empty? && event["attributes"].is_a?(Hash)
|
|
274
|
+
unless valid && JSON.generate(event) == json
|
|
275
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
276
|
+
end
|
|
277
|
+
rescue JSON::ParserError, JSON::GeneratorError, EncodingError
|
|
278
|
+
raise SdkError.new("persistent_queue_error", "persistent queue contains unreadable records")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def validate_prefix(records)
|
|
282
|
+
expected = @records.first(records.length)
|
|
283
|
+
unless expected.length == records.length && expected.map(&:sequence) == records.map(&:sequence)
|
|
284
|
+
raise SdkError.new("persistent_queue_error", "persistent queue acknowledgement is stale")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def compact_records(records)
|
|
289
|
+
compaction_failed = false
|
|
290
|
+
records.each do |record|
|
|
291
|
+
begin
|
|
292
|
+
File.unlink(File.join(@path, event_file_name(record.sequence)))
|
|
293
|
+
rescue Errno::ENOENT
|
|
294
|
+
nil
|
|
295
|
+
rescue StandardError
|
|
296
|
+
compaction_failed = true
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
sync_directory
|
|
302
|
+
rescue StandardError
|
|
303
|
+
compaction_failed = true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if compaction_failed
|
|
307
|
+
SdkError.new("persistent_queue_error", "persistent queue accepted-prefix compaction is incomplete")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def write_atomic(destination_name, content)
|
|
312
|
+
assert_directory_identity
|
|
313
|
+
temp_name = ".tmp-#{SecureRandom.hex(16)}"
|
|
314
|
+
temp_path = File.join(@path, temp_name)
|
|
315
|
+
destination_path = File.join(@path, destination_name)
|
|
316
|
+
renamed = false
|
|
317
|
+
|
|
318
|
+
begin
|
|
319
|
+
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
320
|
+
file.write(content)
|
|
321
|
+
file.flush
|
|
322
|
+
file.fsync
|
|
323
|
+
end
|
|
324
|
+
assert_directory_identity
|
|
325
|
+
File.rename(temp_path, destination_path)
|
|
326
|
+
renamed = true
|
|
327
|
+
begin
|
|
328
|
+
sync_directory
|
|
329
|
+
true
|
|
330
|
+
rescue StandardError
|
|
331
|
+
@directory_sync_pending = true
|
|
332
|
+
false
|
|
333
|
+
end
|
|
334
|
+
ensure
|
|
335
|
+
File.unlink(temp_path) if !renamed && File.exist?(temp_path)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def sync_directory
|
|
340
|
+
@directory_io.fsync
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def confirm_directory_sync
|
|
344
|
+
return unless @directory_sync_pending
|
|
345
|
+
|
|
346
|
+
sync_directory
|
|
347
|
+
@directory_sync_pending = false
|
|
348
|
+
rescue StandardError
|
|
349
|
+
raise SdkError.new("persistence_commit_error", "persistent queue durability is unconfirmed")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def assert_directory_identity
|
|
353
|
+
stat = File.lstat(@path)
|
|
354
|
+
current_identity = [stat.dev, stat.ino]
|
|
355
|
+
unless current_identity == @directory_identity
|
|
356
|
+
raise SdkError.new("persistent_queue_error", "persistent queue directory changed while in use")
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
validate_directory(stat)
|
|
360
|
+
rescue SdkError
|
|
361
|
+
raise
|
|
362
|
+
rescue StandardError
|
|
363
|
+
raise SdkError.new("persistent_queue_error", "persistent queue directory changed while in use")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def event_file_name(sequence)
|
|
367
|
+
format("%020d.event", sequence)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def ensure_usable
|
|
371
|
+
raise SdkError.new("persistent_queue_error", "persistent queue is closed") if @closed
|
|
372
|
+
raise SdkError.new("persistent_queue_error", "persistent queue requires recovery") if @failed
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def assert_process_ownership
|
|
376
|
+
return if current_process_id == @owner_process_id
|
|
377
|
+
|
|
378
|
+
raise SdkError.new("process_ownership_error", "persistent queue must be opened in the current process")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def current_process_id
|
|
382
|
+
process_id = Process.pid
|
|
383
|
+
unless process_id.is_a?(Integer) && process_id.positive?
|
|
384
|
+
raise SdkError.new("process_ownership_error", "persistent queue process identity is unavailable")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
process_id
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def close_resources
|
|
391
|
+
if @lock_io
|
|
392
|
+
@lock_io.flock(File::LOCK_UN) rescue nil
|
|
393
|
+
@lock_io.close rescue nil
|
|
394
|
+
@lock_io = nil
|
|
395
|
+
end
|
|
396
|
+
if @directory_io
|
|
397
|
+
@directory_io.close rescue nil
|
|
398
|
+
@directory_io = nil
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
private_constant :PersistentEventStore
|
|
403
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
# Builders for app-owned product and network timeline action events.
|
|
5
|
+
class ProductTimeline
|
|
6
|
+
private_class_method :new
|
|
7
|
+
|
|
8
|
+
def self.product_action(
|
|
9
|
+
name:,
|
|
10
|
+
status: "success",
|
|
11
|
+
route_template: nil,
|
|
12
|
+
session_id: nil,
|
|
13
|
+
trace_id: nil,
|
|
14
|
+
screen: nil,
|
|
15
|
+
funnel: nil,
|
|
16
|
+
step: nil,
|
|
17
|
+
metadata: nil
|
|
18
|
+
)
|
|
19
|
+
action_metadata = timeline_metadata("product_timeline", metadata)
|
|
20
|
+
put_if_present(action_metadata, "routeTemplate", sanitize_optional_route_template("product route_template", route_template))
|
|
21
|
+
put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
|
|
22
|
+
put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
|
|
23
|
+
put_if_present(action_metadata, "screen", optional_label("screen", screen))
|
|
24
|
+
put_if_present(action_metadata, "funnel", optional_label("funnel", funnel))
|
|
25
|
+
put_if_present(action_metadata, "step", optional_label("step", step))
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
"name" => required_label("product action name", name),
|
|
29
|
+
"status" => normalize_status(status),
|
|
30
|
+
"metadata" => action_metadata
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.network_milestone(
|
|
35
|
+
route_template:,
|
|
36
|
+
method: "GET",
|
|
37
|
+
status_code: nil,
|
|
38
|
+
duration_ms: nil,
|
|
39
|
+
status: nil,
|
|
40
|
+
name: nil,
|
|
41
|
+
session_id: nil,
|
|
42
|
+
trace_id: nil,
|
|
43
|
+
metadata: nil
|
|
44
|
+
)
|
|
45
|
+
route = sanitize_route_template("network milestone route_template", route_template)
|
|
46
|
+
normalized_method = normalize_method(method)
|
|
47
|
+
validate_status_code(status_code)
|
|
48
|
+
validate_duration_ms(duration_ms)
|
|
49
|
+
normalized_status = normalize_status(status || (status_code && status_code >= 400 ? "failure" : "success"))
|
|
50
|
+
|
|
51
|
+
action_metadata = timeline_metadata("network_timeline", metadata)
|
|
52
|
+
action_metadata["routeTemplate"] = route
|
|
53
|
+
action_metadata["method"] = normalized_method
|
|
54
|
+
put_if_present(action_metadata, "statusCode", status_code)
|
|
55
|
+
put_if_present(action_metadata, "durationMs", duration_ms)
|
|
56
|
+
put_if_present(action_metadata, "sessionId", optional_label("session_id", session_id))
|
|
57
|
+
put_if_present(action_metadata, "traceId", optional_label("trace_id", trace_id))
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"name" => name.nil? ? "network.#{normalized_method.downcase} #{route}" : required_label("network milestone name", name),
|
|
61
|
+
"status" => normalized_status,
|
|
62
|
+
"metadata" => action_metadata
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.timeline_metadata(source, metadata)
|
|
67
|
+
copied = { "source" => source }
|
|
68
|
+
validated = Validation.require_metadata(metadata)
|
|
69
|
+
return copied if validated.nil?
|
|
70
|
+
|
|
71
|
+
validated.each do |key, value|
|
|
72
|
+
copied[key] = value unless key == "source"
|
|
73
|
+
end
|
|
74
|
+
copied
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.required_label(label, value)
|
|
78
|
+
Validation.require_non_empty(label, value)
|
|
79
|
+
value.to_s.strip
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.optional_label(label, value)
|
|
83
|
+
return nil if value.nil?
|
|
84
|
+
|
|
85
|
+
required_label(label, value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.normalize_status(status)
|
|
89
|
+
Validation.require_allowed_value("action status", status, LogBrew::ACTION_STATUSES)
|
|
90
|
+
status
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.sanitize_optional_route_template(label, route_template)
|
|
94
|
+
return nil if route_template.nil?
|
|
95
|
+
|
|
96
|
+
sanitize_route_template(label, route_template)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.sanitize_route_template(label, route_template)
|
|
100
|
+
Validation.require_non_empty(label, route_template)
|
|
101
|
+
trimmed = route_template.to_s.strip
|
|
102
|
+
if trimmed.match?(/\Ahttps?:\/\//i)
|
|
103
|
+
uri = URI.parse(trimmed)
|
|
104
|
+
return uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
cutoff = first_present_index(trimmed.index("?"), trimmed.index("#"))
|
|
108
|
+
cutoff.nil? ? trimmed : (trimmed[0...cutoff].rstrip.empty? ? "/" : trimmed[0...cutoff].rstrip)
|
|
109
|
+
rescue URI::InvalidURIError => error
|
|
110
|
+
raise SdkError.new("validation_error", "route_template must be a valid route or URL: #{error.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.normalize_method(method)
|
|
114
|
+
normalized = method.to_s.strip.upcase
|
|
115
|
+
unless normalized.match?(/\A[A-Z0-9_-]+\z/)
|
|
116
|
+
raise SdkError.new("validation_error", "network milestone method must be a valid HTTP method")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
normalized
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.validate_status_code(status_code)
|
|
123
|
+
return if status_code.nil?
|
|
124
|
+
|
|
125
|
+
unless status_code.is_a?(Integer) && status_code >= 100 && status_code <= 599
|
|
126
|
+
raise SdkError.new("validation_error", "network milestone status_code must be between 100 and 599")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.validate_duration_ms(duration_ms)
|
|
131
|
+
return if duration_ms.nil?
|
|
132
|
+
unless duration_ms.is_a?(Numeric) && duration_ms.finite?
|
|
133
|
+
raise SdkError.new("validation_error", "network milestone duration_ms must be a finite number")
|
|
134
|
+
end
|
|
135
|
+
raise SdkError.new("validation_error", "network milestone duration_ms must be non-negative") if duration_ms.negative?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.put_if_present(metadata, key, value)
|
|
139
|
+
metadata[key] = value unless value.nil?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.first_present_index(first, second)
|
|
143
|
+
return second if first.nil?
|
|
144
|
+
return first if second.nil?
|
|
145
|
+
|
|
146
|
+
[first, second].min
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :timeline_metadata, :required_label, :optional_label, :normalize_status,
|
|
150
|
+
:sanitize_optional_route_template, :sanitize_route_template, :normalize_method,
|
|
151
|
+
:validate_status_code, :validate_duration_ms, :put_if_present, :first_present_index
|
|
152
|
+
end
|
|
153
|
+
end
|