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.
@@ -0,0 +1,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logbrew"
4
+
5
+ module LogBrew
6
+ # Explicit Sidekiq middleware for app-owned client and server chains.
7
+ module Sidekiq
8
+ CARRIER_KEY = "logbrew".freeze
9
+ CARRIER_VERSION = 1
10
+ MAX_QUEUE_WAIT_MS = 604_800_000
11
+ MAX_RETRY_COUNT = 1_000
12
+ MAX_REPORTED_FAILURES = 1_024
13
+ private_constant :CARRIER_KEY, :CARRIER_VERSION, :MAX_QUEUE_WAIT_MS, :MAX_RETRY_COUNT, :MAX_REPORTED_FAILURES
14
+
15
+ class TraceOperation
16
+ attr_reader :context, :traceparent
17
+
18
+ def initialize(instrumentation:, context:, name:, source:, retry_count: nil, queue_wait_ms: nil)
19
+ @instrumentation = instrumentation
20
+ @context = context
21
+ @traceparent = Trace.create_headers(context).fetch("traceparent")
22
+ @name = name
23
+ @source = source
24
+ @retry_count = retry_count
25
+ @queue_wait_ms = queue_wait_ms
26
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ @finished = false
28
+ @finish_mutex = Mutex.new
29
+ end
30
+
31
+ def around(terminal_failure: false)
32
+ Trace.with_context(@context) do
33
+ begin
34
+ result = yield
35
+ rescue Exception => error # rubocop:disable Lint/RescueException
36
+ finish(error: error, terminal_failure: terminal_failure)
37
+ raise
38
+ else
39
+ finish
40
+ result
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def finish(error: nil, terminal_failure: false)
48
+ return unless @finish_mutex.synchronize do
49
+ next false if @finished
50
+
51
+ @finished = true
52
+ end
53
+
54
+ @instrumentation.send(:capture_span, self, error)
55
+ @instrumentation.send(:capture_terminal_issue, self) if error && terminal_failure && !cancellation?(error)
56
+ rescue StandardError => capture_error
57
+ @instrumentation.send(:report_capture_error, capture_error)
58
+ end
59
+
60
+ def cancellation?(error)
61
+ error.is_a?(Interrupt)
62
+ end
63
+
64
+ public
65
+
66
+ def span_attributes(error)
67
+ metadata = {
68
+ "source" => @source,
69
+ "sampled" => @context.sampled
70
+ }
71
+ metadata["retryCount"] = @retry_count unless @retry_count.nil?
72
+ metadata["queueWaitMs"] = @queue_wait_ms unless @queue_wait_ms.nil?
73
+ metadata["cancelled"] = true if error.is_a?(Interrupt)
74
+
75
+ {
76
+ "name" => @name,
77
+ "traceId" => @context.trace_id,
78
+ "spanId" => @context.span_id,
79
+ "parentSpanId" => @context.parent_span_id,
80
+ "status" => error ? "error" : "ok",
81
+ "durationMs" => elapsed_ms,
82
+ "metadata" => metadata
83
+ }
84
+ end
85
+
86
+ def issue_attributes
87
+ {
88
+ "title" => "Sidekiq job failed",
89
+ "level" => "error",
90
+ "metadata" => {
91
+ "source" => @source,
92
+ "sampled" => @context.sampled,
93
+ "retryCount" => @retry_count + 1
94
+ }
95
+ }
96
+ end
97
+
98
+ def retry_count
99
+ @retry_count
100
+ end
101
+
102
+ def failure_key
103
+ [@context.trace_id, @context.parent_span_id, @retry_count].join(":")
104
+ end
105
+
106
+ private
107
+
108
+ def elapsed_ms
109
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
110
+ (elapsed * 1000.0).round(3)
111
+ end
112
+ end
113
+ private_constant :TraceOperation
114
+
115
+ # App-owned Sidekiq registration and lifecycle state.
116
+ class Instrumentation
117
+ def self.create(client:, transport: nil, max_retries: 25, on_capture_error: nil)
118
+ unless client.is_a?(Client)
119
+ raise SdkError.new("validation_error", "client must be a LogBrew::Client")
120
+ end
121
+ unless transport.nil? || transport.respond_to?(:send)
122
+ raise SdkError.new("validation_error", "transport must respond to send")
123
+ end
124
+ unless max_retries.is_a?(Integer) && max_retries.between?(0, MAX_RETRY_COUNT)
125
+ raise SdkError.new("validation_error", "max_retries must be a bounded non-negative integer")
126
+ end
127
+ unless on_capture_error.nil? || on_capture_error.respond_to?(:call)
128
+ raise SdkError.new("validation_error", "on_capture_error must be callable")
129
+ end
130
+
131
+ new(
132
+ client: client,
133
+ transport: transport,
134
+ max_retries: max_retries,
135
+ on_capture_error: on_capture_error
136
+ )
137
+ end
138
+
139
+ private_class_method :new
140
+
141
+ def initialize(client:, transport:, max_retries:, on_capture_error:)
142
+ @client = client
143
+ @transport = transport
144
+ @max_retries = max_retries
145
+ @on_capture_error = on_capture_error
146
+ @owner_process_id = Process.pid
147
+ @state_mutex = Mutex.new
148
+ @state = :enabled
149
+ @shutdown_mutex = Mutex.new
150
+ @shutdown_response = nil
151
+ @failure_mutex = Mutex.new
152
+ @reported_failures = {}
153
+ @registration_mutex = Mutex.new
154
+ @registrations = {}
155
+ end
156
+
157
+ def register_client(config)
158
+ register(config, :client_middleware, ClientMiddleware)
159
+ end
160
+
161
+ def unregister_client(config)
162
+ unregister(config, :client_middleware, ClientMiddleware)
163
+ end
164
+
165
+ def register_server(config)
166
+ register(config, :server_middleware, ServerMiddleware)
167
+ end
168
+
169
+ def unregister_server(config)
170
+ unregister(config, :server_middleware, ServerMiddleware)
171
+ end
172
+
173
+ def enable
174
+ update_state(:enabled)
175
+ end
176
+
177
+ def disable
178
+ update_state(:disabled)
179
+ end
180
+
181
+ def quiet
182
+ update_state(:quiet)
183
+ end
184
+
185
+ def shutdown
186
+ assert_process_ownership!
187
+ @shutdown_mutex.synchronize do
188
+ return @shutdown_response unless @shutdown_response.nil?
189
+
190
+ quiet
191
+ response = @transport.nil? ? @client.shutdown : @client.shutdown(@transport)
192
+ @state_mutex.synchronize { @state = :closed }
193
+ @shutdown_response = response
194
+ end
195
+ end
196
+
197
+ def around_client(job)
198
+ return yield unless capture_enabled?
199
+ return yield unless job.is_a?(Hash)
200
+ return yield if job.key?(CARRIER_KEY)
201
+
202
+ operation = prepare_operation(name: "sidekiq.enqueue", source: "sidekiq.client", continue_current: true)
203
+ return yield if operation.nil?
204
+
205
+ begin
206
+ job[CARRIER_KEY] = {
207
+ "version" => CARRIER_VERSION,
208
+ "traceparent" => operation.traceparent,
209
+ "enqueuedAtMs" => wall_time_ms
210
+ }
211
+ rescue StandardError => error
212
+ report_capture_error(error)
213
+ return yield
214
+ end
215
+ operation.around { yield }
216
+ end
217
+
218
+ def around_server(job)
219
+ return yield unless capture_enabled?
220
+ return yield unless job.is_a?(Hash)
221
+
222
+ begin
223
+ carrier = read_carrier(job[CARRIER_KEY])
224
+ retry_count = normalized_retry_count(job["retry_count"], job.key?("retry_count"))
225
+ operation = prepare_operation(
226
+ name: "sidekiq.perform",
227
+ source: "sidekiq.server",
228
+ carrier: carrier,
229
+ retry_count: retry_count,
230
+ queue_wait_ms: queue_wait_ms(carrier)
231
+ )
232
+ rescue StandardError => error
233
+ report_capture_error(error)
234
+ operation = nil
235
+ end
236
+ return yield if operation.nil?
237
+
238
+ terminal = terminal_failure?(job["retry"], retry_count)
239
+ operation.around(terminal_failure: terminal) { yield }
240
+ end
241
+
242
+ private :around_client, :around_server
243
+
244
+ private
245
+
246
+ def register(config, method_name, middleware)
247
+ assert_process_ownership!
248
+ changed = false
249
+ with_chain(config, method_name) do |chain|
250
+ unless chain.exists?(middleware)
251
+ chain.add(middleware, self)
252
+ changed = true
253
+ end
254
+ end
255
+ if changed
256
+ @registration_mutex.synchronize { @registrations[registration_key(config, method_name, middleware)] = config }
257
+ end
258
+ changed
259
+ end
260
+
261
+ def unregister(config, method_name, middleware)
262
+ assert_process_ownership!
263
+ key = registration_key(config, method_name, middleware)
264
+ owned = @registration_mutex.synchronize do
265
+ registered = @registrations[key]
266
+ registered.equal?(config)
267
+ end
268
+ return false unless owned
269
+
270
+ changed = false
271
+ with_chain(config, method_name) do |chain|
272
+ if chain.exists?(middleware)
273
+ chain.remove(middleware)
274
+ changed = true
275
+ end
276
+ end
277
+ @registration_mutex.synchronize { @registrations.delete(key) }
278
+ changed
279
+ end
280
+
281
+ def registration_key(config, method_name, middleware)
282
+ [config.object_id, method_name, middleware]
283
+ end
284
+
285
+ def with_chain(config, method_name)
286
+ unless config.respond_to?(method_name)
287
+ raise SdkError.new("validation_error", "Sidekiq configuration does not expose the requested middleware chain")
288
+ end
289
+
290
+ config.public_send(method_name) do |chain|
291
+ unless chain.respond_to?(:exists?) && chain.respond_to?(:add) && chain.respond_to?(:remove)
292
+ raise SdkError.new("validation_error", "Sidekiq middleware chain is unavailable")
293
+ end
294
+
295
+ yield chain
296
+ end
297
+ end
298
+
299
+ def update_state(state)
300
+ assert_process_ownership!
301
+ @state_mutex.synchronize do
302
+ raise SdkError.new("shutdown_error", "Sidekiq instrumentation is shut down") if @state == :closed
303
+
304
+ @state = state
305
+ end
306
+ self
307
+ end
308
+
309
+ def capture_enabled?
310
+ unless current_process?
311
+ report_capture_error(SdkError.new("process_ownership_error", "Sidekiq instrumentation belongs to another process"))
312
+ return false
313
+ end
314
+
315
+ @state_mutex.synchronize { @state == :enabled }
316
+ end
317
+
318
+ def current_process?
319
+ Process.pid == @owner_process_id
320
+ rescue StandardError
321
+ false
322
+ end
323
+
324
+ def assert_process_ownership!
325
+ return if current_process?
326
+
327
+ raise SdkError.new("process_ownership_error", "Sidekiq instrumentation belongs to another process")
328
+ end
329
+
330
+ def prepare_operation(name:, source:, carrier: nil, retry_count: nil, queue_wait_ms: nil, continue_current: false)
331
+ parsed = carrier && Traceparent.parse(carrier.fetch("traceparent"))
332
+ parent = parsed.nil? && continue_current ? Trace.current : parsed
333
+ context = if parsed
334
+ Trace.create(
335
+ trace_id: parsed.trace_id,
336
+ span_id: Trace.generate_span_id,
337
+ parent_span_id: parsed.parent_span_id,
338
+ trace_flags: parsed.trace_flags
339
+ )
340
+ elsif parent
341
+ Trace.create(
342
+ trace_id: parent.trace_id,
343
+ span_id: Trace.generate_span_id,
344
+ parent_span_id: parent.span_id,
345
+ trace_flags: parent.trace_flags
346
+ )
347
+ else
348
+ Trace.create_root
349
+ end
350
+ TraceOperation.new(
351
+ instrumentation: self,
352
+ context: context,
353
+ name: name,
354
+ source: source,
355
+ retry_count: retry_count,
356
+ queue_wait_ms: queue_wait_ms
357
+ )
358
+ rescue StandardError => error
359
+ report_capture_error(error)
360
+ nil
361
+ end
362
+
363
+ def read_carrier(value)
364
+ return nil unless value.is_a?(Hash)
365
+ keys = %w[enqueuedAtMs traceparent version]
366
+ return nil unless value.size == keys.length && keys.all? { |key| value.key?(key) }
367
+ return nil unless value["version"] == CARRIER_VERSION
368
+ return nil unless value["traceparent"].is_a?(String) && value["traceparent"].bytesize <= 55
369
+ return nil unless value["enqueuedAtMs"].is_a?(Integer) && value["enqueuedAtMs"].between?(0, 9_007_199_254_740_991)
370
+
371
+ Traceparent.parse(value["traceparent"])
372
+ value
373
+ rescue SdkError
374
+ nil
375
+ end
376
+
377
+ def normalized_retry_count(value, present)
378
+ return 0 unless present
379
+ return nil unless value.is_a?(Integer) && value.between?(0, MAX_RETRY_COUNT)
380
+
381
+ value
382
+ end
383
+
384
+ def terminal_failure?(retry_setting, retry_count)
385
+ return false if retry_count.nil?
386
+ return true if retry_setting == false || retry_setting == 0
387
+ return false unless retry_setting.nil? || retry_setting == true || retry_setting.is_a?(Integer)
388
+
389
+ limit = retry_setting.is_a?(Integer) ? retry_setting : @max_retries
390
+ return false if limit.negative?
391
+
392
+ retry_count >= [limit - 1, 0].max
393
+ end
394
+
395
+ def queue_wait_ms(carrier)
396
+ return nil if carrier.nil?
397
+
398
+ elapsed = wall_time_ms - carrier.fetch("enqueuedAtMs")
399
+ [[elapsed, 0].max, MAX_QUEUE_WAIT_MS].min
400
+ end
401
+
402
+ def wall_time_ms
403
+ (Time.now.to_f * 1000.0).floor
404
+ end
405
+
406
+ def capture_span(operation, error)
407
+ @client.span(
408
+ "ruby_sidekiq_span_#{operation.context.span_id}",
409
+ Time.now.utc.iso8601,
410
+ operation.span_attributes(error)
411
+ )
412
+ end
413
+
414
+ def capture_terminal_issue(operation)
415
+ key = operation.failure_key
416
+ reserved = @failure_mutex.synchronize do
417
+ next false if @reported_failures.key?(key)
418
+
419
+ @reported_failures[key] = true
420
+ @reported_failures.shift while @reported_failures.length > MAX_REPORTED_FAILURES
421
+ true
422
+ end
423
+ return unless reserved
424
+
425
+ begin
426
+ @client.issue(
427
+ "ruby_sidekiq_issue_#{operation.context.span_id}",
428
+ Time.now.utc.iso8601,
429
+ operation.issue_attributes
430
+ )
431
+ rescue StandardError
432
+ @failure_mutex.synchronize { @reported_failures.delete(key) }
433
+ raise
434
+ end
435
+ end
436
+
437
+ def report_capture_error(error)
438
+ @on_capture_error&.call(error)
439
+ rescue StandardError
440
+ nil
441
+ end
442
+ end
443
+
444
+ # Sidekiq client middleware installed in an app-owned middleware chain.
445
+ class ClientMiddleware
446
+ def initialize(instrumentation)
447
+ @instrumentation = instrumentation
448
+ end
449
+
450
+ def call(_worker_class, job, _queue, _redis_pool)
451
+ @instrumentation.send(:around_client, job) { yield }
452
+ end
453
+ end
454
+
455
+ # Sidekiq server middleware installed in an app-owned middleware chain.
456
+ class ServerMiddleware
457
+ def initialize(instrumentation)
458
+ @instrumentation = instrumentation
459
+ end
460
+
461
+ def call(_worker, job, _queue)
462
+ @instrumentation.send(:around_server, job) { yield }
463
+ end
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ module SpanEvents
5
+ LIMIT = 8
6
+ private_constant :LIMIT
7
+
8
+ module_function
9
+
10
+ def validate(events)
11
+ return nil if events.nil?
12
+ raise SdkError.new("validation_error", "span events must be an array") unless events.is_a?(Array)
13
+ raise SdkError.new("validation_error", "span events must contain at most #{LIMIT} entries") if events.length > LIMIT
14
+ return nil if events.empty?
15
+
16
+ events.map.with_index do |event, index|
17
+ raise SdkError.new("validation_error", "span event #{index} must be an object") unless event.is_a?(Hash)
18
+
19
+ event_name = Validation.read(event, "name")
20
+ Validation.require_non_empty("span event name", event_name)
21
+ event_timestamp = Validation.read(event, "timestamp")
22
+ Validation.require_timestamp(event_timestamp) unless event_timestamp.nil?
23
+ event_metadata = Validation.require_metadata(Validation.read(event, "metadata"))
24
+
25
+ {
26
+ "name" => event_name
27
+ }.tap do |payload|
28
+ payload["timestamp"] = event_timestamp unless event_timestamp.nil?
29
+ payload["metadata"] = event_metadata unless event_metadata.nil?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Local-only support-ticket payload drafts for explicit user or agent handoff.
5
+ module SupportTicketDraft
6
+ SOURCES = %w[cli sdk website docs mobile].freeze
7
+ CATEGORIES = %w[
8
+ sdk_install_failure
9
+ ingest_failure
10
+ auth_failure
11
+ project_setup
12
+ dashboard_issue
13
+ docs_confusion
14
+ cli_issue
15
+ mobile_issue
16
+ billing_question
17
+ other
18
+ ].freeze
19
+ SENSITIVE_KEY_MARKERS = %w[
20
+ apikey
21
+ auth
22
+ authorization
23
+ authtoken
24
+ bearer
25
+ clientsecret
26
+ connectionstring
27
+ cookie
28
+ credential
29
+ dsn
30
+ email
31
+ errormessage
32
+ exceptionmessage
33
+ password
34
+ passwd
35
+ privatekey
36
+ refreshtoken
37
+ secret
38
+ session
39
+ setcookie
40
+ stacktrace
41
+ token
42
+ traceback
43
+ ].freeze
44
+ REDACTED = "[redacted]"
45
+ MAX_DEPTH = 5
46
+ MAX_ARRAY_LENGTH = 20
47
+ MAX_STRING_LENGTH = 500
48
+
49
+ module_function
50
+
51
+ def create(source:, category:, title:, description:, **options)
52
+ Validation.require_allowed_value("support ticket source", source, SOURCES)
53
+ Validation.require_allowed_value("support ticket category", category, CATEGORIES)
54
+
55
+ draft = {
56
+ "source" => source,
57
+ "category" => category,
58
+ "title" => required_string("support ticket title", title),
59
+ "description" => required_string("support ticket description", description)
60
+ }
61
+ add_optional_string(draft, "project_id", options[:project_id])
62
+ add_optional_string(draft, "environment", options[:environment])
63
+ add_optional_string(draft, "runtime", options[:runtime])
64
+ add_optional_string(draft, "framework", options[:framework])
65
+ add_optional_string(draft, "sdk_package", options[:sdk_package])
66
+ add_optional_string(draft, "sdk_version", options[:sdk_version])
67
+ add_optional_string(draft, "release", options[:release])
68
+ add_trace_id(draft, options[:trace_id])
69
+ add_optional_string(draft, "event_id", options[:event_id])
70
+ add_diagnostics(draft, options[:diagnostics]) if options.key?(:diagnostics)
71
+ draft
72
+ end
73
+
74
+ def required_string(label, value)
75
+ Validation.require_non_empty(label, value)
76
+ value.strip
77
+ end
78
+ private_class_method :required_string
79
+
80
+ def add_optional_string(draft, key, value)
81
+ return if value.nil?
82
+
83
+ draft[key] = required_string("support ticket #{key}", value)
84
+ end
85
+ private_class_method :add_optional_string
86
+
87
+ def add_trace_id(draft, value)
88
+ return if value.nil?
89
+
90
+ normalized = value.to_s.strip.downcase
91
+ unless normalized.match?(/\A[0-9a-f]{32}\z/) && !normalized.delete("0").empty?
92
+ raise SdkError.new("validation_error", "support ticket trace_id must be 32 non-zero hex characters")
93
+ end
94
+ draft["trace_id"] = normalized
95
+ end
96
+ private_class_method :add_trace_id
97
+
98
+ def add_diagnostics(draft, diagnostics)
99
+ unless diagnostics.is_a?(Hash)
100
+ raise SdkError.new("validation_error", "support ticket diagnostics must be an object")
101
+ end
102
+
103
+ sanitized = sanitize_diagnostic_hash(diagnostics, 0)
104
+ draft["diagnostics"] = sanitized unless sanitized.empty?
105
+ end
106
+ private_class_method :add_diagnostics
107
+
108
+ def sanitize_diagnostic_hash(value, depth)
109
+ value.each_with_object({}) do |(key, child), safe|
110
+ break safe if safe.length >= MAX_ARRAY_LENGTH
111
+
112
+ normalized_key = key.to_s
113
+ next if normalized_key.strip.empty?
114
+
115
+ sanitized = if sensitive_key?(normalized_key)
116
+ REDACTED
117
+ else
118
+ sanitize_diagnostic_value(child, depth + 1)
119
+ end
120
+ safe[normalized_key] = sanitized unless sanitized.nil?
121
+ end
122
+ end
123
+ private_class_method :sanitize_diagnostic_hash
124
+
125
+ def sanitize_diagnostic_value(value, depth)
126
+ return { "type" => value.class.name } if value.is_a?(Exception)
127
+ return nil if depth > MAX_DEPTH
128
+
129
+ case value
130
+ when nil, true, false, String, Integer
131
+ sanitize_primitive(value)
132
+ when Float
133
+ value.finite? ? value : nil
134
+ when Hash
135
+ sanitize_diagnostic_hash(value, depth)
136
+ when Array
137
+ value.first(MAX_ARRAY_LENGTH).map { |item| sanitize_diagnostic_value(item, depth + 1) }.compact
138
+ else
139
+ nil
140
+ end
141
+ end
142
+ private_class_method :sanitize_diagnostic_value
143
+
144
+ def sanitize_primitive(value)
145
+ return value unless value.is_a?(String)
146
+
147
+ text = value.strip
148
+ return "" if text.empty?
149
+ return REDACTED if sensitive_string?(text)
150
+
151
+ redacted = redact_url(redact_local_path(text))
152
+ redacted.length > MAX_STRING_LENGTH ? "#{redacted[0, MAX_STRING_LENGTH]}..." : redacted
153
+ end
154
+ private_class_method :sanitize_primitive
155
+
156
+ def sensitive_key?(key)
157
+ normalized = key.downcase.gsub(/[^a-z0-9]/, "")
158
+ SENSITIVE_KEY_MARKERS.any? { |marker| normalized.include?(marker) }
159
+ end
160
+ private_class_method :sensitive_key?
161
+
162
+ def sensitive_string?(value)
163
+ value.match?(/(?:authorization|api[_-]?key|token|secret|password|passwd|cookie)\s*[:=]/i) ||
164
+ value.match?(/\bBearer\s+[A-Za-z0-9._~+\/=-]+/i) ||
165
+ value.match?(/\blbw_(?:ingest|client|api)_[A-Za-z0-9._-]+/i) ||
166
+ value.match?(/\b(?:github_pat|ghp|gho|npm|pypi|sk_live|sk_test|xox[baprs]|AKIA)[A-Za-z0-9._-]+/)
167
+ end
168
+ private_class_method :sensitive_string?
169
+
170
+ def redact_local_path(value)
171
+ return "[redacted-path]" if value.match?(/\A(?:\/Users\/|\/home\/|\/var\/folders\/|[A-Za-z]:\\)/)
172
+
173
+ value
174
+ end
175
+ private_class_method :redact_local_path
176
+
177
+ def redact_url(value)
178
+ uri = URI.parse(value)
179
+ return "[redacted-url]#{uri.path.empty? ? '/' : uri.path}" if uri.is_a?(URI::HTTP) && uri.host
180
+
181
+ value.split(/[?#]/, 2)[0]
182
+ rescue URI::InvalidURIError
183
+ value.split(/[?#]/, 2)[0]
184
+ end
185
+ private_class_method :redact_url
186
+ end
187
+ end