airbrake-ruby 4.6.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.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/lib/airbrake-ruby.rb +513 -0
  3. data/lib/airbrake-ruby/async_sender.rb +142 -0
  4. data/lib/airbrake-ruby/backtrace.rb +196 -0
  5. data/lib/airbrake-ruby/benchmark.rb +39 -0
  6. data/lib/airbrake-ruby/code_hunk.rb +51 -0
  7. data/lib/airbrake-ruby/config.rb +229 -0
  8. data/lib/airbrake-ruby/config/validator.rb +91 -0
  9. data/lib/airbrake-ruby/deploy_notifier.rb +36 -0
  10. data/lib/airbrake-ruby/file_cache.rb +48 -0
  11. data/lib/airbrake-ruby/filter_chain.rb +95 -0
  12. data/lib/airbrake-ruby/filters/context_filter.rb +29 -0
  13. data/lib/airbrake-ruby/filters/dependency_filter.rb +31 -0
  14. data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +46 -0
  15. data/lib/airbrake-ruby/filters/gem_root_filter.rb +33 -0
  16. data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +92 -0
  17. data/lib/airbrake-ruby/filters/git_repository_filter.rb +64 -0
  18. data/lib/airbrake-ruby/filters/git_revision_filter.rb +66 -0
  19. data/lib/airbrake-ruby/filters/keys_blacklist.rb +49 -0
  20. data/lib/airbrake-ruby/filters/keys_filter.rb +140 -0
  21. data/lib/airbrake-ruby/filters/keys_whitelist.rb +48 -0
  22. data/lib/airbrake-ruby/filters/root_directory_filter.rb +28 -0
  23. data/lib/airbrake-ruby/filters/sql_filter.rb +104 -0
  24. data/lib/airbrake-ruby/filters/system_exit_filter.rb +23 -0
  25. data/lib/airbrake-ruby/filters/thread_filter.rb +92 -0
  26. data/lib/airbrake-ruby/hash_keyable.rb +37 -0
  27. data/lib/airbrake-ruby/ignorable.rb +44 -0
  28. data/lib/airbrake-ruby/inspectable.rb +39 -0
  29. data/lib/airbrake-ruby/loggable.rb +34 -0
  30. data/lib/airbrake-ruby/monotonic_time.rb +43 -0
  31. data/lib/airbrake-ruby/nested_exception.rb +38 -0
  32. data/lib/airbrake-ruby/notice.rb +162 -0
  33. data/lib/airbrake-ruby/notice_notifier.rb +134 -0
  34. data/lib/airbrake-ruby/performance_breakdown.rb +45 -0
  35. data/lib/airbrake-ruby/performance_notifier.rb +125 -0
  36. data/lib/airbrake-ruby/promise.rb +109 -0
  37. data/lib/airbrake-ruby/query.rb +53 -0
  38. data/lib/airbrake-ruby/request.rb +45 -0
  39. data/lib/airbrake-ruby/response.rb +74 -0
  40. data/lib/airbrake-ruby/stashable.rb +15 -0
  41. data/lib/airbrake-ruby/stat.rb +73 -0
  42. data/lib/airbrake-ruby/sync_sender.rb +113 -0
  43. data/lib/airbrake-ruby/tdigest.rb +393 -0
  44. data/lib/airbrake-ruby/time_truncate.rb +17 -0
  45. data/lib/airbrake-ruby/timed_trace.rb +58 -0
  46. data/lib/airbrake-ruby/truncator.rb +115 -0
  47. data/lib/airbrake-ruby/version.rb +6 -0
  48. data/spec/airbrake_spec.rb +324 -0
  49. data/spec/async_sender_spec.rb +155 -0
  50. data/spec/backtrace_spec.rb +427 -0
  51. data/spec/benchmark_spec.rb +33 -0
  52. data/spec/code_hunk_spec.rb +115 -0
  53. data/spec/config/validator_spec.rb +184 -0
  54. data/spec/config_spec.rb +154 -0
  55. data/spec/deploy_notifier_spec.rb +48 -0
  56. data/spec/file_cache.rb +36 -0
  57. data/spec/filter_chain_spec.rb +92 -0
  58. data/spec/filters/context_filter_spec.rb +23 -0
  59. data/spec/filters/dependency_filter_spec.rb +12 -0
  60. data/spec/filters/exception_attributes_filter_spec.rb +50 -0
  61. data/spec/filters/gem_root_filter_spec.rb +41 -0
  62. data/spec/filters/git_last_checkout_filter_spec.rb +46 -0
  63. data/spec/filters/git_repository_filter.rb +61 -0
  64. data/spec/filters/git_revision_filter_spec.rb +126 -0
  65. data/spec/filters/keys_blacklist_spec.rb +225 -0
  66. data/spec/filters/keys_whitelist_spec.rb +194 -0
  67. data/spec/filters/root_directory_filter_spec.rb +39 -0
  68. data/spec/filters/sql_filter_spec.rb +219 -0
  69. data/spec/filters/system_exit_filter_spec.rb +14 -0
  70. data/spec/filters/thread_filter_spec.rb +277 -0
  71. data/spec/fixtures/notroot.txt +7 -0
  72. data/spec/fixtures/project_root/code.rb +221 -0
  73. data/spec/fixtures/project_root/empty_file.rb +0 -0
  74. data/spec/fixtures/project_root/long_line.txt +1 -0
  75. data/spec/fixtures/project_root/short_file.rb +3 -0
  76. data/spec/fixtures/project_root/vendor/bundle/ignored_file.rb +5 -0
  77. data/spec/helpers.rb +9 -0
  78. data/spec/ignorable_spec.rb +14 -0
  79. data/spec/inspectable_spec.rb +45 -0
  80. data/spec/monotonic_time_spec.rb +12 -0
  81. data/spec/nested_exception_spec.rb +73 -0
  82. data/spec/notice_notifier_spec.rb +356 -0
  83. data/spec/notice_notifier_spec/options_spec.rb +259 -0
  84. data/spec/notice_spec.rb +296 -0
  85. data/spec/performance_breakdown_spec.rb +12 -0
  86. data/spec/performance_notifier_spec.rb +435 -0
  87. data/spec/promise_spec.rb +197 -0
  88. data/spec/query_spec.rb +11 -0
  89. data/spec/request_spec.rb +11 -0
  90. data/spec/response_spec.rb +88 -0
  91. data/spec/spec_helper.rb +100 -0
  92. data/spec/stashable_spec.rb +23 -0
  93. data/spec/stat_spec.rb +47 -0
  94. data/spec/sync_sender_spec.rb +133 -0
  95. data/spec/tdigest_spec.rb +230 -0
  96. data/spec/time_truncate_spec.rb +13 -0
  97. data/spec/timed_trace_spec.rb +125 -0
  98. data/spec/truncator_spec.rb +238 -0
  99. metadata +213 -0
