goodsorcery 0.1.0
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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +299 -0
- data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
- data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
- data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
- data/lib/goodsorcery/backtrace/code_context.rb +27 -0
- data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
- data/lib/goodsorcery/backtrace/parser.rb +38 -0
- data/lib/goodsorcery/backtrace/paths.rb +42 -0
- data/lib/goodsorcery/bundler_plugin.rb +117 -0
- data/lib/goodsorcery/client.rb +180 -0
- data/lib/goodsorcery/configuration/validation.rb +59 -0
- data/lib/goodsorcery/configuration.rb +204 -0
- data/lib/goodsorcery/defaults.rb +40 -0
- data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
- data/lib/goodsorcery/delivery/async.rb +122 -0
- data/lib/goodsorcery/delivery/backoff.rb +20 -0
- data/lib/goodsorcery/delivery/custom.rb +27 -0
- data/lib/goodsorcery/delivery/event_queue.rb +124 -0
- data/lib/goodsorcery/delivery/sync.rb +27 -0
- data/lib/goodsorcery/delivery/test.rb +30 -0
- data/lib/goodsorcery/delivery/worker.rb +71 -0
- data/lib/goodsorcery/delivery.rb +39 -0
- data/lib/goodsorcery/delivery_job.rb +26 -0
- data/lib/goodsorcery/env_flag.rb +24 -0
- data/lib/goodsorcery/environment_gate.rb +61 -0
- data/lib/goodsorcery/errors.rb +14 -0
- data/lib/goodsorcery/event/breadcrumb.rb +32 -0
- data/lib/goodsorcery/event/builder.rb +77 -0
- data/lib/goodsorcery/event/cause_chain.rb +39 -0
- data/lib/goodsorcery/event/exception_section.rb +68 -0
- data/lib/goodsorcery/event/fields.rb +61 -0
- data/lib/goodsorcery/event/job_section.rb +34 -0
- data/lib/goodsorcery/event/request_section.rb +41 -0
- data/lib/goodsorcery/event/runtime_context.rb +52 -0
- data/lib/goodsorcery/event/tag_set.rb +41 -0
- data/lib/goodsorcery/event/user_section.rb +36 -0
- data/lib/goodsorcery/event_filter.rb +54 -0
- data/lib/goodsorcery/event_pipeline.rb +35 -0
- data/lib/goodsorcery/event_serializer.rb +25 -0
- data/lib/goodsorcery/exception_coercion.rb +18 -0
- data/lib/goodsorcery/extra_filter_probe.rb +32 -0
- data/lib/goodsorcery/ignore_list.rb +15 -0
- data/lib/goodsorcery/install_check.rb +58 -0
- data/lib/goodsorcery/integrations/action_cable.rb +75 -0
- data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
- data/lib/goodsorcery/integrations/active_job.rb +39 -0
- data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
- data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
- data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
- data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
- data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
- data/lib/goodsorcery/integrations/rails.rb +56 -0
- data/lib/goodsorcery/integrations/rake.rb +68 -0
- data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
- data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
- data/lib/goodsorcery/integrations.rb +27 -0
- data/lib/goodsorcery/job_arguments.rb +77 -0
- data/lib/goodsorcery/key_filter.rb +99 -0
- data/lib/goodsorcery/log.rb +55 -0
- data/lib/goodsorcery/masked_key.rb +29 -0
- data/lib/goodsorcery/rack/middleware.rb +57 -0
- data/lib/goodsorcery/rack/request_details.rb +120 -0
- data/lib/goodsorcery/rack/request_enricher.rb +20 -0
- data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
- data/lib/goodsorcery/railtie.rb +29 -0
- data/lib/goodsorcery/release_detector.rb +42 -0
- data/lib/goodsorcery/reported_mark.rb +19 -0
- data/lib/goodsorcery/sanitizer.rb +71 -0
- data/lib/goodsorcery/scope.rb +106 -0
- data/lib/goodsorcery/status_report.rb +50 -0
- data/lib/goodsorcery/tasks.rb +46 -0
- data/lib/goodsorcery/test_event.rb +46 -0
- data/lib/goodsorcery/text.rb +35 -0
- data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
- data/lib/goodsorcery/transport/connection.rb +87 -0
- data/lib/goodsorcery/transport/result.rb +103 -0
- data/lib/goodsorcery/transport.rb +172 -0
- data/lib/goodsorcery/verification.rb +47 -0
- data/lib/goodsorcery/version.rb +6 -0
- data/lib/goodsorcery.rb +176 -0
- data/plugins.rb +6 -0
- metadata +130 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Marks exception objects once reported, so the same object reaching
|
|
5
|
+
# several integrations (middleware, Rails.error, Sidekiq) is sent once.
|
|
6
|
+
module ReportedMark
|
|
7
|
+
IVAR = :@__goodsorcery_reported
|
|
8
|
+
|
|
9
|
+
def self.reported?(exception)
|
|
10
|
+
exception.instance_variable_defined?(IVAR)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.mark(exception)
|
|
14
|
+
exception.instance_variable_set(IVAR, true) unless exception.frozen?
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Turns app data into JSON-safe values within the server's limits
|
|
5
|
+
# (INGEST_API.md "Limits"): nesting depth, array and object sizes, string
|
|
6
|
+
# lengths. Unknown objects become strings; structs become hashes so their
|
|
7
|
+
# keys are filtered too. The result shares no mutable objects with the app.
|
|
8
|
+
class Sanitizer
|
|
9
|
+
TRUNCATED = "[truncated]"
|
|
10
|
+
|
|
11
|
+
def initialize(max_depth: 6, max_items: 50, max_keys: 100, max_string: 4_096)
|
|
12
|
+
@max_depth = max_depth
|
|
13
|
+
@max_items = max_items
|
|
14
|
+
@max_keys = max_keys
|
|
15
|
+
@max_string = max_string
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sanitize(value, depth = 1)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then sanitize_hash(value, depth)
|
|
21
|
+
when Array then sanitize_array(value, depth)
|
|
22
|
+
when String then Text.limit(value, @max_string)
|
|
23
|
+
when Symbol then Text.limit(value.name, @max_string)
|
|
24
|
+
when Integer, true, false, nil then value
|
|
25
|
+
else sanitize_object(value, depth)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def sanitize_hash(hash, depth)
|
|
32
|
+
return TRUNCATED if depth > @max_depth
|
|
33
|
+
|
|
34
|
+
hash.first(@max_keys).to_h do |key, value|
|
|
35
|
+
[Text.limit(key, 200), sanitize(value, depth + 1)]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sanitize_array(array, depth)
|
|
40
|
+
return TRUNCATED if depth > @max_depth
|
|
41
|
+
|
|
42
|
+
array.first(@max_items).map { |item| sanitize(item, depth + 1) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sanitize_object(value, depth)
|
|
46
|
+
return value if value.is_a?(Float) && value.finite?
|
|
47
|
+
return value.utc.iso8601(3) if value.is_a?(Time)
|
|
48
|
+
return sanitize_collection(value, depth) if collection?(value)
|
|
49
|
+
|
|
50
|
+
Text.limit(Text.safe_to_s(value), @max_string)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Objects whose to_s would print their fields (OpenStruct's is its
|
|
54
|
+
# inspect) become hashes or arrays, so their keys are filtered too.
|
|
55
|
+
def collection?(value)
|
|
56
|
+
structured?(value) || value.respond_to?(:to_unsafe_h) || value.is_a?(::Set)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def structured?(value)
|
|
60
|
+
value.is_a?(Struct) || (defined?(::Data) && value.is_a?(::Data)) ||
|
|
61
|
+
(defined?(::OpenStruct) && value.is_a?(::OpenStruct)) # rubocop:disable Style/OpenStructUse
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sanitize_collection(value, depth)
|
|
65
|
+
return sanitize_array(value.to_a, depth) if value.is_a?(::Set)
|
|
66
|
+
return sanitize_hash(value.to_unsafe_h, depth) if value.respond_to?(:to_unsafe_h)
|
|
67
|
+
|
|
68
|
+
sanitize_hash(value.to_h, depth)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# What is known about the current unit of work (a request, a job): context,
|
|
5
|
+
# user, tags, transaction, request, job and breadcrumbs. Each thread (and
|
|
6
|
+
# fiber) has its own scope. Hashes are replaced rather than mutated, so a
|
|
7
|
+
# saved copy stays valid.
|
|
8
|
+
#
|
|
9
|
+
# An integration that opens a scope can leave an +enricher+ (an object
|
|
10
|
+
# responding to +enrich(scope)+) that fills in details only worth computing
|
|
11
|
+
# when an error is actually reported: the request, the job section, the
|
|
12
|
+
# user, the transaction.
|
|
13
|
+
class Scope
|
|
14
|
+
STORAGE_KEY = :__goodsorcery_scope
|
|
15
|
+
|
|
16
|
+
attr_reader :context, :tags, :breadcrumbs
|
|
17
|
+
attr_accessor :user, :transaction, :request, :request_env, :job, :enricher
|
|
18
|
+
|
|
19
|
+
def self.current
|
|
20
|
+
Thread.current[STORAGE_KEY] ||= new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The scope an integration opened for the current request, job or channel
|
|
24
|
+
# action (GoodSorcery.with_scope), or nil outside one.
|
|
25
|
+
def self.unit_of_work
|
|
26
|
+
scope = Thread.current[STORAGE_KEY]
|
|
27
|
+
scope&.unit_of_work? ? scope : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.current=(scope)
|
|
31
|
+
Thread.current[STORAGE_KEY] = scope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.clear
|
|
35
|
+
Thread.current[STORAGE_KEY] = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(unit_of_work: false)
|
|
39
|
+
@context = {}
|
|
40
|
+
@tags = {}
|
|
41
|
+
@user = nil
|
|
42
|
+
@breadcrumbs = Breadcrumbs.new
|
|
43
|
+
@unit_of_work = unit_of_work
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unit_of_work?
|
|
47
|
+
@unit_of_work
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs the enricher once; later reports reuse what it filled in.
|
|
51
|
+
def enrich
|
|
52
|
+
enricher = @enricher
|
|
53
|
+
return unless enricher
|
|
54
|
+
|
|
55
|
+
@enricher = nil
|
|
56
|
+
enricher.enrich(self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def merge_context(hash)
|
|
60
|
+
@context = @context.merge(hash) if hash.is_a?(Hash)
|
|
61
|
+
@context
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Merges +hash+ into the context for the duration of the block.
|
|
65
|
+
def with_context(hash)
|
|
66
|
+
previous = @context
|
|
67
|
+
merge_context(hash)
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
@context = previous
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def merge_tags(hash)
|
|
74
|
+
@tags = @tags.merge(hash) if hash.is_a?(Hash)
|
|
75
|
+
@tags
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_breadcrumb(crumb, limit)
|
|
79
|
+
@breadcrumbs.add(crumb, limit)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A ring buffer of the most recent breadcrumbs in a scope.
|
|
84
|
+
class Breadcrumbs
|
|
85
|
+
def initialize
|
|
86
|
+
@items = []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def add(crumb, limit)
|
|
90
|
+
@items << crumb
|
|
91
|
+
@items.shift(@items.size - limit) if @items.size > limit
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_a
|
|
95
|
+
@items.dup
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def size
|
|
99
|
+
@items.size
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clear
|
|
103
|
+
@items.clear
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Prints "[goodsorcery]"-prefixed lines describing the setup, the verify
|
|
5
|
+
# result and the test event result. Shows the masked API key only.
|
|
6
|
+
class StatusReport
|
|
7
|
+
PREFIX = "[goodsorcery]"
|
|
8
|
+
|
|
9
|
+
def initialize(client, out: $stdout)
|
|
10
|
+
@client = client
|
|
11
|
+
@config = client.config
|
|
12
|
+
@out = out
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def print_settings
|
|
16
|
+
say("endpoint: #{@config.endpoint_label}")
|
|
17
|
+
say("environment: #{@config.environment} (#{enabled_label})")
|
|
18
|
+
say("delivery: #{delivery_label}")
|
|
19
|
+
say("api key: #{@config.masked_api_key}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def print_verification
|
|
23
|
+
verification = @client.verify
|
|
24
|
+
say("verify: #{verification.ok? ? "ok" : "FAILED"}, #{verification.message}")
|
|
25
|
+
verification.ok?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print_test_event
|
|
29
|
+
outcome = TestEvent.new(@client).send_now
|
|
30
|
+
say("test event: #{outcome.ok? ? "ok" : "FAILED"}, #{outcome.message}")
|
|
31
|
+
outcome.ok?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def say(line)
|
|
35
|
+
@out.puts("#{PREFIX} #{line}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def enabled_label
|
|
41
|
+
state = @client.enabled? ? "reporting enabled" : "reporting disabled"
|
|
42
|
+
"#{state}: #{@client.disabled_reason}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delivery_label
|
|
46
|
+
delivery = @config.delivery
|
|
47
|
+
delivery.is_a?(Symbol) ? delivery.to_s : delivery.class.name.to_s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require_relative "../goodsorcery"
|
|
5
|
+
require_relative "status_report"
|
|
6
|
+
require_relative "integrations/rake"
|
|
7
|
+
|
|
8
|
+
module GoodSorcery
|
|
9
|
+
# Rake tasks: `require "goodsorcery/tasks"` in a Rakefile (Rails apps get
|
|
10
|
+
# them automatically). They load the app's :environment task when there is
|
|
11
|
+
# one. Requiring this file also reports exceptions that end a rake run
|
|
12
|
+
# (GoodSorcery::Integrations::Rake).
|
|
13
|
+
module Tasks
|
|
14
|
+
extend Rake::DSL
|
|
15
|
+
|
|
16
|
+
def self.load_environment
|
|
17
|
+
Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.verify(out: $stdout)
|
|
21
|
+
report = StatusReport.new(GoodSorcery.client, out: out)
|
|
22
|
+
report.print_settings
|
|
23
|
+
report.print_verification
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.send_test_event(out: $stdout)
|
|
27
|
+
StatusReport.new(GoodSorcery.client, out: out).print_test_event
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
namespace :goodsorcery do
|
|
31
|
+
desc "Check the Good Sorcery endpoint and API key (sends nothing)"
|
|
32
|
+
task :verify do
|
|
33
|
+
GoodSorcery::Tasks.load_environment
|
|
34
|
+
abort("[goodsorcery] verification failed") unless GoodSorcery::Tasks.verify
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "Send a test exception to Good Sorcery, in any environment"
|
|
38
|
+
task :test do
|
|
39
|
+
GoodSorcery::Tasks.load_environment
|
|
40
|
+
abort("[goodsorcery] test event failed") unless GoodSorcery::Tasks.send_test_event
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
GoodSorcery::Integrations::Rake.install
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Raises, captures and synchronously sends a GoodSorcery::TestError,
|
|
5
|
+
# regardless of the environment check. Used by `goodsorcery:test` and the
|
|
6
|
+
# install check.
|
|
7
|
+
class TestEvent
|
|
8
|
+
MESSAGE = "Test error from goodsorcery-ruby: if you can see this, error reporting works."
|
|
9
|
+
|
|
10
|
+
Outcome = Struct.new(:ok, :event_id, :message) do
|
|
11
|
+
def ok? = ok
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_now
|
|
19
|
+
return Outcome.new(false, nil, "no API key configured") if @client.config.api_key.to_s.empty?
|
|
20
|
+
|
|
21
|
+
raise TestError, MESSAGE
|
|
22
|
+
rescue TestError => e
|
|
23
|
+
deliver(e)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def deliver(exception)
|
|
29
|
+
event = @client.prepare_event(exception, transaction: "goodsorcery:test",
|
|
30
|
+
tags: { "source" => "goodsorcery:test" })
|
|
31
|
+
return Outcome.new(false, nil, "the test event was dropped by before_send") unless event
|
|
32
|
+
|
|
33
|
+
json = @client.serializer.dump(event)
|
|
34
|
+
return Outcome.new(false, nil, "the test event could not be serialized") unless json
|
|
35
|
+
|
|
36
|
+
outcome_for(@client.transport.post_event(json), event)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def outcome_for(result, event)
|
|
40
|
+
return Outcome.new(false, nil, "the server answered #{result.description}") unless result.success?
|
|
41
|
+
|
|
42
|
+
id = result.event_id || event["event_id"]
|
|
43
|
+
Outcome.new(true, id, "sent test event #{id}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# String helpers: valid UTF-8, length caps, and copies the app can't mutate
|
|
5
|
+
# after the event was built.
|
|
6
|
+
module Text
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def limit(value, max)
|
|
10
|
+
string = utf8(value.is_a?(String) ? value : value.to_s)
|
|
11
|
+
return string[0, max] if string.length > max
|
|
12
|
+
|
|
13
|
+
string.frozen? ? string : string.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def utf8(string)
|
|
17
|
+
return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
|
|
18
|
+
|
|
19
|
+
copy = string.dup
|
|
20
|
+
copy.force_encoding(Encoding::UTF_8) if copy.encoding == Encoding::BINARY
|
|
21
|
+
return copy.scrub if copy.encoding == Encoding::UTF_8
|
|
22
|
+
|
|
23
|
+
copy.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
24
|
+
rescue EncodingError
|
|
25
|
+
string.dup.force_encoding(Encoding::UTF_8).scrub
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# to_s that never raises.
|
|
29
|
+
def safe_to_s(object)
|
|
30
|
+
object.to_s
|
|
31
|
+
rescue StandardError
|
|
32
|
+
"#<#{object.class}>"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
class Transport
|
|
5
|
+
# Splits serialized events into batch request bodies of at most 100
|
|
6
|
+
# events and about 1.5 MiB of uncompressed JSON (the server accepts 2 MiB).
|
|
7
|
+
module BatchChunks
|
|
8
|
+
MAX_EVENTS = 100
|
|
9
|
+
MAX_BYTES = 1_572_864
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def split(jsons)
|
|
14
|
+
chunks = []
|
|
15
|
+
current = []
|
|
16
|
+
bytes = 0
|
|
17
|
+
jsons.each do |json|
|
|
18
|
+
if full?(current, bytes, json)
|
|
19
|
+
chunks << current
|
|
20
|
+
current = []
|
|
21
|
+
bytes = 0
|
|
22
|
+
end
|
|
23
|
+
current << json
|
|
24
|
+
bytes += json.bytesize + 1
|
|
25
|
+
end
|
|
26
|
+
current.empty? ? chunks : chunks << current
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def full?(chunk, bytes, json)
|
|
30
|
+
return false if chunk.empty?
|
|
31
|
+
|
|
32
|
+
chunk.size >= MAX_EVENTS || bytes + json.bytesize > MAX_BYTES
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def body(chunk)
|
|
36
|
+
"{\"events\":[#{chunk.join(",")}]}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
class Transport
|
|
5
|
+
# One persistent (keep-alive) HTTP connection. A failed request closes it,
|
|
6
|
+
# so the next one reconnects. A request on a reused connection that the
|
|
7
|
+
# server already closed is retried once on a fresh connection; event
|
|
8
|
+
# POSTs are idempotent because the server deduplicates event_id.
|
|
9
|
+
#
|
|
10
|
+
# A connection opened before a fork belongs to the parent: the child
|
|
11
|
+
# drops it without closing it (closing would end the parent's TLS
|
|
12
|
+
# session) and opens its own.
|
|
13
|
+
class Connection
|
|
14
|
+
STALE_ERRORS = [EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
|
|
15
|
+
KEEP_ALIVE_SECONDS = 15
|
|
16
|
+
|
|
17
|
+
def initialize(uri, open_timeout:, read_timeout:)
|
|
18
|
+
@uri = uri
|
|
19
|
+
@open_timeout = open_timeout
|
|
20
|
+
@read_timeout = read_timeout
|
|
21
|
+
@http = nil
|
|
22
|
+
@pid = Process.pid
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request(request)
|
|
26
|
+
reused = @http&.started?
|
|
27
|
+
perform(request)
|
|
28
|
+
rescue *STALE_ERRORS
|
|
29
|
+
raise unless reused
|
|
30
|
+
|
|
31
|
+
perform(request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Sends one request on a short-lived connection.
|
|
35
|
+
def request_once(request)
|
|
36
|
+
http = build_http
|
|
37
|
+
http.start { |started| started.request(request) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def close
|
|
41
|
+
forget_inherited_connection
|
|
42
|
+
@http&.finish if @http&.started?
|
|
43
|
+
rescue IOError, SystemCallError
|
|
44
|
+
nil
|
|
45
|
+
ensure
|
|
46
|
+
@http = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def perform(request)
|
|
52
|
+
http.request(request)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
close
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def http
|
|
59
|
+
forget_inherited_connection
|
|
60
|
+
return @http if @http&.started?
|
|
61
|
+
|
|
62
|
+
@http = build_http
|
|
63
|
+
@http.start
|
|
64
|
+
@http
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def forget_inherited_connection
|
|
68
|
+
return if @pid == Process.pid
|
|
69
|
+
|
|
70
|
+
@pid = Process.pid
|
|
71
|
+
@http = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_http
|
|
75
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
76
|
+
http.use_ssl = @uri.scheme == "https"
|
|
77
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
78
|
+
http.open_timeout = @open_timeout
|
|
79
|
+
http.read_timeout = @read_timeout
|
|
80
|
+
http.write_timeout = @read_timeout
|
|
81
|
+
http.keep_alive_timeout = KEEP_ALIVE_SECONDS
|
|
82
|
+
http.max_retries = 0
|
|
83
|
+
http
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
class Transport
|
|
5
|
+
# The outcome of one request, classified for retry decisions:
|
|
6
|
+
#
|
|
7
|
+
# - success: 2xx
|
|
8
|
+
# - rejected: 400, 401, 413, 415, 422 and other 4xx (never retried)
|
|
9
|
+
# - not_found: 404 (an older server without the endpoint)
|
|
10
|
+
# - rate_limited: 429, with retry_after seconds
|
|
11
|
+
# - retryable: 5xx, 408, timeouts and connection errors
|
|
12
|
+
#
|
|
13
|
+
# +pending+ lists the serialized events still to send after a
|
|
14
|
+
# rate_limited or retryable batch result.
|
|
15
|
+
class Result
|
|
16
|
+
KINDS = %i[success rejected not_found rate_limited retryable].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :kind, :status, :retry_after, :error_class, :body, :pending
|
|
19
|
+
|
|
20
|
+
def self.from_response(response)
|
|
21
|
+
status = response.code.to_i
|
|
22
|
+
new(kind_for(status), status: status, body: ResponseBody.parse(response),
|
|
23
|
+
retry_after: status == 429 ? RetryAfter.parse(response["Retry-After"]) : nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.from_error(error)
|
|
27
|
+
new(:retryable, error_class: error.class.name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.kind_for(status)
|
|
31
|
+
return :success if (200..299).cover?(status)
|
|
32
|
+
return :not_found if status == 404
|
|
33
|
+
return :rate_limited if status == 429
|
|
34
|
+
return :retryable if status >= 500 || status == 408
|
|
35
|
+
|
|
36
|
+
:rejected
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(kind, status: nil, body: nil, retry_after: nil, error_class: nil, pending: [])
|
|
40
|
+
@kind = kind
|
|
41
|
+
@status = status
|
|
42
|
+
@body = body || {}
|
|
43
|
+
@retry_after = retry_after
|
|
44
|
+
@error_class = error_class
|
|
45
|
+
@pending = pending
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
KINDS.each { |name| define_method(:"#{name}?") { @kind == name } }
|
|
49
|
+
|
|
50
|
+
def retry_later?
|
|
51
|
+
rate_limited? || retryable?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def with_pending(pending)
|
|
55
|
+
self.class.new(kind, status: status, body: body, retry_after: retry_after,
|
|
56
|
+
error_class: error_class, pending: pending)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def event_id
|
|
60
|
+
body["id"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Status or error class only, never the body.
|
|
64
|
+
def description
|
|
65
|
+
status ? "HTTP #{status}" : error_class.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Parses small JSON response bodies; anything else becomes {}.
|
|
70
|
+
module ResponseBody
|
|
71
|
+
MAX_BYTES = 65_536
|
|
72
|
+
|
|
73
|
+
def self.parse(response)
|
|
74
|
+
text = response.body.to_s
|
|
75
|
+
return {} if text.empty? || text.bytesize > MAX_BYTES
|
|
76
|
+
|
|
77
|
+
parsed = JSON.parse(text)
|
|
78
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reads a Retry-After header (seconds or an HTTP date), defaulting to 60 s.
|
|
85
|
+
module RetryAfter
|
|
86
|
+
DEFAULT = 60
|
|
87
|
+
RANGE = (1..3_600)
|
|
88
|
+
|
|
89
|
+
def self.parse(value, now: Time.now)
|
|
90
|
+
seconds = value.to_s.strip.match?(/\A\d+\z/) ? value.to_i : http_date_delay(value, now)
|
|
91
|
+
(seconds || DEFAULT).clamp(RANGE.min, RANGE.max)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.http_date_delay(value, now)
|
|
95
|
+
return nil if value.nil? || value.to_s.empty?
|
|
96
|
+
|
|
97
|
+
(Time.httpdate(value.to_s) - now).ceil
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|