exception_handling 3.0.pre.1 → 3.0.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 +5 -5
- data/.github/CODEOWNERS +1 -0
- data/.github/workflows/pipeline.yml +36 -0
- data/.gitignore +3 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -1
- data/.tool-versions +1 -0
- data/Appraisals +13 -0
- data/CHANGELOG.md +150 -0
- data/Gemfile +10 -16
- data/Gemfile.lock +65 -128
- data/README.md +51 -19
- data/Rakefile +8 -11
- data/exception_handling.gemspec +11 -13
- data/gemfiles/rails_5.gemfile +16 -0
- data/gemfiles/rails_6.gemfile +16 -0
- data/gemfiles/rails_7.gemfile +16 -0
- data/lib/exception_handling/escalate_callback.rb +19 -0
- data/lib/exception_handling/exception_info.rb +15 -11
- data/lib/exception_handling/log_stub_error.rb +2 -1
- data/lib/exception_handling/logging_methods.rb +21 -0
- data/lib/exception_handling/testing.rb +9 -12
- data/lib/exception_handling/version.rb +1 -1
- data/lib/exception_handling.rb +83 -173
- data/{test → spec}/helpers/exception_helpers.rb +2 -2
- data/spec/rake_test_warning_false.rb +20 -0
- data/{test/test_helper.rb → spec/spec_helper.rb} +63 -66
- data/spec/unit/exception_handling/escalate_callback_spec.rb +81 -0
- data/spec/unit/exception_handling/exception_catalog_spec.rb +85 -0
- data/spec/unit/exception_handling/exception_description_spec.rb +82 -0
- data/{test/unit/exception_handling/exception_info_test.rb → spec/unit/exception_handling/exception_info_spec.rb} +170 -114
- data/{test/unit/exception_handling/log_error_stub_test.rb → spec/unit/exception_handling/log_error_stub_spec.rb} +38 -22
- data/spec/unit/exception_handling/logging_methods_spec.rb +38 -0
- data/spec/unit/exception_handling_spec.rb +1063 -0
- metadata +62 -91
- data/lib/exception_handling/honeybadger_callbacks.rb +0 -59
- data/lib/exception_handling/mailer.rb +0 -70
- data/lib/exception_handling/methods.rb +0 -101
- data/lib/exception_handling/sensu.rb +0 -28
- data/semaphore_ci/setup.sh +0 -3
- data/test/unit/exception_handling/exception_catalog_test.rb +0 -85
- data/test/unit/exception_handling/exception_description_test.rb +0 -82
- data/test/unit/exception_handling/honeybadger_callbacks_test.rb +0 -122
- data/test/unit/exception_handling/mailer_test.rb +0 -98
- data/test/unit/exception_handling/methods_test.rb +0 -84
- data/test/unit/exception_handling/sensu_test.rb +0 -52
- data/test/unit/exception_handling_test.rb +0 -1109
- data/views/exception_handling/mailer/escalate_custom.html.erb +0 -17
- data/views/exception_handling/mailer/escalation_notification.html.erb +0 -17
- data/views/exception_handling/mailer/log_parser_exception_notification.html.erb +0 -82
- /data/{test → spec}/helpers/controller_helpers.rb +0 -0
@@ -1,15 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
4
|
-
require '
|
5
|
-
require '
|
6
|
-
|
7
|
-
require 'action_mailer'
|
8
|
-
require 'action_dispatch'
|
9
|
-
require 'hobo_support'
|
10
|
-
require 'shoulda'
|
11
|
-
require 'rr'
|
12
|
-
require 'minitest/autorun'
|
3
|
+
require 'rspec'
|
4
|
+
require 'rspec/mocks'
|
5
|
+
require 'rspec_junit_formatter'
|
6
|
+
|
13
7
|
require 'pry'
|
14
8
|
require 'honeybadger'
|
15
9
|
require 'contextual_logger'
|
@@ -17,26 +11,31 @@ require 'contextual_logger'
|
|
17
11
|
require 'exception_handling'
|
18
12
|
require 'exception_handling/testing'
|
19
13
|
|
20
|
-
ActiveSupport::TestCase.test_order = :sorted
|
21
|
-
|
22
14
|
class LoggerStub
|
23
|
-
include ContextualLogger
|
24
|
-
attr_accessor :logged
|
15
|
+
include ContextualLogger::LoggerMixin
|
16
|
+
attr_accessor :logged, :level
|
25
17
|
|
26
18
|
def initialize
|
19
|
+
@level = Logger::Severity::DEBUG
|
20
|
+
@progname = nil
|
21
|
+
@logdev = nil
|
27
22
|
clear
|
28
23
|
end
|
29
24
|
|
25
|
+
def debug(message, log_context = {})
|
26
|
+
logged << { message: message, context: log_context, severity: 'DEBUG' }
|
27
|
+
end
|
28
|
+
|
30
29
|
def info(message, log_context = {})
|
31
|
-
logged << { message: message, context: log_context }
|
30
|
+
logged << { message: message, context: log_context, severity: 'INFO' }
|
32
31
|
end
|
33
32
|
|
34
33
|
def warn(message, log_context = {})
|
35
|
-
logged << { message: message, context: log_context }
|
34
|
+
logged << { message: message, context: log_context, severity: 'WARN' }
|
36
35
|
end
|
37
36
|
|
38
37
|
def fatal(message, log_context = {})
|
39
|
-
logged << { message: message, context: log_context }
|
38
|
+
logged << { message: message, context: log_context, severity: 'FATAL' }
|
40
39
|
end
|
41
40
|
|
42
41
|
def clear
|
@@ -75,50 +74,26 @@ def dont_stub_log_error
|
|
75
74
|
true
|
76
75
|
end
|
77
76
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
class ActiveSupport::TestCase
|
84
|
-
@@constant_overrides = []
|
85
|
-
|
86
|
-
setup do
|
87
|
-
unless @@constant_overrides.nil? || @@constant_overrides.empty?
|
88
|
-
raise "Uh-oh! constant_overrides left over: #{@@constant_overrides.inspect}"
|
89
|
-
end
|
90
|
-
|
91
|
-
unless defined?(Rails) && defined?(Rails.env)
|
92
|
-
module ::Rails
|
93
|
-
class << self
|
94
|
-
attr_writer :env
|
77
|
+
module TestHelper
|
78
|
+
@constant_overrides = []
|
79
|
+
class << self
|
80
|
+
attr_accessor :constant_overrides
|
81
|
+
end
|
95
82
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
end
|
100
|
-
end
|
83
|
+
def setup_constant_overrides
|
84
|
+
unless TestHelper.constant_overrides.nil? || TestHelper.constant_overrides.empty?
|
85
|
+
raise "Uh-oh! constant_overrides left over: #{TestHelper.constant_overrides.inspect}"
|
101
86
|
end
|
102
87
|
|
103
88
|
Time.now_override = nil
|
104
89
|
|
105
|
-
|
106
|
-
|
107
|
-
ExceptionHandling.email_environment = 'Test'
|
108
|
-
ExceptionHandling.sender_address = 'server@example.com'
|
109
|
-
ExceptionHandling.exception_recipients = 'exceptions@example.com'
|
110
|
-
ExceptionHandling.escalation_recipients = 'escalation@example.com'
|
90
|
+
ExceptionHandling.environment = 'not_test'
|
111
91
|
ExceptionHandling.server_name = 'server'
|
112
92
|
ExceptionHandling.filter_list_filename = "./config/exception_filters.yml"
|
113
|
-
ExceptionHandling.eventmachine_safe = false
|
114
|
-
ExceptionHandling.eventmachine_synchrony = false
|
115
|
-
ExceptionHandling.sensu_host = "127.0.0.1"
|
116
|
-
ExceptionHandling.sensu_port = 3030
|
117
|
-
ExceptionHandling.sensu_prefix = ""
|
118
93
|
end
|
119
94
|
|
120
|
-
|
121
|
-
|
95
|
+
def teardown_constant_overrides
|
96
|
+
TestHelper.constant_overrides&.reverse&.each do |parent_module, k, v|
|
122
97
|
ExceptionHandling.ensure_safe "constant cleanup #{k.inspect}, #{parent_module}(#{parent_module.class})::#{v.inspect}(#{v.class})" do
|
123
98
|
silence_warnings do
|
124
99
|
if v == :never_defined
|
@@ -129,7 +104,7 @@ class ActiveSupport::TestCase
|
|
129
104
|
end
|
130
105
|
end
|
131
106
|
end
|
132
|
-
|
107
|
+
TestHelper.constant_overrides = []
|
133
108
|
end
|
134
109
|
|
135
110
|
def set_test_const(const_name, value)
|
@@ -149,27 +124,17 @@ class ActiveSupport::TestCase
|
|
149
124
|
end
|
150
125
|
end
|
151
126
|
|
152
|
-
|
127
|
+
TestHelper.constant_overrides << [final_parent_module, final_const_name, original_value]
|
153
128
|
|
154
129
|
silence_warnings { final_parent_module.const_set(final_const_name, value) }
|
155
130
|
end
|
156
|
-
|
157
|
-
def assert_emails(expected, message = nil)
|
158
|
-
if block_given?
|
159
|
-
original_count = ActionMailer::Base.deliveries.size
|
160
|
-
yield
|
161
|
-
else
|
162
|
-
original_count = 0
|
163
|
-
end
|
164
|
-
assert_equal expected, ActionMailer::Base.deliveries.size - original_count, "wrong number of emails#{': ' + message.to_s if message}"
|
165
|
-
end
|
166
131
|
end
|
167
132
|
|
168
133
|
def assert_equal_with_diff(arg1, arg2, msg = '')
|
169
134
|
if arg1 == arg2
|
170
|
-
|
135
|
+
expect(true).to be_truthy # To keep the assertion count accurate
|
171
136
|
else
|
172
|
-
|
137
|
+
expect(arg1).to eq(arg2), "#{msg}\n#{Diff.compare(arg1, arg2)}"
|
173
138
|
end
|
174
139
|
end
|
175
140
|
|
@@ -200,3 +165,35 @@ class Time
|
|
200
165
|
end
|
201
166
|
end
|
202
167
|
end
|
168
|
+
|
169
|
+
RSpec.configure do |config|
|
170
|
+
config.add_formatter(RspecJunitFormatter, 'spec/reports/rspec.xml')
|
171
|
+
config.include TestHelper
|
172
|
+
|
173
|
+
config.before(:each) do
|
174
|
+
setup_constant_overrides
|
175
|
+
unless defined?(Rails) && defined?(Rails.env)
|
176
|
+
module Rails
|
177
|
+
class << self
|
178
|
+
attr_writer :env
|
179
|
+
|
180
|
+
def env
|
181
|
+
@env ||= 'test'
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
config.after(:each) do
|
189
|
+
teardown_constant_overrides
|
190
|
+
end
|
191
|
+
|
192
|
+
config.mock_with :rspec do |mocks|
|
193
|
+
mocks.verify_partial_doubles = true
|
194
|
+
end
|
195
|
+
|
196
|
+
config.expect_with(:rspec, :test_unit)
|
197
|
+
|
198
|
+
RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 2_000
|
199
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'exception_handling/escalate_callback'
|
4
|
+
require File.expand_path('../../spec_helper', __dir__)
|
5
|
+
|
6
|
+
module ExceptionHandling
|
7
|
+
describe EscalateCallback do
|
8
|
+
before do
|
9
|
+
class TestGem
|
10
|
+
class << self
|
11
|
+
attr_accessor :logger
|
12
|
+
end
|
13
|
+
include Escalate.mixin
|
14
|
+
end
|
15
|
+
TestGem.logger = logger
|
16
|
+
Escalate.clear_on_escalate_callbacks
|
17
|
+
end
|
18
|
+
|
19
|
+
after do
|
20
|
+
Escalate.clear_on_escalate_callbacks
|
21
|
+
end
|
22
|
+
|
23
|
+
let(:exception) do
|
24
|
+
raise "boom!"
|
25
|
+
rescue => ex
|
26
|
+
ex
|
27
|
+
end
|
28
|
+
let(:location_message) { "happened in TestGem" }
|
29
|
+
let(:context_hash) { { cuid: 'AABBCD' } }
|
30
|
+
let(:logger) { double("logger") }
|
31
|
+
|
32
|
+
describe '.register_if_configured!' do
|
33
|
+
context 'when already configured' do
|
34
|
+
before do
|
35
|
+
@original_logger = ExceptionHandling.logger
|
36
|
+
ExceptionHandling.logger = ::Logger.new('/dev/null').extend(ContextualLogger::LoggerMixin)
|
37
|
+
end
|
38
|
+
|
39
|
+
after do
|
40
|
+
ExceptionHandling.logger = @original_logger
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'registers a callback' do
|
44
|
+
EscalateCallback.register_if_configured!
|
45
|
+
|
46
|
+
expect(logger).to_not receive(:error)
|
47
|
+
expect(logger).to_not receive(:fatal)
|
48
|
+
expect(ExceptionHandling).to receive(:log_error).with(exception, location_message, context_hash)
|
49
|
+
|
50
|
+
TestGem.escalate(exception, location_message, context: context_hash)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'when not yet configured' do
|
55
|
+
before do
|
56
|
+
@original_logger = ExceptionHandling.logger
|
57
|
+
ExceptionHandling.logger = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
after do
|
61
|
+
ExceptionHandling.logger = @original_logger
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'registers a callback once the logger is set' do
|
65
|
+
EscalateCallback.register_if_configured!
|
66
|
+
|
67
|
+
expect(Escalate.on_escalate_callbacks).to be_empty
|
68
|
+
|
69
|
+
ExceptionHandling.logger = ::Logger.new('/dev/null').extend(ContextualLogger::LoggerMixin)
|
70
|
+
expect(Escalate.on_escalate_callbacks).to_not be_empty
|
71
|
+
|
72
|
+
expect(logger).to_not receive(:error)
|
73
|
+
expect(logger).to_not receive(:fatal)
|
74
|
+
expect(ExceptionHandling).to receive(:log_error).with(exception, location_message, context_hash)
|
75
|
+
|
76
|
+
TestGem.escalate(exception, location_message, context: context_hash)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require File.expand_path('../../spec_helper', __dir__)
|
4
|
+
|
5
|
+
module ExceptionHandling
|
6
|
+
describe ExceptionCatalog do
|
7
|
+
|
8
|
+
context "With stubbed yaml content" do
|
9
|
+
before do
|
10
|
+
filter_list = { exception1: { error: "my error message" },
|
11
|
+
exception2: { error: "some other message", session: "misc data" } }
|
12
|
+
allow(YAML).to receive(:load_file) { filter_list }
|
13
|
+
|
14
|
+
# bump modified time up to get the above filter loaded
|
15
|
+
allow(File).to receive(:mtime) { incrementing_mtime }
|
16
|
+
end
|
17
|
+
|
18
|
+
context "with loaded data" do
|
19
|
+
before do
|
20
|
+
allow(File).to receive(:mtime) { incrementing_mtime }
|
21
|
+
@exception_catalog = ExceptionCatalog.new(ExceptionHandling.filter_list_filename)
|
22
|
+
@exception_catalog.send :load_file
|
23
|
+
end
|
24
|
+
|
25
|
+
it "have loaded filters" do
|
26
|
+
expect(@exception_catalog.instance_eval("@filters").size).to eq(2)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "find messages in the catalog" do
|
30
|
+
expect(!@exception_catalog.find(error: "Scott says unlikely to ever match")).to be_truthy
|
31
|
+
end
|
32
|
+
|
33
|
+
it "find matching data" do
|
34
|
+
exception_description = @exception_catalog.find(error: "this is my error message, which should match something")
|
35
|
+
expect(exception_description).to be_truthy
|
36
|
+
expect(exception_description.filter_name).to eq(:exception1)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
it "write errors loading the yaml file directly to the log file" do
|
41
|
+
@exception_catalog = ExceptionCatalog.new(ExceptionHandling.filter_list_filename)
|
42
|
+
|
43
|
+
expect(ExceptionHandling).to receive(:log_error).never
|
44
|
+
expect(ExceptionHandling).to receive(:write_exception_to_log).with(anything, "ExceptionCatalog#refresh_filters: ./config/exception_filters.yml", any_args)
|
45
|
+
expect(@exception_catalog).to receive(:load_file) { raise "noooooo" }
|
46
|
+
|
47
|
+
@exception_catalog.find({})
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context "with live yaml content" do
|
52
|
+
before do
|
53
|
+
@filename = File.expand_path('../../../config/exception_filters.yml', __dir__)
|
54
|
+
@exception_catalog = ExceptionCatalog.new(@filename)
|
55
|
+
expect do
|
56
|
+
@exception_catalog.send :load_file
|
57
|
+
end.not_to raise_error
|
58
|
+
end
|
59
|
+
|
60
|
+
it "load the filter data" do
|
61
|
+
expect(!@exception_catalog.find(error: "Scott says unlikely to ever match")).to be_truthy
|
62
|
+
expect(!@exception_catalog.find(error: "Scott says unlikely to ever match")).to be_truthy
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context "with no yaml content" do
|
67
|
+
before do
|
68
|
+
@exception_catalog = ExceptionCatalog.new(nil)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "not load filter data" do
|
72
|
+
expect(ExceptionHandling).to receive(:write_exception_to_log).with(any_args).never
|
73
|
+
@exception_catalog.find(error: "Scott says unlikely to ever match")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def incrementing_mtime
|
80
|
+
@mtime ||= Time.now
|
81
|
+
@mtime += 1.day
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require File.expand_path('../../spec_helper', __dir__)
|
4
|
+
|
5
|
+
module ExceptionHandling
|
6
|
+
describe ExceptionDescription do
|
7
|
+
|
8
|
+
context "Filter" do
|
9
|
+
it "allow direct matching of strings" do
|
10
|
+
@f = ExceptionDescription.new(:filter1, error: "my error message")
|
11
|
+
expect(@f.match?('error' => "my error message")).to be_truthy
|
12
|
+
end
|
13
|
+
|
14
|
+
it "allow direct matching of strings on with symbol keys" do
|
15
|
+
@f = ExceptionDescription.new(:filter1, error: "my error message")
|
16
|
+
expect(@f.match?(error: "my error message")).to be_truthy
|
17
|
+
end
|
18
|
+
|
19
|
+
it "allow wildcards to cross line boundries" do
|
20
|
+
@f = ExceptionDescription.new(:filter1, error: "my error message.*with multiple lines")
|
21
|
+
expect(@f.match?(error: "my error message\nwith more than one, with multiple lines")).to be_truthy
|
22
|
+
end
|
23
|
+
|
24
|
+
it "complain when no regexps have a value" do
|
25
|
+
expect { ExceptionDescription.new(:filter1, error: nil) }.to raise_exception(ArgumentError, /has all blank regexes/)
|
26
|
+
end
|
27
|
+
|
28
|
+
it "report when an invalid key is passed" do
|
29
|
+
expect { ExceptionDescription.new(:filter1, error: "my error message", not_a_parameter: false) }.to raise_exception(ArgumentError, "Unknown section: not_a_parameter")
|
30
|
+
end
|
31
|
+
|
32
|
+
it "allow send_to_honeybadger to be specified and have it disabled by default" do
|
33
|
+
expect(!ExceptionDescription.new(:filter1, error: "my error message", send_to_honeybadger: false).send_to_honeybadger).to be_truthy
|
34
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", send_to_honeybadger: true).send_to_honeybadger).to be_truthy
|
35
|
+
expect(!ExceptionDescription.new(:filter1, error: "my error message").send_to_honeybadger).to be_truthy
|
36
|
+
end
|
37
|
+
|
38
|
+
it "allow send_metric to be configured" do
|
39
|
+
expect(!ExceptionDescription.new(:filter1, error: "my error message", send_metric: false).send_metric).to be_truthy
|
40
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message").send_metric).to be_truthy
|
41
|
+
end
|
42
|
+
|
43
|
+
it "provide metric name" do
|
44
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message").metric_name).to eq("filter1")
|
45
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", metric_name: :some_other_metric_name).metric_name).to eq("some_other_metric_name")
|
46
|
+
end
|
47
|
+
|
48
|
+
it "replace spaces in metric name" do
|
49
|
+
@f = ExceptionDescription.new(:"filter has spaces", error: "my error message")
|
50
|
+
expect(@f.metric_name).to eq( "filter_has_spaces")
|
51
|
+
end
|
52
|
+
|
53
|
+
it "allow notes to be recorded" do
|
54
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message").notes).to be_nil
|
55
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", notes: "a long string").notes).to eq("a long string")
|
56
|
+
end
|
57
|
+
|
58
|
+
it "not consider config options in the filter set" do
|
59
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", send_metric: false).match?(error: "my error message")).to be_truthy
|
60
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", metric_name: "false").match?(error: "my error message")).to be_truthy
|
61
|
+
expect(ExceptionDescription.new(:filter1, error: "my error message", notes: "hey").match?(error: "my error message")).to be_truthy
|
62
|
+
end
|
63
|
+
|
64
|
+
it "provide exception details" do
|
65
|
+
exception_description = ExceptionDescription.new(:filter1, error: "my error message", notes: "hey")
|
66
|
+
|
67
|
+
expected = { "send_metric" => true, "metric_name" => "filter1", "notes" => "hey" }
|
68
|
+
|
69
|
+
expect(exception_description.exception_data).to eq( expected)
|
70
|
+
end
|
71
|
+
|
72
|
+
it "match multiple email addresses" do
|
73
|
+
mobi = "ExceptionHandling::Warning: LoginAttempt::IPAddressLocked: failed login for 'mcc@mobistreak.com'"
|
74
|
+
credit = "ExceptionHandling::Warning: LoginAttempt::IPAddressLocked: failed login for 'damon@thecreditpros.com'"
|
75
|
+
|
76
|
+
exception_description = ExceptionDescription.new(:filter1, error: "ExceptionHandling::Warning: LoginAttempt::IPAddressLocked: failed login for '(mcc\@mobistreak|damon\@thecreditpros).com'")
|
77
|
+
expect(exception_description.match?(error: mobi)).to be_truthy
|
78
|
+
expect(exception_description.match?(error: credit)).to be_truthy
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|