active_sanction 1.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 +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
class Sync
|
|
10
|
+
# What one source did in one sync run, and what is stored for it now.
|
|
11
|
+
#
|
|
12
|
+
# result.source # => :ofac_sdn
|
|
13
|
+
# result.status # => :updated, :unchanged or :failed
|
|
14
|
+
# result.record_count # => 19015
|
|
15
|
+
# result.duration # => 12.41
|
|
16
|
+
# result.age # => 0
|
|
17
|
+
#
|
|
18
|
+
# Three statuses, and the distinction between the last two is the whole
|
|
19
|
+
# point of isolating sources from each other:
|
|
20
|
+
#
|
|
21
|
+
# :updated a new list version was parsed and stored
|
|
22
|
+
# :unchanged the publisher confirmed the copy we hold; nothing was written
|
|
23
|
+
# :failed something raised; **the previous snapshot was kept**
|
|
24
|
+
#
|
|
25
|
+
# ### A failed source still says what is being screened against
|
|
26
|
+
#
|
|
27
|
+
# `checksum`, `record_count`, `fetched_at` and `age` describe the snapshot
|
|
28
|
+
# that is in storage *now*, which for a failure is the one that was there
|
|
29
|
+
# before the run. That is deliberate and it is the reason this object
|
|
30
|
+
# carries an age at all: a source that has failed to refresh for nine days
|
|
31
|
+
# is still answering screening calls, and the only thing standing between
|
|
32
|
+
# that and an undetected compliance gap is that the age is visible. An
|
|
33
|
+
# alert on `result.failed?` fires once; an alert on `result.age` is what
|
|
34
|
+
# notices a source that has been quietly failing since Tuesday.
|
|
35
|
+
#
|
|
36
|
+
# `stored?` is the loud case underneath it: a source that failed with
|
|
37
|
+
# nothing stored behind it is not stale, it is absent, and screening will
|
|
38
|
+
# not cover that list at all.
|
|
39
|
+
#
|
|
40
|
+
# ### Serializable, on purpose
|
|
41
|
+
#
|
|
42
|
+
# A host application alerts on a degrading source, and it should not have
|
|
43
|
+
# to parse a log line to do it. `#to_h` is JSON-ready and `.from_h` rebuilds
|
|
44
|
+
# it; the one thing that does not survive the round-trip is the exception
|
|
45
|
+
# object, since a backtrace is not something to put in a metrics pipeline.
|
|
46
|
+
# `error_class` and `error_message` do survive, because those are what an
|
|
47
|
+
# alert is written against.
|
|
48
|
+
#
|
|
49
|
+
# Instances are frozen on construction and compare by value.
|
|
50
|
+
class Result
|
|
51
|
+
extend T::Sig
|
|
52
|
+
|
|
53
|
+
# How a sync of one source can come out. `unchanged` is the publisher
|
|
54
|
+
# answering 304, which is the common case on a list that changes
|
|
55
|
+
# daily at most, and it is a success rather than a no-op.
|
|
56
|
+
STATUSES = T.let(%i[updated unchanged failed].freeze, T::Array[Symbol])
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
MEMBERS = T.let(
|
|
60
|
+
%i[source status record_count checksum fetched_at age duration error].freeze,
|
|
61
|
+
T::Array[Symbol]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
sig { returns(Symbol) }
|
|
65
|
+
attr_reader :source
|
|
66
|
+
|
|
67
|
+
# One of STATUSES.
|
|
68
|
+
sig { returns(Symbol) }
|
|
69
|
+
attr_reader :status
|
|
70
|
+
|
|
71
|
+
# Records stored for this source now -- not records fetched. A failed
|
|
72
|
+
# source reports what its retained snapshot holds, and nil only when
|
|
73
|
+
# there is no snapshot at all.
|
|
74
|
+
sig { returns(T.nilable(Integer)) }
|
|
75
|
+
attr_reader :record_count
|
|
76
|
+
|
|
77
|
+
# The checksum of the snapshot in storage now, which is what a match
|
|
78
|
+
# result cites. Unchanged between two runs means the same list answered
|
|
79
|
+
# both.
|
|
80
|
+
sig { returns(T.nilable(String)) }
|
|
81
|
+
attr_reader :checksum
|
|
82
|
+
|
|
83
|
+
# When the snapshot in storage now was fetched -- for a failure, before
|
|
84
|
+
# this run.
|
|
85
|
+
sig { returns(T.nilable(Time)) }
|
|
86
|
+
attr_reader :fetched_at
|
|
87
|
+
|
|
88
|
+
# Seconds between `fetched_at` and the end of this source's sync, fixed
|
|
89
|
+
# here rather than computed on demand so that a serialized report says
|
|
90
|
+
# how stale the data was when the run saw it and not how long the report
|
|
91
|
+
# has since sat in a queue.
|
|
92
|
+
sig { returns(T.nilable(Integer)) }
|
|
93
|
+
attr_reader :age
|
|
94
|
+
|
|
95
|
+
# Wall-clock seconds this source took, fetch through store.
|
|
96
|
+
sig { returns(Float) }
|
|
97
|
+
attr_reader :duration
|
|
98
|
+
|
|
99
|
+
# The exception that was captured, for a caller that wants the backtrace.
|
|
100
|
+
# nil after a round-trip through #to_h -- see the class comment.
|
|
101
|
+
sig { returns(T.nilable(Exception)) }
|
|
102
|
+
attr_reader :exception
|
|
103
|
+
|
|
104
|
+
# Rebuilds from #to_h output, accepting string keys so a report survives
|
|
105
|
+
# the trip through JSON.
|
|
106
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
107
|
+
def self.from_h(hash)
|
|
108
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
109
|
+
unknown = attributes.keys - MEMBERS
|
|
110
|
+
raise InvalidArgument, "unknown Sync::Result attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
111
|
+
|
|
112
|
+
# `new(**hash)` past required keyword parameters is one of the few
|
|
113
|
+
# things Sorbet cannot check statically; #initialize validates what
|
|
114
|
+
# arrives.
|
|
115
|
+
T.unsafe(self).new(**attributes)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `error` takes the exception itself -- which is what the orchestrator
|
|
119
|
+
# captured -- or the `{class:, message:}` pair #to_h wrote.
|
|
120
|
+
sig do
|
|
121
|
+
params(source: T.untyped, status: T.untyped, duration: T.untyped, record_count: T.untyped,
|
|
122
|
+
checksum: T.untyped, fetched_at: T.untyped, age: T.untyped, error: T.untyped).void
|
|
123
|
+
end
|
|
124
|
+
def initialize(source:, status:, duration: 0.0, record_count: nil, checksum: nil, fetched_at: nil,
|
|
125
|
+
age: nil, error: nil)
|
|
126
|
+
@source = T.let(symbol!(:source, source), Symbol)
|
|
127
|
+
@status = T.let(status!(status), Symbol)
|
|
128
|
+
@duration = T.let(duration.to_f, Float)
|
|
129
|
+
@record_count = T.let(count!(record_count), T.nilable(Integer))
|
|
130
|
+
@checksum = T.let(string_or_nil(checksum), T.nilable(String))
|
|
131
|
+
@fetched_at = T.let(time_or_nil(fetched_at), T.nilable(Time))
|
|
132
|
+
@age = T.let(integer_or_nil(age), T.nilable(Integer))
|
|
133
|
+
@exception = T.let(error.is_a?(Exception) ? error : nil, T.nilable(Exception))
|
|
134
|
+
@failure = T.let(failure!(error), T.nilable(T::Hash[Symbol, String]))
|
|
135
|
+
freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The exception's class name, and its message. Strings rather than the
|
|
139
|
+
# class itself: this is what survives into a metrics pipeline, and a
|
|
140
|
+
# constant that no longer exists in the process reading a year-old report
|
|
141
|
+
# is not something to make it resolve.
|
|
142
|
+
sig { returns(T.nilable(String)) }
|
|
143
|
+
def error_class = @failure&.fetch(:class)
|
|
144
|
+
|
|
145
|
+
sig { returns(T.nilable(String)) }
|
|
146
|
+
def error_message = @failure&.fetch(:message)
|
|
147
|
+
|
|
148
|
+
sig { returns(T::Boolean) }
|
|
149
|
+
def updated? = status == :updated
|
|
150
|
+
|
|
151
|
+
sig { returns(T::Boolean) }
|
|
152
|
+
def unchanged? = status == :unchanged
|
|
153
|
+
|
|
154
|
+
sig { returns(T::Boolean) }
|
|
155
|
+
def failed? = status == :failed
|
|
156
|
+
|
|
157
|
+
# Whether there is a snapshot to screen this source against. False is the
|
|
158
|
+
# state that matters more than a failure: a list nothing is stored for is
|
|
159
|
+
# not covered by a screening run at all.
|
|
160
|
+
sig { returns(T::Boolean) }
|
|
161
|
+
def stored? = !checksum.nil?
|
|
162
|
+
|
|
163
|
+
# A failure that kept its previous good snapshot -- the behaviour this
|
|
164
|
+
# whole run is arranged around.
|
|
165
|
+
sig { returns(T::Boolean) }
|
|
166
|
+
def retained? = failed? && stored?
|
|
167
|
+
|
|
168
|
+
# The failure on one line, for a log or a table. nil when nothing failed.
|
|
169
|
+
sig { returns(T.nilable(String)) }
|
|
170
|
+
def error
|
|
171
|
+
return nil unless error_class
|
|
172
|
+
|
|
173
|
+
message = error_message
|
|
174
|
+
# `raise SomeError` with no message gives a message that is the class
|
|
175
|
+
# name, and "SomeError: SomeError" is not a line worth logging.
|
|
176
|
+
return error_class if message.nil? || message.empty? || message == error_class
|
|
177
|
+
|
|
178
|
+
"#{error_class}: #{message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
182
|
+
def to_h
|
|
183
|
+
{
|
|
184
|
+
source: source,
|
|
185
|
+
status: status,
|
|
186
|
+
record_count: record_count,
|
|
187
|
+
checksum: checksum,
|
|
188
|
+
fetched_at: fetched_at&.iso8601,
|
|
189
|
+
age: age,
|
|
190
|
+
duration: duration,
|
|
191
|
+
error: @failure
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# "3h old" rather than "10800 seconds": this is read by a human in a
|
|
196
|
+
# summary table, and the question being asked of it is only ever "is that
|
|
197
|
+
# a long time?". A source with nothing stored says so instead, because
|
|
198
|
+
# the age of a list that is not there is not the problem with it.
|
|
199
|
+
sig { returns(String) }
|
|
200
|
+
def age_in_words
|
|
201
|
+
return "nothing stored" unless stored?
|
|
202
|
+
|
|
203
|
+
seconds = age
|
|
204
|
+
return "unknown age" if seconds.nil?
|
|
205
|
+
|
|
206
|
+
[[86_400, "d"], [3_600, "h"], [60, "m"]].each do |(unit, suffix)|
|
|
207
|
+
return "#{seconds / unit}#{suffix} old" if seconds >= unit
|
|
208
|
+
end
|
|
209
|
+
"just fetched"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
sig { returns(String) }
|
|
213
|
+
def to_s
|
|
214
|
+
detail = failed? ? error.to_s : "#{record_count || 0} records, #{age_in_words}"
|
|
215
|
+
"#{source} #{status} in #{format("%.2f", duration)}s: #{detail}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
219
|
+
def ==(other)
|
|
220
|
+
return false unless other.instance_of?(self.class)
|
|
221
|
+
|
|
222
|
+
to_h == other.to_h
|
|
223
|
+
end
|
|
224
|
+
alias eql? ==
|
|
225
|
+
|
|
226
|
+
sig { returns(Integer) }
|
|
227
|
+
def hash = [self.class, to_h].hash
|
|
228
|
+
|
|
229
|
+
sig { returns(String) }
|
|
230
|
+
def inspect = "#<#{self.class} #{self}>"
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
235
|
+
def status!(value)
|
|
236
|
+
status = symbol!(:status, value)
|
|
237
|
+
return status if STATUSES.include?(status)
|
|
238
|
+
|
|
239
|
+
raise InvalidArgument, "status must be one of #{STATUSES.join(", ")}, got #{value.inspect}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Takes the exception the orchestrator captured, or the pair #to_h wrote
|
|
243
|
+
# -- with string keys, since a report that has been through JSON has
|
|
244
|
+
# string keys all the way down.
|
|
245
|
+
sig { params(value: T.untyped).returns(T.nilable(T::Hash[Symbol, String])) }
|
|
246
|
+
def failure!(value)
|
|
247
|
+
case value
|
|
248
|
+
when nil then nil
|
|
249
|
+
when Exception then { class: value.class.name.to_s, message: value.message.to_s }.freeze
|
|
250
|
+
else
|
|
251
|
+
pair = value.to_h.transform_keys(&:to_sym)
|
|
252
|
+
name = string_or_nil(pair[:class])
|
|
253
|
+
name && { class: name, message: pair[:message].to_s }.freeze
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
258
|
+
def count!(value)
|
|
259
|
+
integer = integer_or_nil(value)
|
|
260
|
+
raise InvalidArgument, "record_count cannot be negative, got #{integer}" if integer&.negative?
|
|
261
|
+
|
|
262
|
+
integer
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# An age may be negative where a publisher's clock is ahead of ours, so
|
|
266
|
+
# unlike a record count it is not checked for it.
|
|
267
|
+
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
268
|
+
def integer_or_nil(value) = value.nil? ? nil : Integer(value)
|
|
269
|
+
|
|
270
|
+
sig { params(value: T.untyped).returns(T.nilable(Time)) }
|
|
271
|
+
def time_or_nil(value)
|
|
272
|
+
case value
|
|
273
|
+
when nil then nil
|
|
274
|
+
when Time then Time.at(value.to_i).utc
|
|
275
|
+
when String then Time.at(Time.parse(value).to_i).utc
|
|
276
|
+
else raise InvalidArgument, "fetched_at is not a time: #{value.inspect}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
281
|
+
def symbol!(member, value)
|
|
282
|
+
raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
|
|
283
|
+
|
|
284
|
+
value.to_sym
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
288
|
+
def string_or_nil(value)
|
|
289
|
+
return nil if value.nil?
|
|
290
|
+
|
|
291
|
+
string = value.to_s.strip
|
|
292
|
+
string.empty? ? nil : -string
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "uri"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/sources"
|
|
9
|
+
require "active_sanction/storage"
|
|
10
|
+
require "active_sanction/sync/result"
|
|
11
|
+
require "active_sanction/sync/report"
|
|
12
|
+
|
|
13
|
+
module ActiveSanction
|
|
14
|
+
# Fetch, parse and store every configured list in one run, isolating each
|
|
15
|
+
# source from the others.
|
|
16
|
+
#
|
|
17
|
+
# report = ActiveSanction.sync! # every configured source
|
|
18
|
+
# report = ActiveSanction.sync!(:ofac_sdn) # one
|
|
19
|
+
# report = ActiveSanction.sync!(force: true) # bypass conditional GET
|
|
20
|
+
#
|
|
21
|
+
# report.failed? # => false
|
|
22
|
+
# report[:ofac_sdn].record_count # => 19015
|
|
23
|
+
#
|
|
24
|
+
# One source at a time is Sources::Base#sync, which fetches, parses and
|
|
25
|
+
# checksums, and deliberately rescues nothing. This is the layer above it:
|
|
26
|
+
# what a *run* does, which is a different set of decisions.
|
|
27
|
+
#
|
|
28
|
+
# ### One source failing must not abort the others
|
|
29
|
+
#
|
|
30
|
+
# Government endpoints go down, change format without notice, and
|
|
31
|
+
# occasionally serve half a file. If a UN outage stopped OFAC from syncing,
|
|
32
|
+
# the library would fail exactly when it is most needed -- during an
|
|
33
|
+
# incident, which is when lists move. So every source runs inside its own
|
|
34
|
+
# rescue: the failure is captured into its Result, the remaining sources
|
|
35
|
+
# carry on, and the run ends with a summary that says which one broke.
|
|
36
|
+
#
|
|
37
|
+
# StandardError and not Exception. An Interrupt or a SIGTERM is somebody
|
|
38
|
+
# stopping this run on purpose, and swallowing it to go on downloading three
|
|
39
|
+
# more lists is not isolation, it is a job that will not die.
|
|
40
|
+
#
|
|
41
|
+
# ### A failed source keeps its previous snapshot
|
|
42
|
+
#
|
|
43
|
+
# This is the single most important behaviour here, and it is a decision
|
|
44
|
+
# about what "no data" costs. Nothing clears a source's stored list on
|
|
45
|
+
# failure -- not a 500, not a parse error, not a publisher that started
|
|
46
|
+
# serving HTML where XML used to be. Screening against yesterday's OFAC list
|
|
47
|
+
# produces a report with a known, visible age on it; screening against an
|
|
48
|
+
# empty list produces a clean report for every customer, which is the most
|
|
49
|
+
# expensive thing this library can get wrong.
|
|
50
|
+
#
|
|
51
|
+
# That trade is only safe while the age is visible, so every Result carries
|
|
52
|
+
# the age and record count of the snapshot that source is being screened
|
|
53
|
+
# against now -- see Sync::Result, which is where the failure ends up.
|
|
54
|
+
#
|
|
55
|
+
# ### Unchanged sources cost nothing
|
|
56
|
+
#
|
|
57
|
+
# The launch lists change daily at most and every one of them serves ETag and
|
|
58
|
+
# Last-Modified, so an hourly sync should transfer bytes once a day. A source
|
|
59
|
+
# whose publisher answers 304 is never parsed and never stored: the whole
|
|
60
|
+
# saving of conditional GET (#10) is that the parse -- the expensive half for
|
|
61
|
+
# OFAC's three-file join -- is skipped along with the download.
|
|
62
|
+
#
|
|
63
|
+
# A source whose bytes changed but whose *content* hashes to what is already
|
|
64
|
+
# stored is also reported unchanged and not rewritten. A publisher
|
|
65
|
+
# regenerating an identical file with a new timestamp is not a new list
|
|
66
|
+
# version, and rewriting tens of megabytes to say so would churn the
|
|
67
|
+
# checksum every audit record cites.
|
|
68
|
+
#
|
|
69
|
+
# ### Optional parallelism, with a politeness limit
|
|
70
|
+
#
|
|
71
|
+
# ActiveSanction.sync!(concurrency: 3)
|
|
72
|
+
#
|
|
73
|
+
# Sources that share a publisher are never fetched at the same time. They are
|
|
74
|
+
# grouped by the host they download from and each group runs sequentially, so
|
|
75
|
+
# raising concurrency fetches from more governments at once and never harder
|
|
76
|
+
# from any one of them -- which matters because two of the built-in adapters
|
|
77
|
+
# (OFAC SDN and OFAC Consolidated) are the same file server. The default is 1
|
|
78
|
+
# and a run of four lists takes about as long as its slowest list.
|
|
79
|
+
#
|
|
80
|
+
# Each source is fetched by its own adapter, so nothing is shared between two
|
|
81
|
+
# sources in flight except the files underneath them: the validator store is
|
|
82
|
+
# one small JSON file, written by rename and resolving last-writer-wins,
|
|
83
|
+
# exactly as it already does for a sync running beside a CLI command. The
|
|
84
|
+
# cost of losing that race is one avoidable download on the next run, and it
|
|
85
|
+
# is the reason the default is sequential rather than the reason parallelism
|
|
86
|
+
# is unsafe.
|
|
87
|
+
#
|
|
88
|
+
# ### Where this belongs
|
|
89
|
+
#
|
|
90
|
+
# Syncing is a capability of the local backend (#56), not of every backend: a
|
|
91
|
+
# hosted one does not sync, because data freshness is exactly what its user
|
|
92
|
+
# is paying somebody else to handle, and it should say so through
|
|
93
|
+
# `supports?(:sync)`. When that seam lands this becomes `Backend::Local#sync`
|
|
94
|
+
# unchanged -- which is why the report is a serializable object rather than
|
|
95
|
+
# console output, and why nothing here writes to `$stdout`.
|
|
96
|
+
class Sync
|
|
97
|
+
extend T::Sig
|
|
98
|
+
|
|
99
|
+
# Raised by Report#success!, for a caller that wants any failure fatal.
|
|
100
|
+
# Never raised by the run itself: by the time a failure is known, every
|
|
101
|
+
# other source has already been fetched and stored.
|
|
102
|
+
#
|
|
103
|
+
# The one error in the hierarchy that is about a *run* rather than about
|
|
104
|
+
# one list, which is why it hangs off Error directly and carries the whole
|
|
105
|
+
# report rather than a single #source_id.
|
|
106
|
+
class Failed < StandardError
|
|
107
|
+
include ActiveSanction::Error
|
|
108
|
+
extend T::Sig
|
|
109
|
+
|
|
110
|
+
sig { returns(Report) }
|
|
111
|
+
attr_reader :report
|
|
112
|
+
|
|
113
|
+
sig { params(report: Report).void }
|
|
114
|
+
def initialize(report)
|
|
115
|
+
@report = T.let(report, Report)
|
|
116
|
+
super(report.failure_message)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True only when every source that failed failed retryably -- a run with
|
|
120
|
+
# one publisher timing out is worth re-running, and a run with a parse
|
|
121
|
+
# error in it is not going to come out differently in five minutes. A
|
|
122
|
+
# failure whose exception did not survive a round-trip through #to_h
|
|
123
|
+
# counts as not retryable, since nothing is known about it.
|
|
124
|
+
sig { returns(T::Boolean) }
|
|
125
|
+
def retryable?
|
|
126
|
+
failures = report.failed.map(&:exception)
|
|
127
|
+
retryable_or(failures.any? && failures.all? { |e| e.is_a?(ActiveSanction::Error) && e.retryable? })
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The adapters this run covers: classes as registered, or instances a
|
|
132
|
+
# caller passed in.
|
|
133
|
+
sig { returns(T::Array[T.untyped]) }
|
|
134
|
+
attr_reader :sources
|
|
135
|
+
|
|
136
|
+
sig { returns(T::Array[Symbol]) }
|
|
137
|
+
attr_reader :keys
|
|
138
|
+
|
|
139
|
+
sig { returns(T.untyped) }
|
|
140
|
+
attr_reader :store
|
|
141
|
+
|
|
142
|
+
sig { returns(T::Boolean) }
|
|
143
|
+
attr_reader :force
|
|
144
|
+
|
|
145
|
+
# How many publishers to fetch from at once. See the class comment.
|
|
146
|
+
sig { returns(Integer) }
|
|
147
|
+
attr_reader :concurrency
|
|
148
|
+
|
|
149
|
+
sig { returns(T.untyped) }
|
|
150
|
+
attr_reader :logger
|
|
151
|
+
|
|
152
|
+
sig { params(options: T.untyped, block: T.untyped).returns(Report) }
|
|
153
|
+
def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
|
|
154
|
+
|
|
155
|
+
# `sources:` takes source keys, adapter classes, adapter instances, or nil
|
|
156
|
+
# for whatever `config.sources` names. A key nothing is registered under
|
|
157
|
+
# raises here, before the first list is downloaded, rather than after.
|
|
158
|
+
sig do
|
|
159
|
+
params(sources: T.untyped, store: T.untyped, force: T::Boolean, concurrency: T.untyped,
|
|
160
|
+
logger: T.untyped).void
|
|
161
|
+
end
|
|
162
|
+
def initialize(sources: nil, store: nil, force: false, concurrency: nil, logger: ActiveSanction.config.logger)
|
|
163
|
+
@sources = T.let(resolve(sources), T::Array[T.untyped])
|
|
164
|
+
@keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
|
|
165
|
+
@store = T.let(store || ActiveSanction.storage, T.untyped)
|
|
166
|
+
@force = T.let(force, T::Boolean)
|
|
167
|
+
@concurrency = T.let(
|
|
168
|
+
Configuration.sync_concurrency!(concurrency || ActiveSanction.config.sync_concurrency), Integer
|
|
169
|
+
)
|
|
170
|
+
@logger = T.let(logger, T.untyped)
|
|
171
|
+
@lock = T.let(Mutex.new, Mutex)
|
|
172
|
+
# The settings this run was started under, so a worker thread reads them
|
|
173
|
+
# rather than the default client's. A configuration is fiber-local and a
|
|
174
|
+
# `Thread.new` does not inherit one -- see
|
|
175
|
+
# ActiveSanction.with_configuration -- so a run through a Client with its
|
|
176
|
+
# own User-Agent would otherwise identify itself as that client on the
|
|
177
|
+
# first source and as the default on the next three, purely according to
|
|
178
|
+
# `concurrency:`.
|
|
179
|
+
@configuration = T.let(ActiveSanction.config, Configuration)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Runs the sync and returns the Report. Never raises for a source that
|
|
183
|
+
# failed -- that is what the report is for -- and does raise for anything
|
|
184
|
+
# that makes the run itself impossible, such as a store that cannot be
|
|
185
|
+
# written to at all.
|
|
186
|
+
#
|
|
187
|
+
# The optional block is the progress hook: it is called with each Result as
|
|
188
|
+
# that source finishes, so a long run says something before it ends. It is
|
|
189
|
+
# called under a lock, so a block that appends to an array or writes a line
|
|
190
|
+
# does not have to be thread-safe to be correct under `concurrency:`.
|
|
191
|
+
#
|
|
192
|
+
# ActiveSanction.sync! { |result| puts result }
|
|
193
|
+
sig { params(block: T.nilable(T.proc.params(result: Result).void)).returns(Report) }
|
|
194
|
+
def call(&block)
|
|
195
|
+
started_at = Time.now.utc
|
|
196
|
+
began = monotonic
|
|
197
|
+
log_start
|
|
198
|
+
work = keys.each_with_index.map { |key, at| [at, key, sources.fetch(at)] }
|
|
199
|
+
results = run(work, &block).sort_by(&:first).map(&:last)
|
|
200
|
+
report = Report.new(results: results, started_at: started_at, duration: elapsed(began))
|
|
201
|
+
log_finish(report)
|
|
202
|
+
report
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
sig { returns(String) }
|
|
206
|
+
def inspect = "#<#{self.class} #{keys.join(", ")}#{" forced" if force} concurrency=#{concurrency}>"
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
sig { params(requested: T.untyped).returns(T::Array[T.untyped]) }
|
|
211
|
+
def resolve(requested)
|
|
212
|
+
listed = Array(requested).flatten.compact
|
|
213
|
+
return Sources.enabled if listed.empty?
|
|
214
|
+
|
|
215
|
+
listed.map { |source| adapter!(source) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A key is looked up in the registry; a class is left to be built inside
|
|
219
|
+
# the run's rescue, so a source whose constructor raises is that source's
|
|
220
|
+
# failure and not the whole run's; an instance a caller built itself is
|
|
221
|
+
# used as it stands.
|
|
222
|
+
sig { params(source: T.untyped).returns(T.untyped) }
|
|
223
|
+
def adapter!(source)
|
|
224
|
+
return Sources[source] if source.is_a?(Symbol) || source.is_a?(String)
|
|
225
|
+
return source if source.respond_to?(:key) && source.respond_to?(source.is_a?(Class) ? :new : :sync)
|
|
226
|
+
|
|
227
|
+
raise InvalidArgument, "a source must be a registered key, or answer .key and .new, got #{source.inspect}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Groups that may run at the same time, each of which runs in order. See
|
|
231
|
+
# the class comment on politeness.
|
|
232
|
+
sig do
|
|
233
|
+
params(work: T::Array[T.untyped], block: T.nilable(T.proc.params(result: Result).void))
|
|
234
|
+
.returns(T::Array[T.untyped])
|
|
235
|
+
end
|
|
236
|
+
def run(work, &block)
|
|
237
|
+
groups = work.group_by { |(_at, key, source)| publisher(key, source) }.values
|
|
238
|
+
workers = [concurrency, groups.size].min
|
|
239
|
+
return groups.flatten(1).map { |item| pair(item, &block) } if workers < 2
|
|
240
|
+
|
|
241
|
+
queue = Queue.new
|
|
242
|
+
groups.each { |group| queue << group }
|
|
243
|
+
queue.close
|
|
244
|
+
Array.new(workers) do
|
|
245
|
+
Thread.new { ActiveSanction.with_configuration(@configuration) { drain(queue, &block) } }
|
|
246
|
+
end.flat_map(&:value)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
sig do
|
|
250
|
+
params(queue: Queue, block: T.nilable(T.proc.params(result: Result).void)).returns(T::Array[T.untyped])
|
|
251
|
+
end
|
|
252
|
+
def drain(queue, &block)
|
|
253
|
+
collected = []
|
|
254
|
+
while (group = queue.pop)
|
|
255
|
+
group.each { |item| collected << pair(item, &block) }
|
|
256
|
+
end
|
|
257
|
+
collected
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
sig do
|
|
261
|
+
params(item: T::Array[T.untyped], block: T.nilable(T.proc.params(result: Result).void))
|
|
262
|
+
.returns(T::Array[T.untyped])
|
|
263
|
+
end
|
|
264
|
+
def pair(item, &block)
|
|
265
|
+
at, key, source = item
|
|
266
|
+
result = sync_source(key, source)
|
|
267
|
+
@lock.synchronize do
|
|
268
|
+
log_result(result)
|
|
269
|
+
block&.call(result)
|
|
270
|
+
end
|
|
271
|
+
[at, result]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# One source, start to finish, inside its own rescue. Nothing in here may
|
|
275
|
+
# raise past this method, and nothing in here may clear what is stored.
|
|
276
|
+
sig { params(key: Symbol, source: T.untyped).returns(Result) }
|
|
277
|
+
def sync_source(key, source)
|
|
278
|
+
started = monotonic
|
|
279
|
+
previous = T.let(nil, T.nilable(Storage::Meta))
|
|
280
|
+
begin
|
|
281
|
+
# What is stored is read before the adapter is built, so that a source
|
|
282
|
+
# whose constructor raises still reports the snapshot it is keeping.
|
|
283
|
+
previous = readable_meta(key)
|
|
284
|
+
adapter = source.is_a?(Class) ? T.unsafe(source).new : source
|
|
285
|
+
snapshot = adapter.sync(force: force || previous.nil?)
|
|
286
|
+
return complete(key, :unchanged, previous, started) if unchanged?(previous, snapshot)
|
|
287
|
+
|
|
288
|
+
store.write_snapshot(snapshot)
|
|
289
|
+
complete(key, :updated, Storage::Meta.from_snapshot(snapshot), started)
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
complete(key, :failed, previous, started, stamp(key, e))
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# A failure captured for a source names that source, even when it was
|
|
296
|
+
# raised somewhere that could not know -- a store that will not open, an
|
|
297
|
+
# adapter constructor. Only ever fills a blank; see Error#in_source.
|
|
298
|
+
sig { params(key: Symbol, error: StandardError).returns(StandardError) }
|
|
299
|
+
def stamp(key, error)
|
|
300
|
+
error.is_a?(ActiveSanction::Error) ? error.in_source(key) : error
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A publisher that answered 304, or one that served a file whose parsed
|
|
304
|
+
# content is what is already stored.
|
|
305
|
+
sig { params(previous: T.nilable(Storage::Meta), snapshot: T.untyped).returns(T::Boolean) }
|
|
306
|
+
def unchanged?(previous, snapshot)
|
|
307
|
+
snapshot.nil? || (!previous.nil? && previous.same_content?(snapshot))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
sig do
|
|
311
|
+
params(key: Symbol, status: Symbol, meta: T.nilable(Storage::Meta), started: Float, error: T.untyped)
|
|
312
|
+
.returns(Result)
|
|
313
|
+
end
|
|
314
|
+
def complete(key, status, meta, started, error = nil)
|
|
315
|
+
Result.new(source: key, status: status, duration: elapsed(started), record_count: meta&.record_count,
|
|
316
|
+
checksum: meta&.checksum, fetched_at: meta&.fetched_at, age: meta&.age, error: error)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# What is stored for a source, or nil -- and nil for a stored snapshot that
|
|
320
|
+
# cannot be read, which is why this is not simply `store.snapshot_meta`.
|
|
321
|
+
#
|
|
322
|
+
# Both of those answers make the run fetch this source in full rather than
|
|
323
|
+
# conditionally, and the reason is the same for each: a conditional request
|
|
324
|
+
# asks the publisher whether the copy we hold is current, and we do not
|
|
325
|
+
# hold one. Letting a 304 stand against a list that is missing or corrupt
|
|
326
|
+
# would report a source unchanged that cannot be screened at all.
|
|
327
|
+
sig { params(key: Symbol).returns(T.nilable(Storage::Meta)) }
|
|
328
|
+
def readable_meta(key)
|
|
329
|
+
store.snapshot_meta(key)
|
|
330
|
+
rescue StandardError => e
|
|
331
|
+
log(:warn, "#{key} has a stored snapshot that cannot be read (#{e.class}: #{e.message}); fetching in full")
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Which publisher a source downloads from, as the grouping key. A source
|
|
336
|
+
# that cannot say -- one backed by a database table, one whose declaration
|
|
337
|
+
# raises -- gets a group of its own, since there is nobody it could be
|
|
338
|
+
# impolite towards.
|
|
339
|
+
sig { params(key: Symbol, source: T.untyped).returns(String) }
|
|
340
|
+
def publisher(key, source)
|
|
341
|
+
address = source.respond_to?(:urls) ? source.urls.values.first : nil
|
|
342
|
+
host = address.nil? ? nil : URI.parse(address.to_s).host
|
|
343
|
+
host.nil? || host.empty? ? "source:#{key}" : host.downcase
|
|
344
|
+
rescue StandardError
|
|
345
|
+
"source:#{key}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
sig { returns(Float) }
|
|
349
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
350
|
+
|
|
351
|
+
sig { params(started: Float).returns(Float) }
|
|
352
|
+
def elapsed(started) = (monotonic - started).round(3).to_f
|
|
353
|
+
|
|
354
|
+
sig { void }
|
|
355
|
+
def log_start
|
|
356
|
+
log(:info, "syncing #{keys.size} source(s): #{keys.join(", ")}#{" (forced)" if force}" \
|
|
357
|
+
"#{", #{concurrency} publishers at a time" if concurrency > 1}")
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# The line an operator greps for, and the one an on-call engineer reads at
|
|
361
|
+
# three in the morning: a failure has to say what is still being screened
|
|
362
|
+
# against, not merely that something broke.
|
|
363
|
+
sig { params(result: Result).void }
|
|
364
|
+
def log_result(result)
|
|
365
|
+
message = +result.to_s
|
|
366
|
+
if result.retained?
|
|
367
|
+
message << "; keeping the previous snapshot of #{result.record_count} records (#{result.age_in_words})"
|
|
368
|
+
elsif result.failed?
|
|
369
|
+
message << "; nothing is stored for this source, so screening does not cover it"
|
|
370
|
+
end
|
|
371
|
+
log(result.failed? ? :warn : :info, message)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
sig { params(report: Report).void }
|
|
375
|
+
def log_finish(report)
|
|
376
|
+
log(report.failed? ? :warn : :info, "synced #{report.summary}")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Configuration only promises a logger that answers #info, so a failure is
|
|
380
|
+
# logged at warn where the logger has one and at info where it does not --
|
|
381
|
+
# rather than not at all.
|
|
382
|
+
sig { params(level: Symbol, message: String).void }
|
|
383
|
+
def log(level, message)
|
|
384
|
+
return unless logger
|
|
385
|
+
|
|
386
|
+
line = "[active_sanction] #{message}"
|
|
387
|
+
logger.respond_to?(level) ? logger.public_send(level, line) : logger.info(line)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|