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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Builds the event hash for one exception in the caller's thread, already
|
|
6
|
+
# within the server's size limits (INGEST_API.md). Filtering and
|
|
7
|
+
# before_send happen afterwards, in EventPipeline.
|
|
8
|
+
class Builder
|
|
9
|
+
NOTIFIER = { "name" => NOTIFIER_NAME, "version" => VERSION }.freeze
|
|
10
|
+
MAX_TRANSACTION_LENGTH = 1_000
|
|
11
|
+
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@config = config
|
|
14
|
+
@sanitizer = Sanitizer.new
|
|
15
|
+
@exception_section = build_exception_section(config)
|
|
16
|
+
@request_section = RequestSection.new(config.request_extractor, @sanitizer)
|
|
17
|
+
@runtime = RuntimeContext.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(exception, options, scope)
|
|
21
|
+
event = header(options)
|
|
22
|
+
event["exception"] = @exception_section.build(exception)
|
|
23
|
+
event.merge!(scope_sections(options, scope))
|
|
24
|
+
event["context"] = context(exception, options, scope)
|
|
25
|
+
event["fingerprint"] = Fields.fingerprint(options[:fingerprint])
|
|
26
|
+
event["notifier"] = NOTIFIER.dup
|
|
27
|
+
event.compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_exception_section(config)
|
|
33
|
+
paths = Backtrace::Paths.new(config.root)
|
|
34
|
+
code_context = Backtrace::CodeContext.new(Backtrace::LineCache.new(paths))
|
|
35
|
+
ExceptionSection.new(paths: paths, code_context: code_context, send_code_context: config.send_code_context)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def header(options)
|
|
39
|
+
{
|
|
40
|
+
"event_id" => Fields.event_id,
|
|
41
|
+
"occurred_at" => Fields.timestamp,
|
|
42
|
+
"level" => Fields.level(options[:level]),
|
|
43
|
+
"release" => @config.release
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scope_sections(options, scope)
|
|
48
|
+
{
|
|
49
|
+
"transaction" => Fields.text(options[:transaction] || scope.transaction, MAX_TRANSACTION_LENGTH),
|
|
50
|
+
"request" => @request_section.build(scope),
|
|
51
|
+
"job" => JobSection.build(scope.job, @sanitizer),
|
|
52
|
+
"user" => UserSection.build(options[:user] || scope.user),
|
|
53
|
+
"breadcrumbs" => breadcrumbs(scope),
|
|
54
|
+
"tags" => presence(TagSet.merge(@config.tags, scope.tags, options[:tags]))
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def presence(hash)
|
|
59
|
+
hash.empty? ? nil : hash
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def breadcrumbs(scope)
|
|
63
|
+
crumbs = scope.breadcrumbs.to_a.last(100)
|
|
64
|
+
crumbs.empty? ? nil : crumbs.map(&:dup)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def context(exception, options, scope)
|
|
68
|
+
context = @runtime.to_h
|
|
69
|
+
causes = CauseChain.new(exception).to_a
|
|
70
|
+
context["cause"] = causes unless causes.empty?
|
|
71
|
+
context.merge!(@sanitizer.sanitize(scope.context)) if scope.context.any?
|
|
72
|
+
context.merge!(@sanitizer.sanitize(options[:context])) if options[:context].is_a?(Hash)
|
|
73
|
+
context
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Up to three causes of an exception, as class and message.
|
|
6
|
+
class CauseChain
|
|
7
|
+
MAX_CAUSES = 3
|
|
8
|
+
MAX_MESSAGE_LENGTH = 1_000
|
|
9
|
+
|
|
10
|
+
def initialize(exception)
|
|
11
|
+
@exception = exception
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_a
|
|
15
|
+
causes.map do |cause|
|
|
16
|
+
{
|
|
17
|
+
"class" => ExceptionSection.class_name(cause),
|
|
18
|
+
"message" => ExceptionSection.message(cause, MAX_MESSAGE_LENGTH)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def causes
|
|
26
|
+
seen = {}.compare_by_identity
|
|
27
|
+
seen[@exception] = true
|
|
28
|
+
current = @exception.cause
|
|
29
|
+
list = []
|
|
30
|
+
while current && list.size < MAX_CAUSES && !seen[current]
|
|
31
|
+
seen[current] = true
|
|
32
|
+
list << current
|
|
33
|
+
current = current.cause
|
|
34
|
+
end
|
|
35
|
+
list
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# The "exception" section: class, message and backtrace frames, with code
|
|
6
|
+
# context for the top in-app frames.
|
|
7
|
+
class ExceptionSection
|
|
8
|
+
MAX_CLASS_LENGTH = 255
|
|
9
|
+
MAX_MESSAGE_LENGTH = 10_000
|
|
10
|
+
MAX_FRAMES = 250
|
|
11
|
+
CONTEXT_FRAMES = 10
|
|
12
|
+
|
|
13
|
+
def initialize(paths:, code_context:, send_code_context:)
|
|
14
|
+
@paths = paths
|
|
15
|
+
@code_context = code_context
|
|
16
|
+
@send_code_context = send_code_context
|
|
17
|
+
@parser = Backtrace::Parser.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(exception)
|
|
21
|
+
{
|
|
22
|
+
"class" => self.class.class_name(exception),
|
|
23
|
+
"message" => self.class.message(exception, MAX_MESSAGE_LENGTH),
|
|
24
|
+
"backtrace" => backtrace(exception)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.class_name(exception)
|
|
29
|
+
Text.limit(exception.class.name || exception.class.to_s, MAX_CLASS_LENGTH)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.message(exception, max)
|
|
33
|
+
Text.limit(exception.message, max)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def backtrace(exception)
|
|
41
|
+
remaining = @send_code_context ? CONTEXT_FRAMES : 0
|
|
42
|
+
@parser.frames(exception, limit: MAX_FRAMES).map do |frame|
|
|
43
|
+
hash = frame_hash(frame)
|
|
44
|
+
next hash unless hash["in_app"] && remaining.positive?
|
|
45
|
+
|
|
46
|
+
remaining -= 1
|
|
47
|
+
add_code_context(hash, frame)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def frame_hash(frame)
|
|
52
|
+
path = frame.path.to_s
|
|
53
|
+
{
|
|
54
|
+
"file" => Text.limit(@paths.relative(path), 1_024),
|
|
55
|
+
"line" => frame.line,
|
|
56
|
+
"method" => frame.label && Text.limit(frame.label, 255),
|
|
57
|
+
"in_app" => @paths.in_app?(path)
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_code_context(hash, frame)
|
|
62
|
+
context = @code_context.around(frame.path.to_s, frame.line)
|
|
63
|
+
hash["context"] = context if context
|
|
64
|
+
hash
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Small normalizers for top-level event fields.
|
|
6
|
+
module Fields
|
|
7
|
+
LEVELS = %w[fatal error warning info].freeze
|
|
8
|
+
LEVEL_ALIASES = { "warn" => "warning", "critical" => "fatal", "debug" => "info" }.freeze
|
|
9
|
+
MAX_FINGERPRINT_PARTS = 10
|
|
10
|
+
MAX_FINGERPRINT_LENGTH = 200
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def level(value, default = "error")
|
|
15
|
+
name = value.to_s.downcase
|
|
16
|
+
name = LEVEL_ALIASES.fetch(name, name)
|
|
17
|
+
LEVELS.include?(name) ? name : default
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fingerprint(value)
|
|
21
|
+
return nil if value.nil?
|
|
22
|
+
|
|
23
|
+
parts = Array(value).select { |part| part.is_a?(String) || part.is_a?(Symbol) || part.is_a?(Numeric) }
|
|
24
|
+
parts = parts.first(MAX_FINGERPRINT_PARTS).map { |part| Text.limit(part.to_s, MAX_FINGERPRINT_LENGTH) }
|
|
25
|
+
parts.empty? ? nil : parts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def event_id
|
|
29
|
+
SecureRandom.respond_to?(:uuid_v7) ? SecureRandom.uuid_v7 : SecureRandom.uuid
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def timestamp(time = Time.now)
|
|
33
|
+
time.utc.iso8601(3)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A job timestamp as ISO 8601: a Time (or Date), epoch seconds, epoch
|
|
37
|
+
# milliseconds (Sidekiq 8) or an already formatted string.
|
|
38
|
+
def iso_time(value)
|
|
39
|
+
case value
|
|
40
|
+
when nil then nil
|
|
41
|
+
when String then Text.limit(value, 64)
|
|
42
|
+
when Time then timestamp(value)
|
|
43
|
+
when Numeric then timestamp(Time.at(epoch_seconds(value)))
|
|
44
|
+
else formatted_time(value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def epoch_seconds(value)
|
|
49
|
+
value > 100_000_000_000 ? value / 1000.0 : value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def formatted_time(value)
|
|
53
|
+
value.respond_to?(:iso8601) ? Text.limit(value.iso8601, 64) : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def text(value, max)
|
|
57
|
+
value.nil? ? nil : Text.limit(value, max)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# The "job" section (DESIGN.md 5.1): which job ran, where, and with which
|
|
6
|
+
# arguments. Keeps the known fields only; arguments are capped like
|
|
7
|
+
# request params and filtered by EventFilter afterwards.
|
|
8
|
+
module JobSection
|
|
9
|
+
TEXT_FIELDS = %w[
|
|
10
|
+
framework class id provider_job_id queue mailer action enqueued_at scheduled_at
|
|
11
|
+
].freeze
|
|
12
|
+
INTEGER_FIELDS = %w[priority executions retry_count].freeze
|
|
13
|
+
MAX_TEXT_LENGTH = 255
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def build(raw, sanitizer)
|
|
18
|
+
return nil unless raw.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
section = {}
|
|
21
|
+
raw.each { |key, value| add(section, key.to_s, value, sanitizer) }
|
|
22
|
+
section.empty? ? nil : section
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(section, key, value, sanitizer)
|
|
26
|
+
return if value.nil?
|
|
27
|
+
|
|
28
|
+
section[key] = Text.limit(value, MAX_TEXT_LENGTH) if TEXT_FIELDS.include?(key)
|
|
29
|
+
section[key] = value if INTEGER_FIELDS.include?(key) && value.is_a?(Integer)
|
|
30
|
+
section[key] = sanitizer.sanitize(value) if key == "arguments"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# The "request" section. Integrations either store a ready hash in
|
|
6
|
+
# scope.request, or keep the Rack env in scope.request_env and set
|
|
7
|
+
# config.request_extractor (an object responding to +extract(env)+), so
|
|
8
|
+
# the request is only read when an error is actually reported.
|
|
9
|
+
class RequestSection
|
|
10
|
+
SCALARS = %w[url method format request_id remote_ip].freeze
|
|
11
|
+
NESTED = %w[params headers session].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(extractor, sanitizer)
|
|
14
|
+
@extractor = extractor
|
|
15
|
+
@sanitizer = sanitizer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build(scope)
|
|
19
|
+
raw = scope.request || extract(scope.request_env)
|
|
20
|
+
return nil unless raw.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
section = {}
|
|
23
|
+
raw.each { |key, value| add(section, key.to_s, value) }
|
|
24
|
+
section.empty? ? nil : section
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def extract(env)
|
|
30
|
+
env && @extractor ? @extractor.extract(env) : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add(section, key, value)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
|
|
36
|
+
section[key] = Text.limit(value, 4_096) if SCALARS.include?(key)
|
|
37
|
+
section[key] = @sanitizer.sanitize(value) if NESTED.include?(key) && value.is_a?(Hash)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Runtime details for the Environment tab: Ruby (and YJIT), Rails, host,
|
|
6
|
+
# process and the RAILS_ENV/RACK_ENV variables only.
|
|
7
|
+
class RuntimeContext
|
|
8
|
+
ENV_NAMES = %w[RAILS_ENV RACK_ENV].freeze
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@ruby = ruby_label
|
|
12
|
+
@host = hostname
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
"runtime" => { "ruby" => @ruby, "rails" => rails_version }.compact,
|
|
18
|
+
"host" => @host,
|
|
19
|
+
"process" => "pid #{Process.pid} · thread #{thread_label}",
|
|
20
|
+
"env" => env_vars
|
|
21
|
+
}.compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def env_vars
|
|
27
|
+
vars = ENV.to_h.slice(*ENV_NAMES)
|
|
28
|
+
vars.empty? ? nil : vars
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ruby_label
|
|
32
|
+
yjit = defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.respond_to?(:enabled?) && ::RubyVM::YJIT.enabled?
|
|
33
|
+
yjit ? "#{RUBY_VERSION} (YJIT on)" : RUBY_VERSION
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rails_version
|
|
37
|
+
::Rails.version.to_s if defined?(::Rails) && ::Rails.respond_to?(:version)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hostname
|
|
41
|
+
Text.limit(Socket.gethostname, 255)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def thread_label
|
|
47
|
+
thread = Thread.current
|
|
48
|
+
thread.name || thread.native_thread_id || thread.object_id
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Merges tag hashes (later ones win) and keeps what the server accepts:
|
|
6
|
+
# up to 25 keys matching KEY with scalar values of at most 200 chars.
|
|
7
|
+
module TagSet
|
|
8
|
+
KEY = /\A[a-z0-9_.-]{1,32}\z/i
|
|
9
|
+
MAX_TAGS = 25
|
|
10
|
+
MAX_VALUE_LENGTH = 200
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def merge(*hashes)
|
|
15
|
+
merged = {}
|
|
16
|
+
hashes.each { |hash| add(merged, hash) if hash.is_a?(Hash) }
|
|
17
|
+
merged.first(MAX_TAGS).to_h
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add(merged, hash)
|
|
21
|
+
hash.each do |key, raw|
|
|
22
|
+
name = key.to_s
|
|
23
|
+
value = scalar(raw)
|
|
24
|
+
merged[name] = value if KEY.match?(name) && !value.nil?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scalar(raw)
|
|
29
|
+
case raw
|
|
30
|
+
when String, Symbol then Text.limit(raw.to_s, MAX_VALUE_LENGTH)
|
|
31
|
+
when Integer, true, false then raw
|
|
32
|
+
when Float then finite_float(raw)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finite_float(raw)
|
|
37
|
+
raw.finite? ? raw : nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Event
|
|
5
|
+
# Keeps only the four user fields the server accepts. Reads them from a
|
|
6
|
+
# hash (string or symbol keys) or from an object such as a User model.
|
|
7
|
+
module UserSection
|
|
8
|
+
FIELDS = %w[id email username name].freeze
|
|
9
|
+
MAX_LENGTH = 255
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def build(user)
|
|
14
|
+
return nil if user.nil?
|
|
15
|
+
|
|
16
|
+
fields = FIELDS.to_h { |field| [field, value(read(user, field))] }.compact
|
|
17
|
+
fields.empty? ? nil : fields
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(user, field)
|
|
21
|
+
return user.fetch(field) { user[field.to_sym] } if user.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
user.public_send(field) if user.respond_to?(field)
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def value(raw)
|
|
29
|
+
return raw if raw.is_a?(Integer)
|
|
30
|
+
return nil if raw.nil?
|
|
31
|
+
|
|
32
|
+
Text.limit(raw, MAX_LENGTH)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Applies the key filter, and any extra filters, to every part of an event
|
|
5
|
+
# that holds free-form data: request params, headers, session and URL query,
|
|
6
|
+
# job arguments, context, tags and breadcrumb data. The user section is left alone on
|
|
7
|
+
# purpose (DESIGN.md 3.4).
|
|
8
|
+
#
|
|
9
|
+
# Extra filters (config.extra_filters) are objects responding to
|
|
10
|
+
# +filter(hash) -> hash+, for example ActiveSupport::ParameterFilter, which
|
|
11
|
+
# the Rails integration adds for Rails.application.config.filter_parameters.
|
|
12
|
+
class EventFilter
|
|
13
|
+
REQUEST_SECTIONS = %w[params headers session].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(key_filter, extra_filters = [])
|
|
16
|
+
@key_filter = key_filter
|
|
17
|
+
@extra_filters = Array(extra_filters)
|
|
18
|
+
@extra_probe = @extra_filters.empty? ? nil : ExtraFilterProbe.new(@extra_filters)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def filter_event(event)
|
|
22
|
+
filter_request(event["request"]) if event["request"]
|
|
23
|
+
filter_job(event["job"]) if event["job"]
|
|
24
|
+
filter_section(event, "context")
|
|
25
|
+
filter_section(event, "tags")
|
|
26
|
+
event["breadcrumbs"]&.each { |crumb| filter_section(crumb, "data") }
|
|
27
|
+
event
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def filter_hash(hash)
|
|
31
|
+
filtered = @key_filter.filter(hash)
|
|
32
|
+
@extra_filters.reduce(filtered) { |result, extra| extra.filter(result) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def filter_request(request)
|
|
38
|
+
request["url"] = @key_filter.filter_url(request["url"], @extra_probe) if request["url"]
|
|
39
|
+
REQUEST_SECTIONS.each { |name| filter_section(request, name) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Arguments are often an array, so they are filtered inside a wrapper hash.
|
|
43
|
+
def filter_job(job)
|
|
44
|
+
return unless job.key?("arguments")
|
|
45
|
+
|
|
46
|
+
job["arguments"] = filter_hash({ "arguments" => job["arguments"] })["arguments"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def filter_section(container, name)
|
|
50
|
+
value = container[name]
|
|
51
|
+
container[name] = filter_hash(value) if value.is_a?(Hash)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Builds, filters and passes an event through before_send, in the caller's
|
|
5
|
+
# thread. Returns the event, or nil when before_send drops it (or raises).
|
|
6
|
+
class EventPipeline
|
|
7
|
+
def initialize(config, log)
|
|
8
|
+
@builder = Event::Builder.new(config)
|
|
9
|
+
@filter = EventFilter.new(KeyFilter.new(config.filter_params), config.event_filters)
|
|
10
|
+
@before_send = config.before_send
|
|
11
|
+
@log = log
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def prepare(exception, options, scope)
|
|
15
|
+
event = @builder.build(exception, options, scope)
|
|
16
|
+
@filter.filter_event(event)
|
|
17
|
+
apply_before_send(event)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def apply_before_send(event)
|
|
23
|
+
return event unless @before_send
|
|
24
|
+
|
|
25
|
+
result = @before_send.call(event)
|
|
26
|
+
return result if result.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
@log.warn_once(:before_send_type, "before_send returned #{result.class}; event dropped") unless result.nil?
|
|
29
|
+
nil
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@log.warn_every(:before_send_error, 60, "before_send raised #{e.class}; event dropped")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Serializes an event to JSON, dropping (and logging, by size only) events
|
|
5
|
+
# over the server's 512 KiB limit.
|
|
6
|
+
class EventSerializer
|
|
7
|
+
MAX_BYTES = 524_288
|
|
8
|
+
|
|
9
|
+
def initialize(log)
|
|
10
|
+
@log = log
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dump(event)
|
|
14
|
+
json = JSON.generate(event)
|
|
15
|
+
return json if json.bytesize <= MAX_BYTES
|
|
16
|
+
|
|
17
|
+
@log.warn_once(:oversized_event,
|
|
18
|
+
"dropped an event of #{json.bytesize} bytes (limit 512 KiB); later drops are not logged")
|
|
19
|
+
nil
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
@log.warn_every(:unserializable_event, 300, "dropped an event that could not be serialized (#{e.class})")
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Turns what was passed to notify into an exception. A message becomes a
|
|
5
|
+
# RuntimeError whose backtrace is the caller's stack (without this gem's
|
|
6
|
+
# own frames).
|
|
7
|
+
module ExceptionCoercion
|
|
8
|
+
GEM_DIR = File.expand_path("..", __dir__)
|
|
9
|
+
|
|
10
|
+
def self.coerce(error)
|
|
11
|
+
return error if error.is_a?(Exception)
|
|
12
|
+
|
|
13
|
+
exception = RuntimeError.new(Text.safe_to_s(error))
|
|
14
|
+
exception.set_backtrace(caller.reject { |line| line.start_with?(GEM_DIR) })
|
|
15
|
+
exception
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Asks the extra filters (such as Rails' filter_parameters) whether they
|
|
5
|
+
# would filter a key path, by filtering a hash that holds one probe value
|
|
6
|
+
# at that path. Used for URL query parameters, which are text, not hashes.
|
|
7
|
+
class ExtraFilterProbe
|
|
8
|
+
PROBE = "goodsorcery-probe"
|
|
9
|
+
|
|
10
|
+
def initialize(extra_filters)
|
|
11
|
+
@extra_filters = Array(extra_filters)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# +parts+ is a parameter name split on brackets: "user[pin]" => ["user", "pin"].
|
|
15
|
+
def filters_path?(parts)
|
|
16
|
+
return false if parts.empty?
|
|
17
|
+
|
|
18
|
+
@extra_filters.any? { |extra| !survives?(extra.filter(probe(parts)), parts) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def probe(parts)
|
|
24
|
+
parts.reverse.reduce(PROBE) { |inner, part| { part => inner } }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def survives?(value, parts)
|
|
28
|
+
parts.each { |part| value = value.is_a?(Hash) ? value[part] : nil }
|
|
29
|
+
value == PROBE
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Exception classes that are never reported, by name; subclasses (any
|
|
5
|
+
# ancestor with a listed name) are ignored too.
|
|
6
|
+
class IgnoreList
|
|
7
|
+
def initialize(entries)
|
|
8
|
+
@names = Array(entries).to_h { |entry| [entry.is_a?(Module) ? entry.name : entry.to_s, true] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ignore?(exception)
|
|
12
|
+
exception.class.ancestors.any? { |ancestor| @names.key?(ancestor.name.to_s) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|