@@ -0,0 +1,11 @@
1
+ RSpec.describe Airbrake::Query do
2
+ describe "#stash" do
3
+ subject do
4
+ described_class.new(
5
+ method: 'GET', route: '/', query: '', start_time: Time.now
6
+ )
7
+ end
8
+
9
+ it { is_expected.to respond_to(:stash) }
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ RSpec.describe Airbrake::Request do
2
+ describe "#stash" do
3
+ subject do
4
+ described_class.new(
5
+ method: 'GET', route: '/', status_code: 200, start_time: Time.now
6
+ )
7
+ end
8
+
9
+ it { is_expected.to respond_to(:stash) }
10
+ end
11
+ end
@@ -0,0 +1,88 @@
1
+ RSpec.describe Airbrake::Response do
2
+ describe ".parse" do
3
+ [200, 201, 204].each do |code|
4
+ context "when response code is #{code}" do
5
+ it "logs response body" do
6
+ expect(Airbrake::Loggable.instance).to receive(:debug).with(
7
+ /Airbrake::Response \(#{code}\): {}/
8
+ )
9
+ described_class.parse(OpenStruct.new(code: code, body: '{}'))
10
+ end
11
+ end
12
+ end
13
+
14
+ [400, 401, 403, 420].each do |code|
15
+ context "when response code is #{code}" do
16
+ it "logs response message" do
17
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
18
+ /Airbrake: foo/
19
+ )
20
+ described_class.parse(
21
+ OpenStruct.new(code: code, body: '{"message":"foo"}')
22
+ )
23
+ end
24
+ end
25
+ end
26
+
27
+ context "when response code is 429" do
28
+ let(:response) { OpenStruct.new(code: 429, body: '{"message":"rate limited"}') }
29
+
30
+ it "logs response message" do
31
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
32
+ /Airbrake: rate limited/
33
+ )
34
+ described_class.parse(response)
35
+ end
36
+
37
+ it "returns an error response" do
38
+ time = Time.now
39
+ allow(Time).to receive(:now).and_return(time)
40
+
41
+ resp = described_class.parse(response)
42
+ expect(resp).to include(
43
+ 'error' => '**Airbrake: rate limited',
44
+ 'rate_limit_reset' => time
45
+ )
46
+ end
47
+ end
48
+
49
+ context "when response code is unhandled" do
50
+ let(:response) { OpenStruct.new(code: 500, body: 'foo') }
51
+
52
+ it "logs response body" do
53
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
54
+ /Airbrake: unexpected code \(500\)\. Body: foo/
55
+ )
56
+ described_class.parse(response)
57
+ end
58
+
59
+ it "returns an error response" do
60
+ resp = described_class.parse(response)
61
+ expect(resp).to eq('error' => 'foo')
62
+ end
63
+
64
+ it "truncates body" do
65
+ response.body *= 1000
66
+ resp = described_class.parse(response)
67
+ expect(resp).to eq('error' => ('foo' * 33) + 'fo...')
68
+ end
69
+ end
70
+
71
+ context "when response body can't be parsed as JSON" do
72
+ let(:response) { OpenStruct.new(code: 201, body: 'foo') }
73
+
74
+ it "logs response body" do
75
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
76
+ /Airbrake: error while parsing body \(.*unexpected token.*\)\. Body: foo/
77
+ )
78
+ described_class.parse(response)
79
+ end
80
+
81
+ it "returns an error message" do
82
+ expect(described_class.parse(response)['error']).to match(
83
+ /\A#<JSON::ParserError.+>/
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,100 @@
1
+ require 'simplecov'
2
+ SimpleCov.start if ENV['COVERAGE']
3
+
4
+ require 'airbrake-ruby'
5
+
6
+ require 'rspec/its'
7
+
8
+ require 'webmock'
9
+ require 'webmock/rspec'
10
+ require 'pry'
11
+
12
+ require 'pathname'
13
+ require 'webrick'
14
+ require 'English'
15
+ require 'base64'
16
+ require 'pp'
17
+
18
+ require 'helpers'
19
+
20
+ RSpec.configure do |c|
21
+ c.order = 'random'
22
+ c.color = true
23
+ c.disable_monkey_patching!
24
+ c.include Helpers
25
+ end
26
+
27
+ Thread.abort_on_exception = true
28
+
29
+ WebMock.disable_net_connect!(allow_localhost: true)
30
+
31
+ class AirbrakeTestError < RuntimeError
32
+ attr_reader :backtrace
33
+
34
+ def initialize(*)
35
+ super
36
+ # rubocop:disable Metrics/LineLength
37
+ @backtrace = [
38
+ "/home/kyrylo/code/airbrake/ruby/spec/spec_helper.rb:23:in `<top (required)>'",
39
+ "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'",
40
+ "/opt/rubies/ruby-2.2.2/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require'",
41
+ "/home/kyrylo/code/airbrake/ruby/spec/airbrake_spec.rb:1:in `<top (required)>'",
42
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `load'",
43
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1327:in `block in load_spec_files'",
44
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `each'",
45
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/configuration.rb:1325:in `load_spec_files'",
46
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:102:in `setup'",
47
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:88:in `run'",
48
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:73:in `run'",
49
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/lib/rspec/core/runner.rb:41:in `invoke'",
50
+ "/home/kyrylo/.gem/ruby/2.2.2/gems/rspec-core-3.3.2/exe/rspec:4:in `<main>'"
51
+ ]
52
+ # rubocop:enable Metrics/LineLength
53
+ end
54
+
55
+ # rubocop:disable Naming/AccessorMethodName
56
+ def set_backtrace(backtrace)
57
+ @backtrace = backtrace
58
+ end
59
+ # rubocop:enable Naming/AccessorMethodName
60
+
61
+ def message
62
+ 'App crashed!'
63
+ end
64
+ end
65
+
66
+ class JavaAirbrakeTestError < AirbrakeTestError
67
+ def initialize(*)
68
+ super
69
+ # rubocop:disable Metrics/LineLength
70
+ @backtrace = [
71
+ "org.jruby.java.invokers.InstanceMethodInvoker.call(InstanceMethodInvoker.java:26)",
72
+ "org.jruby.ir.interpreter.Interpreter.INTERPRET_EVAL(Interpreter.java:126)",
73
+ "org.jruby.RubyKernel$INVOKER$s$0$3$eval19.call(RubyKernel$INVOKER$s$0$3$eval19.gen)",
74
+ "org.jruby.RubyKernel$INVOKER$s$0$0$loop.call(RubyKernel$INVOKER$s$0$0$loop.gen)",
75
+ "org.jruby.runtime.IRBlockBody.doYield(IRBlockBody.java:139)",
76
+ "org.jruby.RubyKernel$INVOKER$s$rbCatch19.call(RubyKernel$INVOKER$s$rbCatch19.gen)",
77
+ "opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.invokeOther4:start(/opt/rubies/jruby-9.0.0.0/bin/irb)",
78
+ "opt.rubies.jruby_minus_9_dot_0_dot_0_dot_0.bin.irb.RUBY$script(/opt/rubies/jruby-9.0.0.0/bin/irb:13)",
79
+ "org.jruby.ir.Compiler$1.load(Compiler.java:111)",
80
+ "org.jruby.Main.run(Main.java:225)",
81
+ "org.jruby.Main.main(Main.java:197)"
82
+ ]
83
+ # rubocop:enable Metrics/LineLength
84
+ end
85
+
86
+ def is_a?(*)
87
+ true
88
+ end
89
+ end
90
+
91
+ class Ruby21Error < RuntimeError
92
+ attr_accessor :cause
93
+
94
+ def self.raise_error(msg)
95
+ ex = new(msg)
96
+ ex.cause = $ERROR_INFO
97
+
98
+ raise ex
99
+ end
100
+ end
@@ -0,0 +1,23 @@
1
+ RSpec.describe Airbrake::Stashable do
2
+ let(:klass) do
3
+ mod = described_class
4
+ Class.new { include(mod) }
5
+ end
6
+
7
+ describe "#stash" do
8
+ subject { klass.new }
9
+
10
+ it "returns a hash" do
11
+ expect(subject.stash).to be_a(Hash)
12
+ end
13
+
14
+ it "returns an empty hash" do
15
+ expect(subject.stash).to be_empty
16
+ end
17
+
18
+ it "remembers what was put in the stash" do
19
+ subject.stash[:foo] = 1
20
+ expect(subject.stash[:foo]).to eq(1)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ RSpec.describe Airbrake::Stat do
2
+ describe "#to_h" do
3
+ it "converts to a hash" do
4
+ expect(subject.to_h).to eq(
5
+ 'count' => 0,
6
+ 'sum' => 0.0,
7
+ 'sumsq' => 0.0,
8
+ 'tdigest' => 'AAAAAkA0AAAAAAAAAAAAAA=='
9
+ )
10
+ end
11
+ end
12
+
13
+ describe "#increment" do
14
+ let(:start_time) { Time.new(2018, 1, 1, 0, 0, 20, 0) }
15
+ let(:end_time) { Time.new(2018, 1, 1, 0, 0, 22, 0) }
16
+
17
+ before { subject.increment(start_time, end_time) }
18
+
19
+ its(:sum) { is_expected.to eq(2000) }
20
+ end
21
+
22
+ describe "#increment_ms" do
23
+ before { subject.increment_ms(1000) }
24
+
25
+ its(:count) { is_expected.to eq(1) }
26
+ its(:sum) { is_expected.to eq(1000) }
27
+ its(:sumsq) { is_expected.to eq(1000000) }
28
+
29
+ it "updates tdigest" do
30
+ expect(subject.tdigest.size).to eq(1)
31
+ end
32
+ end
33
+
34
+ describe "#inspect" do
35
+ it "provides custom inspect output" do
36
+ expect(subject.inspect).to eq(
37
+ '#<struct Airbrake::Stat count=0, sum=0.0, sumsq=0.0>'
38
+ )
39
+ end
40
+ end
41
+
42
+ describe "#pretty_print" do
43
+ it "is an alias of #inspect" do
44
+ expect(subject.method(:pretty_print)).to eql(subject.method(:inspect))
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,133 @@
1
+ RSpec.describe Airbrake::SyncSender do
2
+ before do
3
+ Airbrake::Config.instance = Airbrake::Config.new(
4
+ project_id: 1, project_key: 'banana'
5
+ )
6
+ end
7
+
8
+ describe "#send" do
9
+ let(:promise) { Airbrake::Promise.new }
10
+
11
+ let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) }
12
+ let(:endpoint) { 'https://api.airbrake.io/api/v3/projects/1/notices' }
13
+
14
+ before { stub_request(:post, endpoint).to_return(body: '{}') }
15
+
16
+ it "sets the Content-Type header to JSON" do
17
+ subject.send({}, promise)
18
+ expect(
19
+ a_request(:post, endpoint).with(
20
+ headers: { 'Content-Type' => 'application/json' }
21
+ )
22
+ ).to have_been_made.once
23
+ end
24
+
25
+ it "sets the User-Agent header to the notifier slug" do
26
+ subject.send({}, promise)
27
+ expect(
28
+ a_request(:post, endpoint).with(
29
+ headers: {
30
+ 'User-Agent' => %r{airbrake-ruby/\d+\.\d+\.\d+ Ruby/\d+\.\d+\.\d+}
31
+ }
32
+ )
33
+ ).to have_been_made.once
34
+ end
35
+
36
+ it "sets the Authorization header to the project key" do
37
+ subject.send({}, promise)
38
+ expect(
39
+ a_request(:post, endpoint).with(
40
+ headers: { 'Authorization' => 'Bearer banana' }
41
+ )
42
+ ).to have_been_made.once
43
+ end
44
+
45
+ it "catches exceptions raised while sending" do
46
+ https = double("foo")
47
+ allow(subject).to receive(:build_https).and_return(https)
48
+ allow(https).to receive(:request).and_raise(StandardError.new('foo'))
49
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
50
+ /HTTP error: foo/
51
+ )
52
+ expect(subject.send({}, promise)).to be_an(Airbrake::Promise)
53
+ expect(promise.value).to eq('error' => '**Airbrake: HTTP error: foo')
54
+ end
55
+
56
+ context "when request body is nil" do
57
+ it "doesn't send data" do
58
+ expect_any_instance_of(Airbrake::Truncator)
59
+ .to receive(:reduce_max_size).and_return(0)
60
+
61
+ encoded = Base64.encode64("\xD3\xE6\xBC\x9D\xBA").encode!('ASCII-8BIT')
62
+ bad_string = Base64.decode64(encoded)
63
+
64
+ ex = AirbrakeTestError.new
65
+ backtrace = []
66
+ 10.times { backtrace << "bin/rails:3:in `<#{bad_string}>'" }
67
+ ex.set_backtrace(backtrace)
68
+
69
+ notice = Airbrake::Notice.new(ex)
70
+
71
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
72
+ /data was not sent/
73
+ )
74
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
75
+ /truncation failed/
76
+ )
77
+ expect(subject.send(notice, promise)).to be_an(Airbrake::Promise)
78
+ expect(promise.value)
79
+ .to match('error' => '**Airbrake: data was not sent because of missing body')
80
+ end
81
+ end
82
+
83
+ context "when IP is rate limited" do
84
+ let(:endpoint) { %r{https://api.airbrake.io/api/v3/projects/1/notices} }
85
+
86
+ before do
87
+ stub_request(:post, endpoint).to_return(
88
+ status: 429,
89
+ body: '{"message":"IP is rate limited"}',
90
+ headers: { 'X-RateLimit-Delay' => '1' }
91
+ )
92
+ end
93
+
94
+ it "returns error" do
95
+ p1 = Airbrake::Promise.new
96
+ subject.send({}, p1)
97
+ expect(p1.value).to match('error' => '**Airbrake: IP is rate limited')
98
+
99
+ p2 = Airbrake::Promise.new
100
+ subject.send({}, p2)
101
+ expect(p2.value).to match('error' => '**Airbrake: IP is rate limited')
102
+
103
+ # Wait for X-RateLimit-Delay and then make a new request to make sure p2
104
+ # was ignored (no request made for it).
105
+ sleep 1
106
+
107
+ p3 = Airbrake::Promise.new
108
+ subject.send({}, p3)
109
+ expect(p3.value).to match('error' => '**Airbrake: IP is rate limited')
110
+
111
+ expect(a_request(:post, endpoint)).to have_been_made.twice
112
+ end
113
+ end
114
+
115
+ context "when the provided method is :put" do
116
+ before { stub_request(:put, endpoint).to_return(status: 200, body: '') }
117
+
118
+ it "PUTs the request" do
119
+ sender = described_class.new(:put)
120
+ sender.send({}, promise)
121
+ expect(a_request(:put, endpoint)).to have_been_made
122
+ end
123
+ end
124
+
125
+ context "when the provided method is :post" do
126
+ it "POSTs the request" do
127
+ sender = described_class.new(:post)
128
+ sender.send({}, promise)
129
+ expect(a_request(:post, endpoint)).to have_been_made
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,230 @@
1
+ RSpec.describe Airbrake::TDigest do
2
+ describe "byte serialization" do
3
+ it "loads serialized data" do
4
+ subject.push(60, 100)
5
+ 10.times { subject.push(rand * 100) }
6
+ bytes = subject.as_bytes
7
+ new_tdigest = described_class.from_bytes(bytes)
8
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
9
+ expect(new_tdigest.as_bytes).to eq(bytes)
10
+ end
11
+
12
+ it "handles zero size" do
13
+ bytes = subject.as_bytes
14
+ expect(described_class.from_bytes(bytes).size).to be_zero
15
+ end
16
+
17
+ it "preserves compression" do
18
+ td = described_class.new(0.001)
19
+ bytes = td.as_bytes
20
+ new_tdigest = described_class.from_bytes(bytes)
21
+ expect(new_tdigest.compression).to eq(td.compression)
22
+ end
23
+ end
24
+
25
+ describe "small byte serialization" do
26
+ it "loads serialized data" do
27
+ 10.times { subject.push(10) }
28
+ bytes = subject.as_small_bytes
29
+ new_tdigest = described_class.from_bytes(bytes)
30
+ # Expect some rounding error due to compression
31
+ expect(new_tdigest.percentile(0.9).round(5)).to eq(
32
+ subject.percentile(0.9).round(5)
33
+ )
34
+ expect(new_tdigest.as_small_bytes).to eq(bytes)
35
+ end
36
+
37
+ it "handles zero size" do
38
+ bytes = subject.as_small_bytes
39
+ expect(described_class.from_bytes(bytes).size).to be_zero
40
+ end
41
+ end
42
+
43
+ describe "JSON serialization" do
44
+ it "loads serialized data" do
45
+ subject.push(60, 100)
46
+ json = subject.as_json
47
+ new_tdigest = described_class.from_json(json)
48
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
49
+ end
50
+ end
51
+
52
+ describe "#percentile" do
53
+ it "returns nil if empty" do
54
+ expect(subject.percentile(0.90)).to be_nil # This should not crash
55
+ end
56
+
57
+ it "raises ArgumentError of input not between 0 and 1" do
58
+ expect { subject.percentile(1.1) }.to raise_error(ArgumentError)
59
+ end
60
+
61
+ describe "with only single value" do
62
+ it "returns the value" do
63
+ subject.push(60, 100)
64
+ expect(subject.percentile(0.90)).to eq(60)
65
+ end
66
+
67
+ it "returns 0 for all percentiles when only 0 present" do
68
+ subject.push(0)
69
+ expect(subject.percentile([0.0, 0.5, 1.0])).to eq([0, 0, 0])
70
+ end
71
+ end
72
+
73
+ describe "with alot of uniformly distributed points" do
74
+ it "has minimal error" do
75
+ seed = srand(1234) # Makes the values a proper fixture
76
+ N = 100_000
77
+ maxerr = 0
78
+ values = Array.new(N).map { rand }
79
+ srand(seed)
80
+
81
+ subject.push(values)
82
+ subject.compress!
83
+
84
+ 0.step(1, 0.1).each do |i|
85
+ q = subject.percentile(i)
86
+ maxerr = [maxerr, (i - q).abs].max
87
+ end
88
+
89
+ expect(maxerr).to be < 0.01
90
+ end
91
+ end
92
+ end
93
+
94
+ describe "#push" do
95
+ it "calls _cumulate so won't crash because of uninitialized mean_cumn" do
96
+ subject.push(
97
+ [
98
+ 125000000.0,
99
+ 104166666.66666666,
100
+ 135416666.66666666,
101
+ 104166666.66666666,
102
+ 104166666.66666666,
103
+ 93750000.0,
104
+ 125000000.0,
105
+ 62500000.0,
106
+ 114583333.33333333,
107
+ 156250000.0,
108
+ 124909090.90909092,
109
+ 104090909.0909091,
110
+ 135318181.81818184,
111
+ 104090909.0909091,
112
+ 104090909.0909091,
113
+ 93681818.18181819,
114
+ 124909090.90909092,
115
+ 62454545.45454546,
116
+ 114500000.00000001,
117
+ 156136363.63636366,
118
+ 123567567.56756756,
119
+ 102972972.97297296,
120
+ 133864864.86486486,
121
+ 102972972.97297296,
122
+ 102972972.97297296,
123
+ 92675675.67567568,
124
+ 123567567.56756756,
125
+ 61783783.78378378,
126
+ 113270270.27027026,
127
+ 154459459.45945945,
128
+ 123829787.23404256,
129
+ 103191489.36170213
130
+ ]
131
+ )
132
+ end
133
+
134
+ it "does not blow up if data comes in sorted" do
135
+ subject.push(0..10_000)
136
+ expect(subject.centroids.size).to be < 5_000
137
+ subject.compress!
138
+ expect(subject.centroids.size).to be < 1_000
139
+ end
140
+ end
141
+
142
+ describe "#size" do
143
+ it "reports the number of observations" do
144
+ n = 10_000
145
+ n.times { subject.push(rand) }
146
+ subject.compress!
147
+ expect(subject.size).to eq(n)
148
+ end
149
+ end
150
+
151
+ describe "#+" do
152
+ it "works with empty tdigests" do
153
+ other = described_class.new(0.001, 50, 1.2)
154
+ expect((subject + other).centroids.size).to eq(0)
155
+ end
156
+
157
+ describe "adding two tdigests" do
158
+ before do
159
+ @other = described_class.new(0.001, 50, 1.2)
160
+ [subject, @other].each do |td|
161
+ td.push(60, 100)
162
+ 10.times { td.push(rand * 100) }
163
+ end
164
+ end
165
+
166
+ it "has the parameters of the left argument (the calling tdigest)" do
167
+ new_tdigest = subject + @other
168
+ expect(new_tdigest.instance_variable_get(:@delta)).to eq(
169
+ subject.instance_variable_get(:@delta)
170
+ )
171
+ expect(new_tdigest.instance_variable_get(:@k)).to eq(
172
+ subject.instance_variable_get(:@k)
173
+ )
174
+ expect(new_tdigest.instance_variable_get(:@cx)).to eq(
175
+ subject.instance_variable_get(:@cx)
176
+ )
177
+ end
178
+
179
+ it "returns a tdigest with less than or equal centroids" do
180
+ new_tdigest = subject + @other
181
+ expect(new_tdigest.centroids.size)
182
+ .to be <= subject.centroids.size + @other.centroids.size
183
+ end
184
+
185
+ it "has the size of the two digests combined" do
186
+ new_tdigest = subject + @other
187
+ expect(new_tdigest.size).to eq(subject.size + @other.size)
188
+ end
189
+ end
190
+ end
191
+
192
+ describe "#merge!" do
193
+ it "works with empty tdigests" do
194
+ other = described_class.new(0.001, 50, 1.2)
195
+ subject.merge!(other)
196
+ expect(subject.centroids.size).to be_zero
197
+ end
198
+
199
+ describe "with populated tdigests" do
200
+ before do
201
+ @other = described_class.new(0.001, 50, 1.2)
202
+ [subject, @other].each do |td|
203
+ td.push(60, 100)
204
+ 10.times { td.push(rand * 100) }
205
+ end
206
+ end
207
+
208
+ it "has the parameters of the calling tdigest" do
209
+ vars = %i[@delta @k @cx]
210
+ expected = Hash[vars.map { |v| [v, subject.instance_variable_get(v)] }]
211
+ subject.merge!(@other)
212
+ vars.each do |v|
213
+ expect(subject.instance_variable_get(v)).to eq(expected[v])
214
+ end
215
+ end
216
+
217
+ it "returns a tdigest with less than or equal centroids" do
218
+ combined_size = subject.centroids.size + @other.centroids.size
219
+ subject.merge!(@other)
220
+ expect(subject.centroids.size).to be <= combined_size
221
+ end
222
+
223
+ it "has the size of the two digests combined" do
224
+ combined_size = subject.size + @other.size
225
+ subject.merge!(@other)
226
+ expect(subject.size).to eq(combined_size)
227
+ end
228
+ end
229
+ end
230
+ end